diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 3f1385f67cce..f181251864ab 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -2,74 +2,78 @@ # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 17989 CLI/cmux.swift -16364 Sources/TerminalController.swift -15762 Sources/ContentView.swift -13748 Sources/AppDelegate.swift -13702 Sources/Workspace.swift -13208 Sources/GhosttyTerminalView.swift -10549 Sources/Panels/BrowserPanel.swift -8419 Sources/cmuxApp.swift -7490 Sources/TabManager.swift -6811 Sources/Panels/BrowserPanelView.swift -5139 cmuxTests/AppDelegateShortcutRoutingTests.swift -4641 cmuxTests/WorkspaceUnitTests.swift -4531 cmuxTests/TerminalAndGhosttyTests.swift -4503 cmuxTests/GhosttyConfigTests.swift -4178 Sources/BrowserWindowPortal.swift -3818 Sources/Feed/FeedPanelView.swift -3660 cmuxTests/BrowserConfigTests.swift -3128 cmuxTests/BrowserPanelTests.swift -3126 cmuxTests/WorkspaceRemoteConnectionTests.swift -2850 Sources/CmuxConfig.swift -2675 Sources/KeyboardShortcutSettings.swift -2491 Sources/Panels/CmuxWebView.swift -2292 cmuxTests/TabManagerUnitTests.swift -2153 cmuxTests/SessionPersistenceTests.swift -2110 Sources/TerminalWindowPortal.swift -2026 cmuxTests/CJKIMEInputTests.swift -1949 Sources/FileExplorerView.swift -1949 Sources/Panels/BrowserWebAuthnSupport.swift -1879 Sources/SessionIndexStore.swift -1735 Sources/SessionIndexView.swift -1692 cmuxTests/CmuxConfigTests.swift -1677 cmuxTests/ShortcutAndCommandPaletteTests.swift -1676 Sources/KeyboardShortcutSettingsFileStore.swift -1563 cmuxTests/WindowAndDragTests.swift -1517 cmuxUITests/BrowserPaneNavigationKeybindUITests.swift -1502 Sources/Update/UpdateTitlebarAccessory.swift -1423 Sources/TerminalNotificationStore.swift +16092 Sources/TerminalController.swift +15634 Sources/ContentView.swift +14192 Sources/Workspace.swift +13603 Sources/AppDelegate.swift +13147 Sources/GhosttyTerminalView.swift +11446 Sources/Panels/BrowserPanel.swift +8555 Sources/cmuxApp.swift +7172 Sources/TabManager.swift +7070 Sources/Panels/BrowserPanelView.swift +6521 cmuxTests/WorkspaceUnitTests.swift +4969 cmuxTests/AppDelegateShortcutRoutingTests.swift +4616 cmuxTests/BrowserPanelTests.swift +4276 cmuxTests/TerminalAndGhosttyTests.swift +4273 cmuxTests/GhosttyConfigTests.swift +4156 Sources/BrowserWindowPortal.swift +3925 cmuxTests/BrowserConfigTests.swift +3817 Sources/Feed/FeedPanelView.swift +2999 cmuxTests/WorkspaceRemoteConnectionTests.swift +2490 Sources/Panels/CmuxWebView.swift +2195 cmuxTests/TabManagerUnitTests.swift +2086 cmuxUITests/BrowserPaneNavigationKeybindUITests.swift +2054 Sources/TerminalWindowPortal.swift +1948 Sources/FileExplorerView.swift +1921 Sources/Panels/BrowserWebAuthnSupport.swift +1878 Sources/SessionIndexStore.swift +1842 Sources/KeyboardShortcutSettingsFileStore.swift +1831 Sources/KeyboardShortcutSettings.swift +1734 Sources/SessionIndexView.swift +1658 cmuxTests/CJKIMEInputTests.swift +1519 Sources/Update/UpdateTitlebarAccessory.swift +1460 cmuxTests/ShortcutAndCommandPaletteTests.swift +1429 Sources/TerminalNotificationStore.swift +1377 cmuxTests/WindowAndDragTests.swift 1365 Sources/Feed/FeedButtonStyleDebugWindowController.swift 1292 cmuxUITests/SidebarHelpMenuUITests.swift -1255 cmuxTests/NotificationAndMenuBarTests.swift +1254 cmuxTests/NotificationAndMenuBarTests.swift 1215 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift 1187 cmuxUITests/MultiWindowNotificationsUITests.swift 1165 Sources/Auth/AuthManager.swift -1001 cmuxTests/SidebarOrderingTests.swift +1118 cmuxTests/SessionPersistenceTests.swift +1000 cmuxTests/SidebarOrderingTests.swift 991 Sources/FileExplorerStore.swift 987 Sources/CommandPalette/CommandPaletteSearch.swift -976 cmuxTests/OmnibarAndToolsTests.swift +977 cmuxTests/OmnibarAndToolsTests.swift 908 cmuxTests/CommandPaletteSearchEngineTests.swift -840 Sources/WorkspaceContentView.swift 834 Sources/RestorableAgentSession.swift 829 cmuxUITests/TerminalCmdClickUITests.swift 807 Sources/TerminalSSHSessionDetector.swift 785 Sources/App/TerminalDirectoryOpenSupport.swift -761 Sources/GhosttyConfig.swift 752 cmuxUITests/CloseWorkspaceCmdDUITests.swift +748 Sources/WorkspaceContentView.swift +733 Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNativeTabButtonView.swift +722 Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutController.swift 719 cmuxUITests/BrowserOmnibarSuggestionsUITests.swift +717 Sources/GhosttyConfig.swift +715 Sources/App/ShortcutRoutingSupport.swift 714 Sources/AppleScriptSupport.swift 708 Sources/App/MenuBarExtraController.swift +707 cmuxTests/WorkspaceContentViewVisibilityTests.swift +692 cmuxTests/CmuxConfigTests.swift 683 Sources/SocketControlSettings.swift -664 Sources/App/ShortcutRoutingSupport.swift -643 Sources/Panels/BrowserPopupWindowController.swift +640 Sources/Panels/BrowserPopupWindowController.swift 632 Sources/MainWindowFocusController.swift +617 cmuxUITests/WorkspaceSplitTabDragUITests.swift +616 Sources/CmuxConfig.swift 613 Sources/PortScanner.swift -603 cmuxUITests/BonsplitTabDragUITests.swift +595 Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNativeTabBarView.swift 586 Sources/Update/UpdateViewModel.swift 582 cmuxTests/TerminalControllerSocketSecurityTests.swift 573 Sources/Feed/FeedTextEditorDebugWindowController.swift -536 Sources/SessionPersistence.swift 535 Sources/Feed/FeedCoordinator.swift +529 Sources/Find/BrowserSearchOverlay.swift 510 Sources/Update/UpdatePopoverView.swift 505 cmuxUITests/DisplayResolutionRegressionUITests.swift -504 Sources/WindowDragHandleView.swift +503 Sources/WindowDragHandleView.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c06c7a5895b2..54a89ee1762b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -565,6 +565,13 @@ jobs: spm-release- spm- + - name: Clear stale Release module caches + run: | + set -euo pipefail + rm -rf build-universal/ModuleCache.noindex + rm -rf build-universal/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules + find build-universal/Build/Intermediates.noindex -name '*Bridging-header.pch' -delete 2>/dev/null || true + - name: Build universal app (Release) run: | set -euo pipefail diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 6d276dd23338..5904cb5ca316 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -7,30 +7,59 @@ objects = { /* Begin PBXBuildFile section */ + 350DAC5EBD38642A3E81471A /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 312DE7503B4658DD173121B8 /* AuthManager.swift */; }; + 36CE99ED050785B5E96B72BB /* AuthCallbackRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A19A4145F034965110CF87 /* AuthCallbackRouter.swift */; }; + 698944F99A6ADFBA24A7BFB9 /* AuthSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B1EA8948C5F126FE63CFB4E /* AuthSettingsStore.swift */; }; + D9FEC58D5BACCF76459F1BBE /* AuthEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43430FA5929121E2EAAB3091 /* AuthEnvironment.swift */; }; + FE003101 /* SessionIndexStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003001 /* SessionIndexStore.swift */; }; + FE003102 /* SessionIndexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003002 /* SessionIndexView.swift */; }; + FE003103 /* RightSidebarPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003003 /* RightSidebarPanelView.swift */; }; + FEED0000000000000000F002 /* FeedCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F001 /* FeedCoordinator.swift */; }; + FEED0000000000000000F005 /* FeedPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F004 /* FeedPanelView.swift */; }; + FEED0000000000000000F00B /* FeedPreviewWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F00A /* FeedPreviewWindowController.swift */; }; + FEED0000000000000000F00D /* FeedButtonStyleDebugWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F00C /* FeedButtonStyleDebugWindowController.swift */; }; + FEED0000000000000000F00F /* FeedTextEditorDebugWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F00E /* FeedTextEditorDebugWindowController.swift */; }; 063BC42CEE257D6213A2E30C /* WindowAndDragTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */; }; 0F2C25F9170130F8DC09DD1B /* WorkspaceManualUnreadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */; }; + 109D06B6FEACFF411E7AAE0E /* WorkspaceLayoutNativeHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE8D508AD8E68ED52B3D7BA /* WorkspaceLayoutNativeHost.swift */; }; + 1222ADAF6D1EED56FFC27D34 /* WorkspaceSplitAppBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 702DA72CC66CD8A0D09B6831 /* WorkspaceSplitAppBridge.swift */; }; + 1485323DB5A912A32BF038AE /* WorkspaceLayoutNativeTabButtonViewDrawing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A7EBFAC731D60EC5C5A9A8E /* WorkspaceLayoutNativeTabButtonViewDrawing.swift */; }; 1521D55DC63D5E5FC4955E31 /* ShortcutAndCommandPaletteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */; }; 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */; }; + 21F5EF2BA807EFD15B3BE6A8 /* WorkspaceLayoutNativeTabBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5DE1A6410BC5B26F5BF2561 /* WorkspaceLayoutNativeTabBarView.swift */; }; + 265EB8E59168FB3206145A79 /* TabBarMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A69CDB8F3A7CDD4368C3966 /* TabBarMetrics.swift */; }; + 2955228EE41B197870285A14 /* WorkspaceLayoutInteractionHandlers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0099C7D458EC166FF3EA7519 /* WorkspaceLayoutInteractionHandlers.swift */; }; + 2A5A9A8E5EA1904E7FB76C3C /* SplitState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B5E7854635606DACB33A658 /* SplitState.swift */; }; + 2AA538C21CBEFB3299913AF8 /* WorkspaceLayoutTabChromeDebugSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8580F6BF648E80E59B486314 /* WorkspaceLayoutTabChromeDebugSettings.swift */; }; + 2ACBF359244BEA97CF9C6142 /* WorkspaceLayoutTabAccessories.swift in Sources */ = {isa = PBXBuildFile; fileRef = D427B07191C6579D67B0C80F /* WorkspaceLayoutTabAccessories.swift */; }; 2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */; }; - 3023A1003023A1003023A100 /* ConfigSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3023B1003023B1003023B100 /* ConfigSource.swift */; }; - 3023A1013023A1013023A101 /* ConfigSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3023B1013023B1013023B101 /* ConfigSettingsView.swift */; }; - 350DAC5EBD38642A3E81471A /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 312DE7503B4658DD173121B8 /* AuthManager.swift */; }; - 36CE99ED050785B5E96B72BB /* AuthCallbackRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A19A4145F034965110CF87 /* AuthCallbackRouter.swift */; }; + 2D7CD8D7D3A20AB6329CC620 /* WorkspaceLayoutViewportHostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E50D828019C1AB22700D4747 /* WorkspaceLayoutViewportHostView.swift */; }; + 2FAE5BB3D9942AC8E0759B9F /* WorkspaceLayoutNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA23CB3501AC83F841408C46 /* WorkspaceLayoutNotifications.swift */; }; + 330E80BBCAF5793180215870 /* WorkspaceLayoutDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EC979DC315D820931EDCC1E /* WorkspaceLayoutDelegate.swift */; }; 4378399A7C0245EF8186F306 /* OmnibarAndToolsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */; }; 46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */; }; 5EDB6027B346C46521A93C74 /* CMUXAuthCore in Frameworks */ = {isa = PBXBuildFile; productRef = 29813FE5A6CBC1019289A251 /* CMUXAuthCore */; }; - AA11BB22CC33DD44EE550001 /* CMUXWorkstream in Frameworks */ = {isa = PBXBuildFile; productRef = AA11BB22CC33DD44EE550002 /* CMUXWorkstream */; }; - FEED0000000000000000F002 /* FeedCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F001 /* FeedCoordinator.swift */; }; - FEED0000000000000000F005 /* FeedPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F004 /* FeedPanelView.swift */; }; - FEED0000000000000000F00B /* FeedPreviewWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F00A /* FeedPreviewWindowController.swift */; }; - FEED0000000000000000F00D /* FeedButtonStyleDebugWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F00C /* FeedButtonStyleDebugWindowController.swift */; }; - FEED0000000000000000F00F /* FeedTextEditorDebugWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F00E /* FeedTextEditorDebugWindowController.swift */; }; - FEEDC0DEC0DEC0DEC0DE0001 /* FeedCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDC0DEC0DEC0DEC0DE0002 /* FeedCoordinatorTests.swift */; }; - 698944F99A6ADFBA24A7BFB9 /* AuthSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B1EA8948C5F126FE63CFB4E /* AuthSettingsStore.swift */; }; + 4AEDDE63C7538F982F96809C /* WorkspaceLayoutImageBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8D7736837B3E2A5D42E0336 /* WorkspaceLayoutImageBuffer.swift */; }; + 4B5C8A6D57E71F8187A52CC7 /* WorkspaceLayoutShortcutHints.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6BF9B20326C32168D9AE10D /* WorkspaceLayoutShortcutHints.swift */; }; + 4FE3CF08E845FEA5E3E37572 /* TabBarColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EB0620178B37F36D4051FD /* TabBarColors.swift */; }; + 533C6A6F0F130DD9737BE282 /* WorkspaceLayoutNativeTabButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC704564C01489F0CA319BC5 /* WorkspaceLayoutNativeTabButtonView.swift */; }; + 5600E642699F31B79776C808 /* WorkspaceLayoutShortcutHintPillBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E4AC19A59C3741657AB9D8A /* WorkspaceLayoutShortcutHintPillBackground.swift */; }; + 59F068A5430C40D4FAE18A23 /* WorkspaceLayoutTabChromeDebugPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC67056878D634DC314F2C2 /* WorkspaceLayoutTabChromeDebugPreview.swift */; }; 6B524A0BA34FD46A771335AB /* WorkspaceUnitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */; }; 734F49D37E543DD01C2F4FEF /* NotificationAndMenuBarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */; }; + 7F5DD04489767155147D1842 /* WorkspaceLayoutTabChromeDebugScenarios.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B6FBB0FD8D88A1FA816FA3F /* WorkspaceLayoutTabChromeDebugScenarios.swift */; }; 84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */ = {isa = PBXBuildFile; fileRef = B2E7294509CC42FE9191870E /* xterm-ghostty */; }; 8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */; }; + 8DFCD5A9118520E53FEC3C49 /* WorkspaceLayoutCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431F333092DC9DE05040BA17 /* WorkspaceLayoutCore.swift */; }; + 91EC7D01D95450DABAD146C8 /* WorkspaceLayoutPaneDropOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90F16A70A569E818E5FF262B /* WorkspaceLayoutPaneDropOverlayView.swift */; }; + 9261DFD4113F742EB1BCEFCC /* WorkspaceLayoutTabChromeDebugRendering.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02790DCCA5B2FA4B507D89E5 /* WorkspaceLayoutTabChromeDebugRendering.swift */; }; + 93725215DD92F8757DF8C1F1 /* WorkspaceLayoutShortcutHintSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BF59ECD0A7DADE7ADB862E /* WorkspaceLayoutShortcutHintSettings.swift */; }; + 9A1513E7C77BE7455D1F3308 /* WorkspacePaneDropRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A221E3EF205917A15190CC1 /* WorkspacePaneDropRouting.swift */; }; + 9ABE4C9ABF7F6A1ECAF6B93C /* SafeTooltip.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA20B754F7899E9467DCEE71 /* SafeTooltip.swift */; }; + 9E128F2EA1DD1C1D8AC767F6 /* WorkspaceLayoutController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED1678ACE45B1688AF8B1454 /* WorkspaceLayoutController.swift */; }; + 9E9215080D8F2678F9ED7F31 /* WorkspaceLayoutControllerInternals.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA37BF6E527B56DB71603719 /* WorkspaceLayoutControllerInternals.swift */; }; + 9EDA555FC9FE5EC03ABF5681 /* WorkspaceLayoutContentSnapshots.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B55A281C175E5533E152F8 /* WorkspaceLayoutContentSnapshots.swift */; }; + A44A128870F6DB0BE321538B /* SplitAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A25CFAA8BB40203B40DE1C2 /* SplitAnimator.swift */; }; A5001001 /* cmuxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001011 /* cmuxApp.swift */; }; A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; }; 619D509BC9B1EA946CBD6A8A /* WorkspaceRuntimeSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9960CC3992F3C44489E7627C /* WorkspaceRuntimeSettings.swift */; }; @@ -77,6 +106,7 @@ A5001250 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = A5001251 /* Sentry */; }; A5001270 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = A5001271 /* PostHog */; }; A5001290 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = A5001291 /* MarkdownUI */; }; + AA11BB22CC33DD44EE550001 /* CMUXWorkstream in Frameworks */ = {isa = PBXBuildFile; productRef = AA11BB22CC33DD44EE550002 /* CMUXWorkstream */; }; A50012F1 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F0 /* Backport.swift */; }; A50012F3 /* KeyboardShortcutSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F2 /* KeyboardShortcutSettings.swift */; }; A50012F5 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F4 /* KeyboardLayout.swift */; }; @@ -86,7 +116,6 @@ A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; }; A5001403 /* TerminalPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001413 /* TerminalPanelView.swift */; }; A5001404 /* BrowserPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001414 /* BrowserPanelView.swift */; }; - A5001405 /* PanelContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001415 /* PanelContentView.swift */; }; A5001406 /* Workspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001416 /* Workspace.swift */; }; A5001407 /* WorkspaceContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001417 /* WorkspaceContentView.swift */; }; A5001420 /* MarkdownPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001418 /* MarkdownPanel.swift */; }; @@ -104,10 +133,9 @@ A5001621 /* AppleScriptSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001620 /* AppleScriptSupport.swift */; }; A5001623 /* cmux.sdef in Resources */ = {isa = PBXBuildFile; fileRef = A5001622 /* cmux.sdef */; }; A5001640 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; }; - A5001660 /* RestorableAgentSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001661 /* RestorableAgentSession.swift */; }; A5001650 /* CmuxConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001651 /* CmuxConfig.swift */; }; A5001652 /* CmuxConfigExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001653 /* CmuxConfigExecutor.swift */; }; - A5001654 /* CmuxActionTrust.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001655 /* CmuxActionTrust.swift */; }; + A5001654 /* CmuxDirectoryTrust.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001655 /* CmuxDirectoryTrust.swift */; }; A5002000 /* THIRD_PARTY_LICENSES.md in Resources */ = {isa = PBXBuildFile; fileRef = A5002001 /* THIRD_PARTY_LICENSES.md */; }; A5007420 /* BrowserPopupWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5007421 /* BrowserPopupWindowController.swift */; }; A5007422 /* BrowserWebAuthnSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5007423 /* BrowserWebAuthnSupport.swift */; }; @@ -117,7 +145,7 @@ A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; }; A500RG01 /* ReactGrab.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500RG00 /* ReactGrab.swift */; }; A5F10011A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F10010A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift */; }; - AA1B2C3D4E5F60718 /* BonsplitTabDragUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1B2C3D4E5F60719 /* BonsplitTabDragUITests.swift */; }; + AA1B2C3D4E5F60718 /* WorkspaceSplitTabDragUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1B2C3D4E5F60719 /* WorkspaceSplitTabDragUITests.swift */; }; ACE4FE1A8F1CC6C0B6A673AB /* VMClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CEC6EF35B71AE59FA45BA69 /* VMClient.swift */; }; B6BF3DC98DB1495E57900199 /* TabManagerUnitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */; }; B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; }; @@ -128,14 +156,11 @@ B900002EA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900002CA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift */; }; B900002FA1B2C3D4E5F60719 /* CMUXCLI+ThemeSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900002DA1B2C3D4E5F60719 /* CMUXCLI+ThemeSupport.swift */; }; B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; }; - FEED0000000000000000F009 /* FeedSidebarUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F008 /* FeedSidebarUITests.swift */; }; B9000014A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */; }; B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */; }; B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */; }; B900001AA1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */; }; - B900001BA1B2C3D4E5F60719 /* CloseWorkspacesConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900001CA1B2C3D4E5F60719 /* CloseWorkspacesConfirmDialogUITests.swift */; }; B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */; }; - F0F0A001A1B2C3D4E5F60718 /* WorkspaceSidebarScrollUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F0A002A1B2C3D4E5F60718 /* WorkspaceSidebarScrollUITests.swift */; }; B9000024A1B2C3D4E5F60719 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = A5001251 /* Sentry */; }; B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */; }; B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; }; @@ -145,30 +170,37 @@ C2577000A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */; }; CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */; }; CB23911D7E131E8FBC9B82B6 /* SidebarOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */; }; + CC7C7A9DCC18C85403F30A92 /* WorkspaceLayoutConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC2AFDD811CBE1502BCEF12 /* WorkspaceLayoutConfiguration.swift */; }; + CFBB5C67530EEE83A46E5F5F /* TabTransferData.swift in Sources */ = {isa = PBXBuildFile; fileRef = E54539EEF0A984B222083CA9 /* TabTransferData.swift */; }; D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; }; D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; }; D1320AA0D1320AA0D1320AA1 /* AppIconDockTilePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */; }; D1320AA0D1320AA0D1320AA2 /* CmuxDockTilePlugin.plugin in Copy Dock Tile Plugin */ = {isa = PBXBuildFile; fileRef = D1320AA0D1320AA0D1320AA5 /* CmuxDockTilePlugin.plugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */ = {isa = PBXBuildFile; fileRef = D1BEF00001A1B2C3D4E5F719 /* open */; }; - D9FEC58D5BACCF76459F1BBE /* AuthEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43430FA5929121E2EAAB3091 /* AuthEnvironment.swift */; }; DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; }; DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000002 /* InfoPlist.xcstrings */; }; - FEED0000000000000000F007 /* opencode-plugin.js in Resources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F006 /* opencode-plugin.js */; }; + DAECD3B4512F7EA6102D6005 /* WorkspaceSurfaceRegistryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C56488EC2C0BFC5562391B /* WorkspaceSurfaceRegistryProtocol.swift */; }; + DC5EC915F07E12890C97D47C /* TabContextMenuState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8483648EA49261A7249827 /* TabContextMenuState.swift */; }; DCC935C5F55C1DCB33E25521 /* WorkspacePullRequestSidebarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14A7DC53B9CA33BE2A421711 /* WorkspacePullRequestSidebarTests.swift */; }; E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; }; E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */; }; + E5FC5DDBD41DB4E1DD01DE1D /* WorkspaceLayoutPaneHostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6072C5A3EB45D85E311CDB10 /* WorkspaceLayoutPaneHostView.swift */; }; E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; }; E6FA9084A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6FA9085A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift */; }; + E81520199F6512878C420257 /* WorkspaceLayoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6CB6D6ABDC2199E56C4EAB6 /* WorkspaceLayoutView.swift */; }; + E86616099A162E9A18125C8B /* PaneState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B4DD2B5E0F2A8C2A0F0088 /* PaneState.swift */; }; F1C1AA21B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C1AA20B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift */; }; - F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; }; F20F85FC5900550685FA33AD /* StackAuth in Frameworks */ = {isa = PBXBuildFile; productRef = A8BD195031FC4B82B4354297 /* StackAuth */; }; + F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; }; F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; F4100000A1B2C3D4E5F60718 /* PortScannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4100001A1B2C3D4E5F60718 /* PortScannerTests.swift */; }; F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; + F5210498CD51F88F48CCE183 /* WorkspaceLayoutNativeSplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3FFAF119DB6D24427EF5525 /* WorkspaceLayoutNativeSplitView.swift */; }; F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; }; F6100000A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */; }; F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; + F7100000A1B2C3D4E5F60718 /* WorkspaceLayoutRootHostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7100001A1B2C3D4E5F60718 /* WorkspaceLayoutRootHostTests.swift */; }; F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; }; F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */; }; FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */; }; @@ -178,9 +210,7 @@ FE001102 /* FileExplorerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001002 /* FileExplorerView.swift */; }; FE002101 /* FileExplorerRootResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE002001 /* FileExplorerRootResolverTests.swift */; }; FE002102 /* FileExplorerStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE002002 /* FileExplorerStoreTests.swift */; }; - FE003101 /* SessionIndexStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003001 /* SessionIndexStore.swift */; }; - FE003102 /* SessionIndexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003002 /* SessionIndexView.swift */; }; - FE003103 /* RightSidebarPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003003 /* RightSidebarPanelView.swift */; }; + FF881D8639208DE4D71CAB7A /* WorkspaceLayoutDebugLogging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A27A831C39E6FEDC8316A6 /* WorkspaceLayoutDebugLogging.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -252,37 +282,56 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAndGhosttyTests.swift; sourceTree = ""; }; - 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerSessionSnapshotTests.swift; sourceTree = ""; }; - 14A7DC53B9CA33BE2A421711 /* WorkspacePullRequestSidebarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePullRequestSidebarTests.swift; sourceTree = ""; }; - 1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceManualUnreadTests.swift; sourceTree = ""; }; 2F0C05000000000000000001 /* MainWindowFocusController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowFocusController.swift; sourceTree = ""; }; - 3023B1003023B1003023B100 /* ConfigSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings/ConfigSource.swift; sourceTree = ""; }; - 3023B1013023B1013023B101 /* ConfigSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings/ConfigSettingsView.swift; sourceTree = ""; }; 312DE7503B4658DD173121B8 /* AuthManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = ""; }; + 43430FA5929121E2EAAB3091 /* AuthEnvironment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthEnvironment.swift; sourceTree = ""; }; + 5B1EA8948C5F126FE63CFB4E /* AuthSettingsStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthSettingsStore.swift; sourceTree = ""; }; + 61A19A4145F034965110CF87 /* AuthCallbackRouter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthCallbackRouter.swift; sourceTree = ""; }; + FE003001 /* SessionIndexStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexStore.swift; sourceTree = ""; }; + FE003002 /* SessionIndexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexView.swift; sourceTree = ""; }; + FE003003 /* RightSidebarPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarPanelView.swift; sourceTree = ""; }; FEED0000000000000000F001 /* FeedCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedCoordinator.swift; sourceTree = ""; }; FEED0000000000000000F004 /* FeedPanelView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPanelView.swift; sourceTree = ""; }; FEED0000000000000000F00A /* FeedPreviewWindowController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPreviewWindowController.swift; sourceTree = ""; }; FEED0000000000000000F00C /* FeedButtonStyleDebugWindowController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedButtonStyleDebugWindowController.swift; sourceTree = ""; }; FEED0000000000000000F00E /* FeedTextEditorDebugWindowController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedTextEditorDebugWindowController.swift; sourceTree = ""; }; - FEED0000000000000000F006 /* opencode-plugin.js */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.javascript; path = "opencode-plugin.js"; sourceTree = ""; }; - FEEDC0DEC0DEC0DEC0DE0002 /* FeedCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCoordinatorTests.swift; sourceTree = ""; }; + 0099C7D458EC166FF3EA7519 /* WorkspaceLayoutInteractionHandlers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutInteractionHandlers.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutInteractionHandlers.swift; sourceTree = ""; }; + 02790DCCA5B2FA4B507D89E5 /* WorkspaceLayoutTabChromeDebugRendering.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutTabChromeDebugRendering.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutTabChromeDebugRendering.swift; sourceTree = ""; }; + 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAndGhosttyTests.swift; sourceTree = ""; }; + 0A25CFAA8BB40203B40DE1C2 /* SplitAnimator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SplitAnimator.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/SplitAnimator.swift; sourceTree = ""; }; + 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerSessionSnapshotTests.swift; sourceTree = ""; }; + 13B4DD2B5E0F2A8C2A0F0088 /* PaneState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PaneState.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/PaneState.swift; sourceTree = ""; }; + 14A7DC53B9CA33BE2A421711 /* WorkspacePullRequestSidebarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePullRequestSidebarTests.swift; sourceTree = ""; }; + 17A27A831C39E6FEDC8316A6 /* WorkspaceLayoutDebugLogging.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutDebugLogging.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutDebugLogging.swift; sourceTree = ""; }; + 1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceManualUnreadTests.swift; sourceTree = ""; }; + 3A69CDB8F3A7CDD4368C3966 /* TabBarMetrics.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TabBarMetrics.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/TabBarMetrics.swift; sourceTree = ""; }; + 3A7EBFAC731D60EC5C5A9A8E /* WorkspaceLayoutNativeTabButtonViewDrawing.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutNativeTabButtonViewDrawing.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNativeTabButtonViewDrawing.swift; sourceTree = ""; }; + 3EC979DC315D820931EDCC1E /* WorkspaceLayoutDelegate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutDelegate.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutDelegate.swift; sourceTree = ""; }; 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerUnitTests.swift; sourceTree = ""; }; - 43430FA5929121E2EAAB3091 /* AuthEnvironment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthEnvironment.swift; sourceTree = ""; }; + 42BF59ECD0A7DADE7ADB862E /* WorkspaceLayoutShortcutHintSettings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutShortcutHintSettings.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutShortcutHintSettings.swift; sourceTree = ""; }; + 431F333092DC9DE05040BA17 /* WorkspaceLayoutCore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutCore.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutCore.swift; sourceTree = ""; }; 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerSocketSecurityTests.swift; sourceTree = ""; }; 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPanelTests.swift; sourceTree = ""; }; - 5B1EA8948C5F126FE63CFB4E /* AuthSettingsStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthSettingsStore.swift; sourceTree = ""; }; + 6072C5A3EB45D85E311CDB10 /* WorkspaceLayoutPaneHostView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutPaneHostView.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutPaneHostView.swift; sourceTree = ""; }; 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutAndCommandPaletteTests.swift; sourceTree = ""; }; - 61A19A4145F034965110CF87 /* AuthCallbackRouter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthCallbackRouter.swift; sourceTree = ""; }; + 6A8483648EA49261A7249827 /* TabContextMenuState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TabContextMenuState.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/TabContextMenuState.swift; sourceTree = ""; }; + 702DA72CC66CD8A0D09B6831 /* WorkspaceSplitAppBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkspaceSplitAppBridge.swift; sourceTree = ""; }; 71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceUnitTests.swift; sourceTree = ""; }; + 74B55A281C175E5533E152F8 /* WorkspaceLayoutContentSnapshots.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutContentSnapshots.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutContentSnapshots.swift; sourceTree = ""; }; + 7A221E3EF205917A15190CC1 /* WorkspacePaneDropRouting.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspacePaneDropRouting.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspacePaneDropRouting.swift; sourceTree = ""; }; + 7B6FBB0FD8D88A1FA816FA3F /* WorkspaceLayoutTabChromeDebugScenarios.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutTabChromeDebugScenarios.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutTabChromeDebugScenarios.swift; sourceTree = ""; }; 7E7E6EF344A568AC7FEE3715 /* cmuxUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = cmuxUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; + 8580F6BF648E80E59B486314 /* WorkspaceLayoutTabChromeDebugSettings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutTabChromeDebugSettings.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutTabChromeDebugSettings.swift; sourceTree = ""; }; + 8E4AC19A59C3741657AB9D8A /* WorkspaceLayoutShortcutHintPillBackground.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutShortcutHintPillBackground.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutShortcutHintPillBackground.swift; sourceTree = ""; }; + 90F16A70A569E818E5FF262B /* WorkspaceLayoutPaneDropOverlayView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutPaneDropOverlayView.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutPaneDropOverlayView.swift; sourceTree = ""; }; 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserConfigTests.swift; sourceTree = ""; }; 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = ""; }; - 9CEC6EF35B71AE59FA45BA69 /* VMClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VMClient.swift; sourceTree = ""; }; + 9B5E7854635606DACB33A658 /* SplitState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SplitState.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/SplitState.swift; sourceTree = ""; }; A5001000 /* cmux.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = cmux.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5001011 /* cmuxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmuxApp.swift; sourceTree = ""; }; A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 9CEC6EF35B71AE59FA45BA69 /* VMClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VMClient.swift; sourceTree = ""; }; 9960CC3992F3C44489E7627C /* WorkspaceRuntimeSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/WorkspaceRuntimeSettings.swift; sourceTree = ""; }; 243632BA1DBBA36FD46E0610 /* SidebarAppearanceSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarAppearanceSupport.swift; sourceTree = ""; }; 610008EA5E3744DDBE05C8E2 /* InternalTabDragConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/InternalTabDragConfiguration.swift; sourceTree = ""; }; @@ -333,7 +382,6 @@ A5001412 /* BrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanel.swift; sourceTree = ""; }; A5001413 /* TerminalPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelView.swift; sourceTree = ""; }; A5001414 /* BrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelView.swift; sourceTree = ""; }; - A5001415 /* PanelContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/PanelContentView.swift; sourceTree = ""; }; A5001416 /* Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workspace.swift; sourceTree = ""; }; A5001417 /* WorkspaceContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentView.swift; sourceTree = ""; }; A5001418 /* MarkdownPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanel.swift; sourceTree = ""; }; @@ -351,10 +399,9 @@ A5001620 /* AppleScriptSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptSupport.swift; sourceTree = ""; }; A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = ""; }; A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = ""; }; - A5001661 /* RestorableAgentSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentSession.swift; sourceTree = ""; }; A5001651 /* CmuxConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfig.swift; sourceTree = ""; }; A5001653 /* CmuxConfigExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfigExecutor.swift; sourceTree = ""; }; - A5001655 /* CmuxActionTrust.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxActionTrust.swift; sourceTree = ""; }; + A5001655 /* CmuxDirectoryTrust.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxDirectoryTrust.swift; sourceTree = ""; }; A5002001 /* THIRD_PARTY_LICENSES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = THIRD_PARTY_LICENSES.md; sourceTree = SOURCE_ROOT; }; A5007421 /* BrowserPopupWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPopupWindowController.swift; sourceTree = ""; }; A5007423 /* BrowserWebAuthnSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserWebAuthnSupport.swift; sourceTree = ""; }; @@ -364,9 +411,14 @@ A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = ""; }; A500RG00 /* ReactGrab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ReactGrab.swift; sourceTree = ""; }; A5F10010A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettingsFileStore.swift; sourceTree = ""; }; - AA1B2C3D4E5F60719 /* BonsplitTabDragUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonsplitTabDragUITests.swift; sourceTree = ""; }; + A6CB6D6ABDC2199E56C4EAB6 /* WorkspaceLayoutView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutView.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutView.swift; sourceTree = ""; }; + AA1B2C3D4E5F60719 /* WorkspaceSplitTabDragUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSplitTabDragUITests.swift; sourceTree = ""; }; + AA37BF6E527B56DB71603719 /* WorkspaceLayoutControllerInternals.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutControllerInternals.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutControllerInternals.swift; sourceTree = ""; }; + ACC2AFDD811CBE1502BCEF12 /* WorkspaceLayoutConfiguration.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutConfiguration.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutConfiguration.swift; sourceTree = ""; }; B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmnibarAndToolsTests.swift; sourceTree = ""; }; + B2C56488EC2C0BFC5562391B /* WorkspaceSurfaceRegistryProtocol.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceSurfaceRegistryProtocol.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceSurfaceRegistryProtocol.swift; sourceTree = ""; }; B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = ""; }; + B2EB0620178B37F36D4051FD /* TabBarColors.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TabBarColors.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/TabBarColors.swift; sourceTree = ""; }; B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = ""; }; B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayResolutionRegressionUITests.swift; sourceTree = ""; }; B9000001A1B2C3D4E5F60719 /* cmux.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmux.swift; sourceTree = ""; }; @@ -374,31 +426,38 @@ B900002CA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Themes.swift"; sourceTree = ""; }; B900002DA1B2C3D4E5F60719 /* CMUXCLI+ThemeSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+ThemeSupport.swift"; sourceTree = ""; }; B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationSocketUITests.swift; sourceTree = ""; }; - FEED0000000000000000F008 /* FeedSidebarUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedSidebarUITests.swift; sourceTree = ""; }; B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpToUnreadUITests.swift; sourceTree = ""; }; B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiWindowNotificationsUITests.swift; sourceTree = ""; }; - F0F0A002A1B2C3D4E5F60718 /* WorkspaceSidebarScrollUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSidebarScrollUITests.swift; sourceTree = ""; }; B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDragHandleView.swift; sourceTree = ""; }; B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceConfirmDialogUITests.swift; sourceTree = ""; }; - B900001CA1B2C3D4E5F60719 /* CloseWorkspacesConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspacesConfirmDialogUITests.swift; sourceTree = ""; }; B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceCmdDUITests.swift; sourceTree = ""; }; B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWindowConfirmDialogUITests.swift; sourceTree = ""; }; + BA20B754F7899E9467DCEE71 /* SafeTooltip.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SafeTooltip.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/SafeTooltip.swift; sourceTree = ""; }; BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarOrderingTests.swift; sourceTree = ""; }; BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAndDragTests.swift; sourceTree = ""; }; C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = ""; }; C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfigTests.swift; sourceTree = ""; }; C1ADE00001A1B2C3D4E5F719 /* claude */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = Resources/bin/claude; sourceTree = SOURCE_ROOT; }; C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCmdClickUITests.swift; sourceTree = ""; }; + C6BF9B20326C32168D9AE10D /* WorkspaceLayoutShortcutHints.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutShortcutHints.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutShortcutHints.swift; sourceTree = ""; }; + CC704564C01489F0CA319BC5 /* WorkspaceLayoutNativeTabButtonView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutNativeTabButtonView.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNativeTabButtonView.swift; sourceTree = ""; }; D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = ""; }; D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = ""; }; D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconDockTilePlugin.swift; sourceTree = ""; }; D1320AA0D1320AA0D1320AA5 /* CmuxDockTilePlugin.plugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CmuxDockTilePlugin.plugin; sourceTree = BUILT_PRODUCTS_DIR; }; D1BEF00001A1B2C3D4E5F719 /* open */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = Resources/bin/open; sourceTree = SOURCE_ROOT; }; D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationAndMenuBarTests.swift; sourceTree = ""; }; + D427B07191C6579D67B0C80F /* WorkspaceLayoutTabAccessories.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutTabAccessories.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutTabAccessories.swift; sourceTree = ""; }; + DA23CB3501AC83F841408C46 /* WorkspaceLayoutNotifications.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutNotifications.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNotifications.swift; sourceTree = ""; }; DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = ""; }; + E3FFAF119DB6D24427EF5525 /* WorkspaceLayoutNativeSplitView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutNativeSplitView.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNativeSplitView.swift; sourceTree = ""; }; + E50D828019C1AB22700D4747 /* WorkspaceLayoutViewportHostView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutViewportHostView.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutViewportHostView.swift; sourceTree = ""; }; + E54539EEF0A984B222083CA9 /* TabTransferData.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TabTransferData.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/TabTransferData.swift; sourceTree = ""; }; E6FA9085A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceDescriptionUITests.swift; sourceTree = ""; }; + ED1678ACE45B1688AF8B1454 /* WorkspaceLayoutController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutController.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutController.swift; sourceTree = ""; }; + EDC67056878D634DC314F2C2 /* WorkspaceLayoutTabChromeDebugPreview.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutTabChromeDebugPreview.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutTabChromeDebugPreview.swift; sourceTree = ""; }; EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWidthPolicyTests.swift; sourceTree = ""; }; F1000002A1B2C3D4E5F60718 /* cmuxTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = cmuxTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; F1C1AA20B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InactivePaneFirstClickFocusTests.swift; sourceTree = ""; }; @@ -407,10 +466,13 @@ F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; F4100001A1B2C3D4E5F60718 /* PortScannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScannerTests.swift; sourceTree = ""; }; F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; + F5DE1A6410BC5B26F5BF2561 /* WorkspaceLayoutNativeTabBarView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutNativeTabBarView.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNativeTabBarView.swift; sourceTree = ""; }; F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = ""; }; F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteConnectionTests.swift; sourceTree = ""; }; F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = ""; }; + F7100001A1B2C3D4E5F60718 /* WorkspaceLayoutRootHostTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceLayoutRootHostTests.swift; sourceTree = ""; }; F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; }; + F8D7736837B3E2A5D42E0336 /* WorkspaceLayoutImageBuffer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutImageBuffer.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutImageBuffer.swift; sourceTree = ""; }; F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = ""; }; FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStressProfileTests.swift; sourceTree = ""; }; FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMappingTests.swift; sourceTree = ""; }; @@ -419,9 +481,7 @@ FE001002 /* FileExplorerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerView.swift; sourceTree = ""; }; FE002001 /* FileExplorerRootResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerRootResolverTests.swift; sourceTree = ""; }; FE002002 /* FileExplorerStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerStoreTests.swift; sourceTree = ""; }; - FE003001 /* SessionIndexStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexStore.swift; sourceTree = ""; }; - FE003002 /* SessionIndexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexView.swift; sourceTree = ""; }; - FE003003 /* RightSidebarPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarPanelView.swift; sourceTree = ""; }; + FFE8D508AD8E68ED52B3D7BA /* WorkspaceLayoutNativeHost.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkspaceLayoutNativeHost.swift; path = Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNativeHost.swift; sourceTree = ""; }; IC000002 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder; path = AppIcon.icon; sourceTree = ""; }; /* End PBXFileReference section */ @@ -473,20 +533,6 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 087C454FFF74443AB06942C3 /* Resources */ = { - isa = PBXGroup; - children = ( - B2E7294509CC42FE9191870E /* xterm-ghostty */, - A5002001 /* THIRD_PARTY_LICENSES.md */, - C1ADE00001A1B2C3D4E5F719 /* claude */, - DA7A10CA710E000000000001 /* Localizable.xcstrings */, - DA7A10CA710E000000000002 /* InfoPlist.xcstrings */, - FEED0000000000000000F006 /* opencode-plugin.js */, - A5001622 /* cmux.sdef */, - ); - path = Resources; - sourceTree = ""; - }; 105D8421DF4E2E09D255D785 /* Auth */ = { isa = PBXGroup; children = ( @@ -512,21 +558,55 @@ path = Feed; sourceTree = ""; }; + 087C454FFF74443AB06942C3 /* Resources */ = { + isa = PBXGroup; + children = ( + B2E7294509CC42FE9191870E /* xterm-ghostty */, + A5002001 /* THIRD_PARTY_LICENSES.md */, + C1ADE00001A1B2C3D4E5F719 /* claude */, + DA7A10CA710E000000000001 /* Localizable.xcstrings */, + DA7A10CA710E000000000002 /* InfoPlist.xcstrings */, + A5001622 /* cmux.sdef */, + ); + path = Resources; + sourceTree = ""; + }; + 0A66625BF1E6379C55494E45 /* CMUXWorkspaceSplit */ = { + isa = PBXGroup; + children = ( + 1ADAA9A56FF6F0DC136958B3 /* Sources */, + ); + name = CMUXWorkspaceSplit; + sourceTree = ""; + }; + 15D4F41321AC2D56EFC360D5 /* Packages */ = { + isa = PBXGroup; + children = ( + 0A66625BF1E6379C55494E45 /* CMUXWorkspaceSplit */, + ); + name = Packages; + sourceTree = ""; + }; + 1ADAA9A56FF6F0DC136958B3 /* Sources */ = { + isa = PBXGroup; + children = ( + 7F3C97D1933C78871B2A7F42 /* CMUXWorkspaceSplit */, + ); + name = Sources; + sourceTree = ""; + }; 3196C9C2D01F054C1D3385DD /* cmuxUITests */ = { isa = PBXGroup; children = ( B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */, - FEED0000000000000000F008 /* FeedSidebarUITests.swift */, B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */, B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */, B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */, - B900001CA1B2C3D4E5F60719 /* CloseWorkspacesConfirmDialogUITests.swift */, B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */, B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */, 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */, B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */, B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */, - F0F0A002A1B2C3D4E5F60718 /* WorkspaceSidebarScrollUITests.swift */, C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */, E6FA9085A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift */, D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */, @@ -534,11 +614,55 @@ FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */, C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */, E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */, - AA1B2C3D4E5F60719 /* BonsplitTabDragUITests.swift */, + AA1B2C3D4E5F60719 /* WorkspaceSplitTabDragUITests.swift */, ); path = cmuxUITests; sourceTree = ""; }; + 7F3C97D1933C78871B2A7F42 /* CMUXWorkspaceSplit */ = { + isa = PBXGroup; + children = ( + 13B4DD2B5E0F2A8C2A0F0088 /* PaneState.swift */, + BA20B754F7899E9467DCEE71 /* SafeTooltip.swift */, + 0A25CFAA8BB40203B40DE1C2 /* SplitAnimator.swift */, + 9B5E7854635606DACB33A658 /* SplitState.swift */, + B2EB0620178B37F36D4051FD /* TabBarColors.swift */, + 3A69CDB8F3A7CDD4368C3966 /* TabBarMetrics.swift */, + 6A8483648EA49261A7249827 /* TabContextMenuState.swift */, + E54539EEF0A984B222083CA9 /* TabTransferData.swift */, + ACC2AFDD811CBE1502BCEF12 /* WorkspaceLayoutConfiguration.swift */, + 74B55A281C175E5533E152F8 /* WorkspaceLayoutContentSnapshots.swift */, + ED1678ACE45B1688AF8B1454 /* WorkspaceLayoutController.swift */, + 431F333092DC9DE05040BA17 /* WorkspaceLayoutCore.swift */, + 17A27A831C39E6FEDC8316A6 /* WorkspaceLayoutDebugLogging.swift */, + 3EC979DC315D820931EDCC1E /* WorkspaceLayoutDelegate.swift */, + F8D7736837B3E2A5D42E0336 /* WorkspaceLayoutImageBuffer.swift */, + 0099C7D458EC166FF3EA7519 /* WorkspaceLayoutInteractionHandlers.swift */, + FFE8D508AD8E68ED52B3D7BA /* WorkspaceLayoutNativeHost.swift */, + E3FFAF119DB6D24427EF5525 /* WorkspaceLayoutNativeSplitView.swift */, + F5DE1A6410BC5B26F5BF2561 /* WorkspaceLayoutNativeTabBarView.swift */, + CC704564C01489F0CA319BC5 /* WorkspaceLayoutNativeTabButtonView.swift */, + DA23CB3501AC83F841408C46 /* WorkspaceLayoutNotifications.swift */, + 90F16A70A569E818E5FF262B /* WorkspaceLayoutPaneDropOverlayView.swift */, + 6072C5A3EB45D85E311CDB10 /* WorkspaceLayoutPaneHostView.swift */, + 8E4AC19A59C3741657AB9D8A /* WorkspaceLayoutShortcutHintPillBackground.swift */, + 42BF59ECD0A7DADE7ADB862E /* WorkspaceLayoutShortcutHintSettings.swift */, + C6BF9B20326C32168D9AE10D /* WorkspaceLayoutShortcutHints.swift */, + D427B07191C6579D67B0C80F /* WorkspaceLayoutTabAccessories.swift */, + EDC67056878D634DC314F2C2 /* WorkspaceLayoutTabChromeDebugPreview.swift */, + 8580F6BF648E80E59B486314 /* WorkspaceLayoutTabChromeDebugSettings.swift */, + A6CB6D6ABDC2199E56C4EAB6 /* WorkspaceLayoutView.swift */, + E50D828019C1AB22700D4747 /* WorkspaceLayoutViewportHostView.swift */, + 7A221E3EF205917A15190CC1 /* WorkspacePaneDropRouting.swift */, + B2C56488EC2C0BFC5562391B /* WorkspaceSurfaceRegistryProtocol.swift */, + AA37BF6E527B56DB71603719 /* WorkspaceLayoutControllerInternals.swift */, + 3A7EBFAC731D60EC5C5A9A8E /* WorkspaceLayoutNativeTabButtonViewDrawing.swift */, + 7B6FBB0FD8D88A1FA816FA3F /* WorkspaceLayoutTabChromeDebugScenarios.swift */, + 02790DCCA5B2FA4B507D89E5 /* WorkspaceLayoutTabChromeDebugRendering.swift */, + ); + name = CMUXWorkspaceSplit; + sourceTree = ""; + }; 590E4F5F342E5B27010ECC8D /* Cloud */ = { isa = PBXGroup; children = ( @@ -562,14 +686,13 @@ 3196C9C2D01F054C1D3385DD /* cmuxUITests */, F1000003A1B2C3D4E5F60718 /* cmuxTests */, A5001042 /* Products */, + 15D4F41321AC2D56EFC360D5 /* Packages */, ); sourceTree = ""; }; A5001041 /* Sources */ = { isa = PBXGroup; children = ( - 3023B1003023B1003023B100 /* ConfigSource.swift */, - 3023B1013023B1013023B101 /* ConfigSettingsView.swift */, A5001011 /* cmuxApp.swift */, A5001012 /* ContentView.swift */, 9960CC3992F3C44489E7627C /* WorkspaceRuntimeSettings.swift */, @@ -586,6 +709,7 @@ B42A82C6AA614E74873D9A5F /* ShortcutRoutingSupport.swift */, C7934BB35B66491B1BCA8064 /* MenuBarExtraController.swift */, A500D011A1B2C3D4E5F60718 /* DebugLogging.swift */, + 2F0C05000000000000000001 /* MainWindowFocusController.swift */, 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */, B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, A50012F0 /* Backport.swift */, @@ -610,7 +734,6 @@ A5001620 /* AppleScriptSupport.swift */, D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */, A5001090 /* AppDelegate.swift */, - 2F0C05000000000000000001 /* MainWindowFocusController.swift */, A5001091 /* NotificationsPage.swift */, A5001092 /* TerminalNotificationStore.swift */, A5001301 /* SurfaceSearchOverlay.swift */, @@ -627,7 +750,6 @@ A5001418 /* MarkdownPanel.swift */, A5001419 /* MarkdownPanelView.swift */, A5001510 /* CmuxWebView.swift */, - A5001415 /* PanelContentView.swift */, A5001211 /* UpdateController.swift */, A5001212 /* UpdateDelegate.swift */, A5001213 /* UpdateDriver.swift */, @@ -644,13 +766,13 @@ A5001241 /* WindowDecorationsController.swift */, A5001222 /* WindowAccessor.swift */, A5001611 /* SessionPersistence.swift */, - A5001661 /* RestorableAgentSession.swift */, A5001641 /* RemoteRelayZshBootstrap.swift */, A5001651 /* CmuxConfig.swift */, A5001653 /* CmuxConfigExecutor.swift */, - A5001655 /* CmuxActionTrust.swift */, + A5001655 /* CmuxDirectoryTrust.swift */, FE001001 /* FileExplorerStore.swift */, FE001002 /* FileExplorerView.swift */, + 702DA72CC66CD8A0D09B6831 /* WorkspaceSplitAppBridge.swift */, FE003001 /* SessionIndexStore.swift */, FE003002 /* SessionIndexView.swift */, FE003003 /* RightSidebarPanelView.swift */, @@ -695,6 +817,7 @@ F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */, F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, + F7100001A1B2C3D4E5F60718 /* WorkspaceLayoutRootHostTests.swift */, F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */, F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */, F1C1AA20B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift */, @@ -712,7 +835,6 @@ D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */, 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */, 14A7DC53B9CA33BE2A421711 /* WorkspacePullRequestSidebarTests.swift */, - FEEDC0DEC0DEC0DEC0DE0002 /* FeedCoordinatorTests.swift */, 1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */, EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */, 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */, @@ -750,7 +872,6 @@ A5001231 /* Sparkle */, A5001251 /* Sentry */, A5001271 /* PostHog */, - A5001261 /* Bonsplit */, A5001291 /* MarkdownUI */, 29813FE5A6CBC1019289A251 /* CMUXAuthCore */, AA11BB22CC33DD44EE550002 /* CMUXWorkstream */, @@ -874,7 +995,6 @@ A5001252 /* XCRemoteSwiftPackageReference "sentry-cocoa" */, A5001272 /* XCRemoteSwiftPackageReference "posthog-ios" */, A5001292 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, - A5001260 /* XCLocalSwiftPackageReference "bonsplit" */, 40B63BB4A170F0BD2D1DEFD1 /* XCLocalSwiftPackageReference "CMUXAuthCore" */, AA11BB22CC33DD44EE550003 /* XCLocalSwiftPackageReference "CMUXWorkstream" */, A500D012A1B2C3D4E5F60718 /* XCLocalSwiftPackageReference "CMUXDebugLog" */, @@ -903,7 +1023,6 @@ A5002000 /* THIRD_PARTY_LICENSES.md in Resources */, DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */, DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */, - FEED0000000000000000F007 /* opencode-plugin.js in Resources */, A5001623 /* cmux.sdef in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -956,8 +1075,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 3023A1003023A1003023A100 /* ConfigSource.swift in Sources */, - 3023A1013023A1013023A101 /* ConfigSettingsView.swift in Sources */, A5001001 /* cmuxApp.swift in Sources */, A5001002 /* ContentView.swift in Sources */, 619D509BC9B1EA946CBD6A8A /* WorkspaceRuntimeSettings.swift in Sources */, @@ -974,6 +1091,7 @@ 5C3E0454B6C24B02A2F091A8 /* ShortcutRoutingSupport.swift in Sources */, 1A8BEE693C9E4C3190CB7F20 /* MenuBarExtraController.swift in Sources */, A500D010A1B2C3D4E5F60718 /* DebugLogging.swift in Sources */, + 2F0C05000000000000000002 /* MainWindowFocusController.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, A50012F1 /* Backport.swift in Sources */, @@ -997,7 +1115,6 @@ A5001601 /* SentryHelper.swift in Sources */, A5001621 /* AppleScriptSupport.swift in Sources */, A5001093 /* AppDelegate.swift in Sources */, - 2F0C05000000000000000002 /* MainWindowFocusController.swift in Sources */, A5001094 /* NotificationsPage.swift in Sources */, A5001095 /* TerminalNotificationStore.swift in Sources */, A5001303 /* SurfaceSearchOverlay.swift in Sources */, @@ -1014,7 +1131,6 @@ A5001420 /* MarkdownPanel.swift in Sources */, A5001421 /* MarkdownPanelView.swift in Sources */, A5001500 /* CmuxWebView.swift in Sources */, - A5001405 /* PanelContentView.swift in Sources */, A5001201 /* UpdateController.swift in Sources */, A5001202 /* UpdateDelegate.swift in Sources */, A5001203 /* UpdateDriver.swift in Sources */, @@ -1031,13 +1147,50 @@ A5001240 /* WindowDecorationsController.swift in Sources */, A500120C /* WindowAccessor.swift in Sources */, A5001610 /* SessionPersistence.swift in Sources */, - A5001660 /* RestorableAgentSession.swift in Sources */, A5001640 /* RemoteRelayZshBootstrap.swift in Sources */, A5001650 /* CmuxConfig.swift in Sources */, A5001652 /* CmuxConfigExecutor.swift in Sources */, - A5001654 /* CmuxActionTrust.swift in Sources */, + A5001654 /* CmuxDirectoryTrust.swift in Sources */, FE001101 /* FileExplorerStore.swift in Sources */, FE001102 /* FileExplorerView.swift in Sources */, + 1222ADAF6D1EED56FFC27D34 /* WorkspaceSplitAppBridge.swift in Sources */, + E86616099A162E9A18125C8B /* PaneState.swift in Sources */, + 9ABE4C9ABF7F6A1ECAF6B93C /* SafeTooltip.swift in Sources */, + A44A128870F6DB0BE321538B /* SplitAnimator.swift in Sources */, + 2A5A9A8E5EA1904E7FB76C3C /* SplitState.swift in Sources */, + 4FE3CF08E845FEA5E3E37572 /* TabBarColors.swift in Sources */, + 265EB8E59168FB3206145A79 /* TabBarMetrics.swift in Sources */, + DC5EC915F07E12890C97D47C /* TabContextMenuState.swift in Sources */, + CFBB5C67530EEE83A46E5F5F /* TabTransferData.swift in Sources */, + CC7C7A9DCC18C85403F30A92 /* WorkspaceLayoutConfiguration.swift in Sources */, + 9EDA555FC9FE5EC03ABF5681 /* WorkspaceLayoutContentSnapshots.swift in Sources */, + 9E128F2EA1DD1C1D8AC767F6 /* WorkspaceLayoutController.swift in Sources */, + 8DFCD5A9118520E53FEC3C49 /* WorkspaceLayoutCore.swift in Sources */, + FF881D8639208DE4D71CAB7A /* WorkspaceLayoutDebugLogging.swift in Sources */, + 330E80BBCAF5793180215870 /* WorkspaceLayoutDelegate.swift in Sources */, + 4AEDDE63C7538F982F96809C /* WorkspaceLayoutImageBuffer.swift in Sources */, + 2955228EE41B197870285A14 /* WorkspaceLayoutInteractionHandlers.swift in Sources */, + 109D06B6FEACFF411E7AAE0E /* WorkspaceLayoutNativeHost.swift in Sources */, + F5210498CD51F88F48CCE183 /* WorkspaceLayoutNativeSplitView.swift in Sources */, + 21F5EF2BA807EFD15B3BE6A8 /* WorkspaceLayoutNativeTabBarView.swift in Sources */, + 533C6A6F0F130DD9737BE282 /* WorkspaceLayoutNativeTabButtonView.swift in Sources */, + 2FAE5BB3D9942AC8E0759B9F /* WorkspaceLayoutNotifications.swift in Sources */, + 91EC7D01D95450DABAD146C8 /* WorkspaceLayoutPaneDropOverlayView.swift in Sources */, + E5FC5DDBD41DB4E1DD01DE1D /* WorkspaceLayoutPaneHostView.swift in Sources */, + 5600E642699F31B79776C808 /* WorkspaceLayoutShortcutHintPillBackground.swift in Sources */, + 93725215DD92F8757DF8C1F1 /* WorkspaceLayoutShortcutHintSettings.swift in Sources */, + 4B5C8A6D57E71F8187A52CC7 /* WorkspaceLayoutShortcutHints.swift in Sources */, + 2ACBF359244BEA97CF9C6142 /* WorkspaceLayoutTabAccessories.swift in Sources */, + 59F068A5430C40D4FAE18A23 /* WorkspaceLayoutTabChromeDebugPreview.swift in Sources */, + 2AA538C21CBEFB3299913AF8 /* WorkspaceLayoutTabChromeDebugSettings.swift in Sources */, + E81520199F6512878C420257 /* WorkspaceLayoutView.swift in Sources */, + 2D7CD8D7D3A20AB6329CC620 /* WorkspaceLayoutViewportHostView.swift in Sources */, + 9A1513E7C77BE7455D1F3308 /* WorkspacePaneDropRouting.swift in Sources */, + DAECD3B4512F7EA6102D6005 /* WorkspaceSurfaceRegistryProtocol.swift in Sources */, + 9E9215080D8F2678F9ED7F31 /* WorkspaceLayoutControllerInternals.swift in Sources */, + 1485323DB5A912A32BF038AE /* WorkspaceLayoutNativeTabButtonViewDrawing.swift in Sources */, + 7F5DD04489767155147D1842 /* WorkspaceLayoutTabChromeDebugScenarios.swift in Sources */, + 9261DFD4113F742EB1BCEFCC /* WorkspaceLayoutTabChromeDebugRendering.swift in Sources */, FE003101 /* SessionIndexStore.swift in Sources */, FE003102 /* SessionIndexView.swift in Sources */, FE003103 /* RightSidebarPanelView.swift in Sources */, @@ -1078,14 +1231,11 @@ buildActionMask = 2147483647; files = ( B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */, - FEED0000000000000000F009 /* FeedSidebarUITests.swift in Sources */, B9000014A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift in Sources */, B900001AA1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift in Sources */, - B900001BA1B2C3D4E5F60719 /* CloseWorkspacesConfirmDialogUITests.swift in Sources */, B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */, B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */, B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */, - F0F0A001A1B2C3D4E5F60718 /* WorkspaceSidebarScrollUITests.swift in Sources */, B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */, B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */, B8F266266A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift in Sources */, @@ -1096,7 +1246,7 @@ FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */, C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */, E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */, - AA1B2C3D4E5F60718 /* BonsplitTabDragUITests.swift in Sources */, + AA1B2C3D4E5F60718 /* WorkspaceSplitTabDragUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1113,6 +1263,7 @@ F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */, F6100000A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift in Sources */, F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, + F7100000A1B2C3D4E5F60718 /* WorkspaceLayoutRootHostTests.swift in Sources */, F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */, F1C1AA21B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift in Sources */, @@ -1130,7 +1281,6 @@ 734F49D37E543DD01C2F4FEF /* NotificationAndMenuBarTests.swift in Sources */, B6BF3DC98DB1495E57900199 /* TabManagerUnitTests.swift in Sources */, DCC935C5F55C1DCB33E25521 /* WorkspacePullRequestSidebarTests.swift in Sources */, - FEEDC0DEC0DEC0DEC0DE0001 /* FeedCoordinatorTests.swift in Sources */, 0F2C25F9170130F8DC09DD1B /* WorkspaceManualUnreadTests.swift in Sources */, CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */, 8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */, @@ -1249,7 +1399,6 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-Debug"; - CMUX_AUTH_CALLBACK_SCHEME = "cmux-dev"; CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; @@ -1289,8 +1438,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CMUX_AUTH_CALLBACK_SCHEME = cmux; - CODE_SIGN_ENTITLEMENTS = Resources/cmux.entitlements; + CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 79; @@ -1544,10 +1692,6 @@ isa = XCLocalSwiftPackageReference; relativePath = Packages/CMUXDebugLog; }; - A5001260 /* XCLocalSwiftPackageReference "bonsplit" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = vendor/bonsplit; - }; /* End XCLocalSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -1611,11 +1755,6 @@ package = A5001252 /* XCRemoteSwiftPackageReference "sentry-cocoa" */; productName = Sentry; }; - A5001261 /* Bonsplit */ = { - isa = XCSwiftPackageProductDependency; - package = A5001260 /* XCLocalSwiftPackageReference "bonsplit" */; - productName = Bonsplit; - }; A5001271 /* PostHog */ = { isa = XCSwiftPackageProductDependency; package = A5001272 /* XCRemoteSwiftPackageReference "posthog-ios" */; diff --git a/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 85e1de4b0ce8..2c8985b03022 100644 --- a/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "21ae962c1bd1db162e73bb73adabe7e6e4bf38fc9981b5ecc0a9ac8ac7c16f5e", + "originHash" : "492a43f3def200cebd3e23d8dfe5756ce6942929b31adc8656398616f5c84ba1", "pins" : [ { "identity" : "networkimage", diff --git a/Package.resolved b/Package.resolved index 0572c520dbc1..6c245b1aba65 100644 --- a/Package.resolved +++ b/Package.resolved @@ -8,15 +8,6 @@ "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", "version" : "1.7.0" } - }, - { - "identity" : "swiftterm", - "kind" : "remoteSourceControl", - "location" : "https://github.com/migueldeicaza/SwiftTerm.git", - "state" : { - "revision" : "0b8d99bd19b694df44e1ccaa3891309719d34330", - "version" : "1.5.1" - } } ], "version" : 2 diff --git a/Package.swift b/Package.swift index ccbc6ab299a5..3fae73350907 100644 --- a/Package.swift +++ b/Package.swift @@ -9,13 +9,11 @@ let package = Package( products: [ .executable(name: "cmux", targets: ["cmux"]) ], - dependencies: [ - .package(url: "https://github.com/migueldeicaza/SwiftTerm.git", from: "1.2.0") - ], + dependencies: [], targets: [ .executableTarget( name: "cmux", - dependencies: ["SwiftTerm"], + dependencies: [], path: "Sources" ) ] diff --git a/Packages/CMUXWorkspaceSplit/Package.swift b/Packages/CMUXWorkspaceSplit/Package.swift new file mode 100644 index 000000000000..22264274a4e3 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Package.swift @@ -0,0 +1,19 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "CMUXWorkspaceSplit", + platforms: [ + .macOS(.v13) + ], + products: [ + .library(name: "CMUXWorkspaceSplit", targets: ["CMUXWorkspaceSplit"]) + ], + targets: [ + .target(name: "CMUXWorkspaceSplit"), + .testTarget( + name: "CMUXWorkspaceSplitTests", + dependencies: ["CMUXWorkspaceSplit"] + ) + ] +) diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/PaneState.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/PaneState.swift new file mode 100644 index 000000000000..00a2566ba1a1 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/PaneState.swift @@ -0,0 +1,279 @@ +import Foundation +import SwiftUI + +/// State for a single pane (leaf node in the split tree) +struct PaneState: Identifiable { + let id: PaneID + var tabIds: [UUID] + var selectedTabId: UUID? + // AppKit tab chrome is driven by snapshots of this pane. Bump explicitly on + // metadata edits so hosts don't depend on nested array observation quirks. + var chromeRevision: UInt64 = 0 + + init( + id: PaneID = PaneID(), + tabIds: [UUID] = [], + selectedTabId: UUID? = nil + ) { + self.id = id + self.tabIds = tabIds + self.selectedTabId = selectedTabId ?? tabIds.first + } + + /// Select a tab by ID + mutating func selectTab(_ tabId: UUID) { + guard tabIds.contains(tabId) else { return } + guard selectedTabId != tabId else { return } + selectedTabId = tabId + chromeRevision &+= 1 + } + + /// Add a new tab + mutating func addTab(_ tabId: UUID, select: Bool = true) { + tabIds.append(tabId) + if select { + selectedTabId = tabId + } + chromeRevision &+= 1 + } + + /// Insert a tab at a specific index + mutating func insertTab(_ tabId: UUID, at index: Int, select: Bool = true) { + let safeIndex = min(max(0, index), tabIds.count) + tabIds.insert(tabId, at: safeIndex) + if select { + selectedTabId = tabId + } + chromeRevision &+= 1 + } + + /// Remove a tab and return it + @discardableResult + mutating func removeTab(_ tabId: UUID) -> UUID? { + guard let index = tabIds.firstIndex(of: tabId) else { return nil } + let removedTabId = tabIds.remove(at: index) + + // If we removed the selected tab, keep the index stable when possible: + // prefer selecting the tab that moved into the removed tab's slot (the "next" tab), + // and only fall back to selecting the previous tab when we removed the last tab. + if selectedTabId == tabId { + if !tabIds.isEmpty { + let newIndex = min(index, max(0, tabIds.count - 1)) + selectedTabId = tabIds[newIndex] + } else { + selectedTabId = nil + } + } + + chromeRevision &+= 1 + + return removedTabId + } + + /// Move a tab within this pane + mutating func moveTab(from sourceIndex: Int, to destinationIndex: Int) { + guard tabIds.indices.contains(sourceIndex), + destinationIndex >= 0, destinationIndex <= tabIds.count else { return } + + // Treat dropping "on itself" or "after itself" as a no-op. + // This avoids remove/insert churn that can cause brief visual artifacts during drag/drop. + if destinationIndex == sourceIndex || destinationIndex == sourceIndex + 1 { + return + } + + let tabId = tabIds.remove(at: sourceIndex) + let requestedIndex = destinationIndex > sourceIndex ? destinationIndex - 1 : destinationIndex + let safeIndex = min(max(0, requestedIndex), tabIds.count) + tabIds.insert(tabId, at: safeIndex) + chromeRevision &+= 1 + } +} + +extension PaneState: Equatable { + static func == (lhs: PaneState, rhs: PaneState) -> Bool { + lhs.id == rhs.id + } +} + +import Foundation + +/// Represents a pane with its computed bounds in normalized coordinates (0-1) +struct PaneBounds { + let paneId: PaneID + let bounds: CGRect +} + +/// Recursive structure representing the split tree +/// - pane: A leaf node containing a single pane with tabs +/// - split: A branch node containing two children with a divider +indirect enum SplitNode: Identifiable, Equatable { + case pane(PaneState) + case split(SplitState) + + var id: UUID { + switch self { + case .pane(let state): + return state.id.id + case .split(let state): + return state.id + } + } + + /// Find a pane by its ID + func findPane(_ paneId: PaneID) -> PaneState? { + switch self { + case .pane(let state): + return state.id == paneId ? state : nil + case .split(let state): + return state.first.findPane(paneId) ?? state.second.findPane(paneId) + } + } + + /// Mutate a pane in place. + @discardableResult + mutating func updatePane(_ paneId: PaneID, _ update: (inout PaneState) -> Void) -> Bool { + switch self { + case .pane(var state): + guard state.id == paneId else { return false } + update(&state) + self = .pane(state) + return true + case .split(var state): + if state.first.updatePane(paneId, update) { + self = .split(state) + return true + } + if state.second.updatePane(paneId, update) { + self = .split(state) + return true + } + return false + } + } + + /// Find the leaf node for a pane by ID. + func findNode(containing paneId: PaneID) -> SplitNode? { + switch self { + case .pane(let state): + return state.id == paneId ? self : nil + case .split(let state): + return state.first.findNode(containing: paneId) ?? state.second.findNode(containing: paneId) + } + } + + /// Find a split by its ID. + func findSplit(_ splitId: UUID) -> SplitState? { + switch self { + case .pane: + return nil + case .split(let state): + if state.id == splitId { + return state + } + return state.first.findSplit(splitId) ?? state.second.findSplit(splitId) + } + } + + /// Mutate a split in place. + @discardableResult + mutating func updateSplit(_ splitId: UUID, _ update: (inout SplitState) -> Void) -> Bool { + switch self { + case .pane: + return false + case .split(var state): + if state.id == splitId { + update(&state) + self = .split(state) + return true + } + if state.first.updateSplit(splitId, update) { + self = .split(state) + return true + } + if state.second.updateSplit(splitId, update) { + self = .split(state) + return true + } + return false + } + } + + /// Get all pane IDs in the tree + var allPaneIds: [PaneID] { + switch self { + case .pane(let state): + return [state.id] + case .split(let state): + return state.first.allPaneIds + state.second.allPaneIds + } + } + + /// Get all panes in the tree + var allPanes: [PaneState] { + switch self { + case .pane(let state): + return [state] + case .split(let state): + return state.first.allPanes + state.second.allPanes + } + } + + /// Find a tab by ID. + func findTab(_ tabId: TabID) -> (paneId: PaneID, tabIndex: Int)? { + switch self { + case .pane(let state): + guard let tabIndex = state.tabIds.firstIndex(of: tabId.id) else { return nil } + return (state.id, tabIndex) + case .split(let state): + return state.first.findTab(tabId) ?? state.second.findTab(tabId) + } + } + + /// Discriminator for detecting structural changes in the tree + enum NodeType: Equatable { + case pane + case split + } + + var nodeType: NodeType { + switch self { + case .pane: return .pane + case .split: return .split + } + } + + static func == (lhs: SplitNode, rhs: SplitNode) -> Bool { + lhs.id == rhs.id + } + + /// Compute normalized bounds (0-1) for all panes in the tree + /// - Parameter availableRect: The rect available for this subtree (starts as unit rect) + /// - Returns: Array of pane IDs with their computed bounds + func computePaneBounds(in availableRect: CGRect = CGRect(x: 0, y: 0, width: 1, height: 1)) -> [PaneBounds] { + switch self { + case .pane(let paneState): + return [PaneBounds(paneId: paneState.id, bounds: availableRect)] + + case .split(let splitState): + let dividerPos = splitState.dividerPosition + let firstRect: CGRect + let secondRect: CGRect + + switch splitState.orientation { + case .horizontal: // Side-by-side: first=LEFT, second=RIGHT + firstRect = CGRect(x: availableRect.minX, y: availableRect.minY, + width: availableRect.width * dividerPos, height: availableRect.height) + secondRect = CGRect(x: availableRect.minX + availableRect.width * dividerPos, y: availableRect.minY, + width: availableRect.width * (1 - dividerPos), height: availableRect.height) + case .vertical: // Stacked: first=TOP, second=BOTTOM + firstRect = CGRect(x: availableRect.minX, y: availableRect.minY, + width: availableRect.width, height: availableRect.height * dividerPos) + secondRect = CGRect(x: availableRect.minX, y: availableRect.minY + availableRect.height * dividerPos, + width: availableRect.width, height: availableRect.height * (1 - dividerPos)) + } + + return splitState.first.computePaneBounds(in: firstRect) + + splitState.second.computePaneBounds(in: secondRect) + } + } +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/SafeTooltip.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/SafeTooltip.swift new file mode 100644 index 000000000000..9887b5cae5ed --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/SafeTooltip.swift @@ -0,0 +1,136 @@ +import AppKit +import SwiftUI + +struct SafeTooltipModifier: ViewModifier { + let text: String? + + func body(content: Content) -> some View { + content.background { + SafeTooltipViewRepresentable(text: text) + .allowsHitTesting(false) + } + } +} + +struct SafeTooltipViewRepresentable: NSViewRepresentable { + let text: String? + + func makeNSView(context: Context) -> SafeTooltipView { + let view = SafeTooltipView() + view.updateTooltip(text) + return view + } + + func updateNSView(_ nsView: SafeTooltipView, context: Context) { + nsView.updateTooltip(text) + } + + static func dismantleNSView(_ nsView: SafeTooltipView, coordinator: ()) { + nsView.invalidateTooltip() + } +} + +final class SafeTooltipView: NSView { + var tooltipTag: NSView.ToolTipTag? + var registeredBounds: NSRect = .zero + var registeredText: String? + var tooltipText: String? + + override var isOpaque: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + override func layout() { + super.layout() + refreshTooltipRegistration() + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + refreshTooltipRegistration() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window == nil { + invalidateTooltip() + } else { + refreshTooltipRegistration() + } + } + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + if superview == nil { + invalidateTooltip() + } else { + refreshTooltipRegistration() + } + } + + func updateTooltip(_ text: String?) { + let normalized = text? + .trimmingCharacters(in: .whitespacesAndNewlines) + tooltipText = normalized?.isEmpty == false ? normalized : nil + refreshTooltipRegistration() + } + + func invalidateTooltip() { + if let tooltipTag { + removeToolTip(tooltipTag) + self.tooltipTag = nil + } + registeredBounds = .zero + registeredText = nil + } + + func refreshTooltipRegistration() { + guard let tooltipText, + window != nil, + superview != nil else { + invalidateTooltip() + return + } + + let nextBounds = bounds.standardized.integral + guard nextBounds.width > 0, nextBounds.height > 0 else { + invalidateTooltip() + return + } + + if tooltipTag != nil, + nextBounds == registeredBounds, + tooltipText == registeredText { + return + } + + invalidateTooltip() + tooltipTag = addToolTip(nextBounds, owner: self, userData: nil) + registeredBounds = nextBounds + registeredText = tooltipText + } + + @objc + func view( + _ view: NSView, + stringForToolTip tag: NSView.ToolTipTag, + point: NSPoint, + userData data: UnsafeMutableRawPointer? + ) -> String { + tooltipText ?? "" + } + + deinit { + invalidateTooltip() + } +} + +extension View { + /// Uses an AppKit-backed tooltip host that explicitly unregisters its tooltip + /// before the view is detached or deallocated. + func safeHelp(_ text: String?) -> some View { + modifier(SafeTooltipModifier(text: text)) + } +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/SplitAnimator.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/SplitAnimator.swift new file mode 100644 index 000000000000..d79893e59e7b --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/SplitAnimator.swift @@ -0,0 +1,140 @@ +import Foundation +import AppKit +import QuartzCore +import CoreVideo + +/// Animates split view divider positions with display-synced updates and pixel-perfect positioning +@MainActor +final class SplitAnimator { + + // MARK: - Types + + struct Animation { + weak var splitView: NSSplitView? + let startPosition: CGFloat + let endPosition: CGFloat + let startTime: CFTimeInterval + let duration: CFTimeInterval + var onComplete: (() -> Void)? + } + + // MARK: - Properties + + var displayLink: CVDisplayLink? + var animations: [UUID: Animation] = [:] + + /// Shared animator instance + static let shared = SplitAnimator() + + /// Default animation duration in seconds + nonisolated static let defaultAnimationDuration: CFTimeInterval = 0.16 + // MARK: - Initialization + + init() { + setupDisplayLink() + } + + deinit { + if let displayLink, CVDisplayLinkIsRunning(displayLink) { + CVDisplayLinkStop(displayLink) + } + } + + // MARK: - Display Link + + func setupDisplayLink() { + var link: CVDisplayLink? + CVDisplayLinkCreateWithActiveCGDisplays(&link) + guard let link else { return } + + let callback: CVDisplayLinkOutputCallback = { _, _, _, _, _, context in + let animator = Unmanaged.fromOpaque(context!).takeUnretainedValue() + DispatchQueue.main.async { + Task { @MainActor in + animator.tick() + } + } + return kCVReturnSuccess + } + + CVDisplayLinkSetOutputCallback(link, callback, Unmanaged.passUnretained(self).toOpaque()) + displayLink = link + } + + // MARK: - Animation Control + + @discardableResult + func animate( + splitView: NSSplitView, + from startPosition: CGFloat, + to endPosition: CGFloat, + duration: CFTimeInterval = SplitAnimator.defaultAnimationDuration, + onComplete: (() -> Void)? = nil + ) -> UUID { + let id = UUID() + + splitView.layoutSubtreeIfNeeded() + splitView.setPosition(round(startPosition), ofDividerAt: 0) + splitView.layoutSubtreeIfNeeded() + + animations[id] = Animation( + splitView: splitView, + startPosition: startPosition, + endPosition: endPosition, + startTime: CACurrentMediaTime(), + duration: duration, + onComplete: onComplete + ) + + if let displayLink, !CVDisplayLinkIsRunning(displayLink) { + CVDisplayLinkStart(displayLink) + } + + return id + } + + func cancel(_ id: UUID) { + animations.removeValue(forKey: id) + stopIfNeeded() + } + + // MARK: - Frame Update + + func tick() { + let currentTime = CACurrentMediaTime() + var completedIds: [UUID] = [] + + for (id, animation) in animations { + guard let splitView = animation.splitView else { + completedIds.append(id) + continue + } + + let elapsed = currentTime - animation.startTime + let progress = min(elapsed / animation.duration, 1.0) + let eased = progress == 1.0 ? 1.0 : 1.0 - pow(2.0, -10.0 * progress) + + let position = animation.startPosition + (animation.endPosition - animation.startPosition) * eased + + // Round to whole pixels to prevent artifacts + splitView.setPosition(round(position), ofDividerAt: 0) + + if progress >= 1.0 { + completedIds.append(id) + animation.onComplete?() + } + } + + for id in completedIds { + animations.removeValue(forKey: id) + } + + stopIfNeeded() + } + + func stopIfNeeded() { + if animations.isEmpty, let displayLink, CVDisplayLinkIsRunning(displayLink) { + CVDisplayLinkStop(displayLink) + } + } +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/SplitState.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/SplitState.swift new file mode 100644 index 000000000000..18871aae333d --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/SplitState.swift @@ -0,0 +1,42 @@ +import Foundation +import SwiftUI + +/// Direction from which a new split animates in +enum SplitAnimationOrigin: Equatable, Sendable { + case fromFirst // New pane slides in from start (left/top) + case fromSecond // New pane slides in from end (right/bottom) +} + +/// State for a split node (branch in the split tree) +struct SplitState: Identifiable { + let id: UUID + var orientation: SplitOrientation + var first: SplitNode + var second: SplitNode + var dividerPosition: CGFloat // 0.0 to 1.0 + + /// Animation origin for entry animation (nil = no animation needed) + var animationOrigin: SplitAnimationOrigin? + + init( + id: UUID = UUID(), + orientation: SplitOrientation, + first: SplitNode, + second: SplitNode, + dividerPosition: CGFloat = 0.5, + animationOrigin: SplitAnimationOrigin? = nil + ) { + self.id = id + self.orientation = orientation + self.first = first + self.second = second + self.dividerPosition = dividerPosition + self.animationOrigin = animationOrigin + } +} + +extension SplitState: Equatable { + static func == (lhs: SplitState, rhs: SplitState) -> Bool { + lhs.id == rhs.id + } +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/TabBarColors.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/TabBarColors.swift new file mode 100644 index 000000000000..bfa7f346a941 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/TabBarColors.swift @@ -0,0 +1,408 @@ +import SwiftUI +import AppKit + +/// Native macOS colors for the tab bar +enum TabBarColors { + enum Constants { + static let darkTextAlpha: CGFloat = 0.82 + static let darkSecondaryTextAlpha: CGFloat = 0.62 + static let darkTertiaryTextAlpha: CGFloat = 0.35 + static let lightTextAlpha: CGFloat = 0.82 + static let lightSecondaryTextAlpha: CGFloat = 0.68 + static let lightTertiaryTextAlpha: CGFloat = 0.35 + } + + static func chromeBackgroundColor( + for appearance: WorkspaceLayoutConfiguration.Appearance + ) -> NSColor? { + guard let value = appearance.chromeColors.backgroundHex else { return nil } + return NSColor(workspaceSplitHex: value) + } + + static func paneBackgroundColor( + for appearance: WorkspaceLayoutConfiguration.Appearance + ) -> NSColor? { + guard let value = appearance.chromeColors.paneBackgroundHex else { + return chromeBackgroundColor(for: appearance) + } + return NSColor(workspaceSplitHex: value) + } + + static func tabBarBackgroundColor( + for appearance: WorkspaceLayoutConfiguration.Appearance + ) -> NSColor? { + guard let value = appearance.chromeColors.tabBarBackgroundHex else { + return chromeBackgroundColor(for: appearance) + } + return NSColor(workspaceSplitHex: value) + } + + static func splitButtonBackdropColor( + for appearance: WorkspaceLayoutConfiguration.Appearance + ) -> NSColor? { + guard let value = appearance.chromeColors.splitButtonBackdropHex else { + return tabBarBackgroundColor(for: appearance) + } + return NSColor(workspaceSplitHex: value) + } + + static func nonClearColor(_ color: NSColor?) -> NSColor? { + guard let color else { return nil } + let resolved = color.usingColorSpace(.sRGB) ?? color + return resolved.alphaComponent <= 0.001 ? nil : resolved + } + + static func semanticTabBarBackgroundColor( + for appearance: WorkspaceLayoutConfiguration.Appearance + ) -> NSColor? { + nonClearColor(tabBarBackgroundColor(for: appearance)) + ?? nonClearColor(chromeBackgroundColor(for: appearance)) + } + + static func chromeBorderColor( + for appearance: WorkspaceLayoutConfiguration.Appearance + ) -> NSColor? { + guard let value = appearance.chromeColors.borderHex else { return nil } + return NSColor(workspaceSplitHex: value) + } + + static func effectiveBackgroundColor( + for appearance: WorkspaceLayoutConfiguration.Appearance, + fallback fallbackColor: NSColor + ) -> NSColor { + chromeBackgroundColor(for: appearance) ?? fallbackColor + } + + static func effectiveTextColor( + for appearance: WorkspaceLayoutConfiguration.Appearance, + secondary: Bool + ) -> NSColor { + guard let custom = semanticTabBarBackgroundColor(for: appearance) else { + return secondary ? .secondaryLabelColor : .labelColor + } + + if custom.isWorkspaceLayoutLightColor { + let alpha = secondary ? Constants.darkSecondaryTextAlpha : Constants.darkTextAlpha + return NSColor.black.withAlphaComponent(alpha) + } + + let alpha = secondary ? Constants.lightSecondaryTextAlpha : Constants.lightTextAlpha + return NSColor.white.withAlphaComponent(alpha) + } + + static func effectiveInactiveSelectedIndicatorColor( + for appearance: WorkspaceLayoutConfiguration.Appearance + ) -> NSColor { + guard let custom = chromeBackgroundColor(for: appearance) else { + return .tertiaryLabelColor + } + + if custom.isWorkspaceLayoutLightColor { + return NSColor.black.withAlphaComponent(Constants.darkTertiaryTextAlpha) + } + + return NSColor.white.withAlphaComponent(Constants.lightTertiaryTextAlpha) + } + + static func paneBackground(for appearance: WorkspaceLayoutConfiguration.Appearance) -> Color { + Color(nsColor: nsColorPaneBackground(for: appearance)) + } + + static func nsColorPaneBackground(for appearance: WorkspaceLayoutConfiguration.Appearance) -> NSColor { + paneBackgroundColor(for: appearance) ?? .textBackgroundColor + } + + // MARK: - Tab Bar Background + + static var barBackground: Color { + Color(nsColor: .windowBackgroundColor) + } + + static func barBackground(for appearance: WorkspaceLayoutConfiguration.Appearance) -> Color { + Color(nsColor: nsColorBarBackground(for: appearance)) + } + + static func nsColorBarBackground(for appearance: WorkspaceLayoutConfiguration.Appearance) -> NSColor { + tabBarBackgroundColor(for: appearance) + ?? effectiveBackgroundColor(for: appearance, fallback: .windowBackgroundColor) + } + + static func nsColorChromeBackground(for appearance: WorkspaceLayoutConfiguration.Appearance) -> NSColor { + effectiveBackgroundColor(for: appearance, fallback: .windowBackgroundColor) + } + + static func nsColorSplitButtonBackdropSurface(for appearance: WorkspaceLayoutConfiguration.Appearance) -> NSColor { + splitButtonBackdropColor(for: appearance) ?? nsColorBarBackground(for: appearance) + } + + static func nsColorSplitButtonBackdrop( + for appearance: WorkspaceLayoutConfiguration.Appearance, + focused: Bool = true + ) -> NSColor { + precompositedPaneBackground(for: appearance, focused: focused) + } + + static func shouldPaintSplitButtonBackdrop(for appearance: WorkspaceLayoutConfiguration.Appearance) -> Bool { + splitButtonBackdropColor(for: appearance) != nil + || tabBarBackgroundColor(for: appearance) != nil + || chromeBackgroundColor(for: appearance) != nil + } + + static var barMaterial: Material { + .bar + } + + // MARK: - Tab States + + static var activeTabBackground: Color { + Color(nsColor: .controlBackgroundColor) + } + + static func activeTabBackground(for appearance: WorkspaceLayoutConfiguration.Appearance) -> Color { + guard let custom = tabBarBackgroundColor(for: appearance) else { + return activeTabBackground + } + if appearance.usesSharedBackdrop { + let semanticBackground = semanticTabBarBackgroundColor(for: appearance) ?? custom + let overlayColor = semanticBackground.isWorkspaceLayoutLightColor + ? NSColor.black.withAlphaComponent(0.06) + : NSColor.white.withAlphaComponent(0.08) + return Color(nsColor: overlayColor) + } + let adjusted = custom.isWorkspaceLayoutLightColor + ? custom.workspaceSplitDarken(by: 0.065) + : custom.workspaceSplitLighten(by: 0.12) + return Color(nsColor: adjusted) + } + + static var hoveredTabBackground: Color { + Color(nsColor: .controlBackgroundColor).opacity(0.5) + } + + static func hoveredTabBackground(for appearance: WorkspaceLayoutConfiguration.Appearance) -> Color { + guard let custom = tabBarBackgroundColor(for: appearance) else { + return hoveredTabBackground + } + if appearance.usesSharedBackdrop { + let semanticBackground = semanticTabBarBackgroundColor(for: appearance) ?? custom + let overlayColor = semanticBackground.isWorkspaceLayoutLightColor + ? NSColor.black.withAlphaComponent(0.055) + : NSColor.white.withAlphaComponent(0.075) + return Color(nsColor: overlayColor) + } + let adjusted = custom.isWorkspaceLayoutLightColor + ? custom.workspaceSplitDarken(by: 0.03) + : custom.workspaceSplitLighten(by: 0.07) + return Color(nsColor: adjusted.withAlphaComponent(0.78)) + } + + static var inactiveTabBackground: Color { + .clear + } + + // MARK: - Text Colors + + static var activeText: Color { + Color(nsColor: .labelColor) + } + + static func activeText(for appearance: WorkspaceLayoutConfiguration.Appearance) -> Color { + Color(nsColor: effectiveTextColor(for: appearance, secondary: false)) + } + + static func nsColorActiveText(for appearance: WorkspaceLayoutConfiguration.Appearance) -> NSColor { + effectiveTextColor(for: appearance, secondary: false) + } + + static var inactiveText: Color { + Color(nsColor: .secondaryLabelColor) + } + + static func inactiveText(for appearance: WorkspaceLayoutConfiguration.Appearance) -> Color { + Color(nsColor: effectiveTextColor(for: appearance, secondary: true)) + } + + static func nsColorInactiveText(for appearance: WorkspaceLayoutConfiguration.Appearance) -> NSColor { + effectiveTextColor(for: appearance, secondary: true) + } + + static func splitActionIcon(for appearance: WorkspaceLayoutConfiguration.Appearance, isPressed: Bool) -> Color { + Color(nsColor: nsColorSplitActionIcon(for: appearance, isPressed: isPressed)) + } + + static func nsColorSplitActionIcon( + for appearance: WorkspaceLayoutConfiguration.Appearance, + isPressed: Bool + ) -> NSColor { + isPressed ? nsColorActiveText(for: appearance) : nsColorInactiveText(for: appearance) + } + + // MARK: - Borders & Indicators + + static var separator: Color { + Color(nsColor: .separatorColor) + } + + static func separator(for appearance: WorkspaceLayoutConfiguration.Appearance) -> Color { + Color(nsColor: nsColorSeparator(for: appearance)) + } + + static func nsColorSeparator(for appearance: WorkspaceLayoutConfiguration.Appearance) -> NSColor { + if let explicit = chromeBorderColor(for: appearance) { + return explicit + } + + guard let custom = tabBarBackgroundColor(for: appearance) else { + return .separatorColor + } + let alpha: CGFloat = custom.isWorkspaceLayoutLightColor ? 0.26 : 0.36 + let tone = custom.isWorkspaceLayoutLightColor + ? custom.workspaceSplitDarken(by: 0.12) + : custom.workspaceSplitLighten(by: 0.16) + return tone.withAlphaComponent(alpha) + } + + static func selectedIndicator( + for appearance: WorkspaceLayoutConfiguration.Appearance, + focused: Bool + ) -> Color { + Color(nsColor: nsColorSelectedIndicator(for: appearance, focused: focused)) + } + + static func nsColorSelectedIndicator( + for appearance: WorkspaceLayoutConfiguration.Appearance, + focused: Bool + ) -> NSColor { + if focused { + return .controlAccentColor + } + + return effectiveInactiveSelectedIndicatorColor(for: appearance) + } + + static var dropIndicator: Color { + Color.accentColor + } + + static func dropIndicator(for appearance: WorkspaceLayoutConfiguration.Appearance) -> Color { + _ = appearance + return dropIndicator + } + + static var focusRing: Color { + Color.accentColor.opacity(0.5) + } + + static var dirtyIndicator: Color { + Color(nsColor: .labelColor).opacity(0.6) + } + + static func dirtyIndicator(for appearance: WorkspaceLayoutConfiguration.Appearance) -> Color { + guard chromeBackgroundColor(for: appearance) != nil else { return dirtyIndicator } + return activeText(for: appearance).opacity(0.72) + } + + private static func precompositedPaneBackground( + for appearance: WorkspaceLayoutConfiguration.Appearance, + focused: Bool + ) -> NSColor { + let chrome = nsColorPaneBackground(for: appearance) + let windowBackground = NSColor.windowBackgroundColor + guard let foreground = chrome.usingColorSpace(.sRGB), + let background = windowBackground.usingColorSpace(.sRGB) else { + return chrome.withAlphaComponent(1.0) + } + let alpha = focused ? foreground.alphaComponent : foreground.alphaComponent * 0.95 + let oneMinusAlpha = 1.0 - alpha + let red = foreground.redComponent * alpha + background.redComponent * oneMinusAlpha + let green = foreground.greenComponent * alpha + background.greenComponent * oneMinusAlpha + let blue = foreground.blueComponent * alpha + background.blueComponent * oneMinusAlpha + return NSColor(red: red, green: green, blue: blue, alpha: 1.0) + } + + static var notificationBadge: Color { + Color(nsColor: .systemBlue) + } + + static func notificationBadge(for appearance: WorkspaceLayoutConfiguration.Appearance) -> Color { + _ = appearance + return notificationBadge + } + + // MARK: - Shadows + + static var tabShadow: Color { + Color.black.opacity(0.08) + } +} + +extension NSColor { + static let workspaceSplitHexDigits = CharacterSet(charactersIn: "0123456789abcdefABCDEF") + + convenience init?(workspaceSplitHex value: String) { + var hex = value.trimmingCharacters(in: .whitespacesAndNewlines) + if hex.hasPrefix("#") { + hex.removeFirst() + } + guard hex.count == 6 || hex.count == 8 else { return nil } + guard hex.unicodeScalars.allSatisfy({ Self.workspaceSplitHexDigits.contains($0) }) else { return nil } + guard let rgba = UInt64(hex, radix: 16) else { return nil } + let red: CGFloat + let green: CGFloat + let blue: CGFloat + let alpha: CGFloat + if hex.count == 8 { + red = CGFloat((rgba & 0xFF000000) >> 24) / 255.0 + green = CGFloat((rgba & 0x00FF0000) >> 16) / 255.0 + blue = CGFloat((rgba & 0x0000FF00) >> 8) / 255.0 + alpha = CGFloat(rgba & 0x000000FF) / 255.0 + } else { + red = CGFloat((rgba & 0xFF0000) >> 16) / 255.0 + green = CGFloat((rgba & 0x00FF00) >> 8) / 255.0 + blue = CGFloat(rgba & 0x0000FF) / 255.0 + alpha = 1.0 + } + self.init(red: red, green: green, blue: blue, alpha: alpha) + } + + var isWorkspaceLayoutLightColor: Bool { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + let color = usingColorSpace(.sRGB) ?? self + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + let luminance = (0.299 * red) + (0.587 * green) + (0.114 * blue) + return luminance > 0.5 + } + + func workspaceSplitLighten(by amount: CGFloat) -> NSColor { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + let color = usingColorSpace(.sRGB) ?? self + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return NSColor( + red: min(1.0, red + amount), + green: min(1.0, green + amount), + blue: min(1.0, blue + amount), + alpha: alpha + ) + } + + func workspaceSplitDarken(by amount: CGFloat) -> NSColor { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + let color = usingColorSpace(.sRGB) ?? self + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return NSColor( + red: max(0.0, red - amount), + green: max(0.0, green - amount), + blue: max(0.0, blue - amount), + alpha: alpha + ) + } +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/TabBarMetrics.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/TabBarMetrics.swift new file mode 100644 index 000000000000..3262839e399d --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/TabBarMetrics.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Sizing and spacing constants for the tab bar (following macOS HIG) +enum TabBarMetrics { + // MARK: - Tab Bar + + static let barHeight: CGFloat = 30 + static let barPadding: CGFloat = 0 + + // MARK: - Individual Tabs + + static let tabHeight: CGFloat = 30 + static let tabMinWidth: CGFloat = 48 + static let tabMaxWidth: CGFloat = 220 + static let tabCornerRadius: CGFloat = 0 + static let tabHorizontalPadding: CGFloat = 6 + static let tabSpacing: CGFloat = 0 + static let activeIndicatorHeight: CGFloat = 1.5 + + // MARK: - Tab Content + + static let iconSize: CGFloat = 14 + static let titleFontSize: CGFloat = 11 + static let closeButtonSize: CGFloat = 16 + static let closeIconSize: CGFloat = 9 + static let dirtyIndicatorSize: CGFloat = 8 + static let notificationBadgeSize: CGFloat = 6 + static let contentSpacing: CGFloat = 6 + + // MARK: - Drop Indicator + + static let dropIndicatorWidth: CGFloat = 2 + static let dropIndicatorHeight: CGFloat = 20 + + // MARK: - Split View + + static let minimumPaneWidth: CGFloat = 100 + static let minimumPaneHeight: CGFloat = 100 + static let dividerThickness: CGFloat = 1 + + // MARK: - Animations + + static let selectionDuration: Double = 0.15 + static let closeDuration: Double = 0.2 + static let reorderDuration: Double = 0.3 + static let reorderBounce: Double = 0.15 + static let hoverDuration: Double = 0.1 + + // MARK: - Split Animations (120fps via CADisplayLink) + + /// Duration for split entry animation (fast and snappy like Hyprland) + static let splitAnimationDuration: Double = 0.15 +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/TabContextMenuState.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/TabContextMenuState.swift new file mode 100644 index 000000000000..9bced8c916fc --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/TabContextMenuState.swift @@ -0,0 +1,109 @@ +import AppKit +import SwiftUI + +struct TabContextMenuState { + let isPinned: Bool + let isUnread: Bool + let isBrowser: Bool + let isTerminal: Bool + let hasCustomTitle: Bool + let canCloseToLeft: Bool + let canCloseToRight: Bool + let canCloseOthers: Bool + let canMoveToLeftPane: Bool + let canMoveToRightPane: Bool + let isZoomed: Bool + let hasSplits: Bool + let shortcuts: [TabContextAction: KeyboardShortcut] + + var canMarkAsUnread: Bool { + !isUnread + } + + var canMarkAsRead: Bool { + isUnread + } +} + +struct WorkspacePaneActionEligibilityFacts { + let paneId: PaneID + let tabs: [WorkspaceLayout.Tab] + let canMoveToLeftPane: Bool + let canMoveToRightPane: Bool + let isZoomed: Bool + let hasSplits: Bool + let shortcuts: [TabContextAction: KeyboardShortcut] + + let canCloseToLeftByIndex: [Bool] + let canCloseToRightByIndex: [Bool] + let canCloseOthersByIndex: [Bool] + + init( + paneId: PaneID, + tabs: [WorkspaceLayout.Tab], + canMoveToLeftPane: Bool, + canMoveToRightPane: Bool, + isZoomed: Bool, + hasSplits: Bool, + shortcuts: [TabContextAction: KeyboardShortcut] + ) { + self.paneId = paneId + self.tabs = tabs + self.canMoveToLeftPane = canMoveToLeftPane + self.canMoveToRightPane = canMoveToRightPane + self.isZoomed = isZoomed + self.hasSplits = hasSplits + self.shortcuts = shortcuts + + var prefixUnpinned = Array(repeating: 0, count: tabs.count + 1) + for index in tabs.indices { + let unpinnedDelta = tabs[index].isPinned ? 0 : 1 + prefixUnpinned[index + 1] = prefixUnpinned[index] + unpinnedDelta + } + let totalUnpinned = prefixUnpinned[tabs.count] + + var canCloseLeft: [Bool] = [] + var canCloseRight: [Bool] = [] + var canCloseOthers: [Bool] = [] + canCloseLeft.reserveCapacity(tabs.count) + canCloseRight.reserveCapacity(tabs.count) + canCloseOthers.reserveCapacity(tabs.count) + + for index in tabs.indices { + let leftUnpinned = prefixUnpinned[index] + let rightUnpinned = totalUnpinned - prefixUnpinned[index + 1] + let selfUnpinned = tabs[index].isPinned ? 0 : 1 + let otherUnpinned = totalUnpinned - selfUnpinned + + canCloseLeft.append(leftUnpinned > 0) + canCloseRight.append(rightUnpinned > 0) + canCloseOthers.append(otherUnpinned > 0) + } + + canCloseToLeftByIndex = canCloseLeft + canCloseToRightByIndex = canCloseRight + canCloseOthersByIndex = canCloseOthers + } + + func contextMenuState(for tab: WorkspaceLayout.Tab, at index: Int) -> TabContextMenuState { + let canCloseToLeft = canCloseToLeftByIndex.indices.contains(index) ? canCloseToLeftByIndex[index] : false + let canCloseToRight = canCloseToRightByIndex.indices.contains(index) ? canCloseToRightByIndex[index] : false + let canCloseOthers = canCloseOthersByIndex.indices.contains(index) ? canCloseOthersByIndex[index] : false + + return TabContextMenuState( + isPinned: tab.isPinned, + isUnread: tab.showsNotificationBadge, + isBrowser: tab.kind == .browser, + isTerminal: tab.kind == .terminal, + hasCustomTitle: tab.hasCustomTitle, + canCloseToLeft: canCloseToLeft, + canCloseToRight: canCloseToRight, + canCloseOthers: canCloseOthers, + canMoveToLeftPane: canMoveToLeftPane, + canMoveToRightPane: canMoveToRightPane, + isZoomed: isZoomed, + hasSplits: hasSplits, + shortcuts: shortcuts + ) + } +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/TabTransferData.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/TabTransferData.swift new file mode 100644 index 000000000000..a628cf51f671 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/TabTransferData.swift @@ -0,0 +1,70 @@ +import Foundation +import SwiftUI +import UniformTypeIdentifiers + +/// Custom UTTypes for tab drag and drop +extension UTType { + static var tabItem: UTType { + UTType(exportedAs: "com.splittabbar.tabitem") + } + + static var tabTransfer: UTType { + UTType(exportedAs: "com.splittabbar.tabtransfer", conformingTo: .data) + } +} + +/// Transfer data that includes source pane information for cross-pane moves +struct TabTransferData: Codable, Transferable { + struct LegacyTabInfo: Codable { + let id: UUID + } + + let tabId: TabID + let sourcePaneId: UUID + let sourceProcessId: Int32 + + init(tabId: TabID, sourcePaneId: UUID, sourceProcessId: Int32 = Int32(ProcessInfo.processInfo.processIdentifier)) { + self.tabId = tabId + self.sourcePaneId = sourcePaneId + self.sourceProcessId = sourceProcessId + } + + var isFromCurrentProcess: Bool { + sourceProcessId == Int32(ProcessInfo.processInfo.processIdentifier) + } + + enum CodingKeys: String, CodingKey { + case tabId + case tab + case sourcePaneId + case sourceProcessId + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if let tabId = try container.decodeIfPresent(TabID.self, forKey: .tabId) { + self.tabId = tabId + } else if let legacyTab = try container.decodeIfPresent(LegacyTabInfo.self, forKey: .tab) { + self.tabId = TabID(id: legacyTab.id) + } else { + let legacyTab = try container.decode(WorkspaceLayout.Tab.self, forKey: .tab) + self.tabId = legacyTab.id + } + self.sourcePaneId = try container.decode(UUID.self, forKey: .sourcePaneId) + // Legacy payloads won't include this field. Treat as foreign process to reject cross-instance drops. + self.sourceProcessId = try container.decodeIfPresent(Int32.self, forKey: .sourceProcessId) ?? -1 + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(tabId, forKey: .tabId) + // Keep legacy tab.id payload for older in-process drop consumers. + try container.encode(LegacyTabInfo(id: tabId.id), forKey: .tab) + try container.encode(sourcePaneId, forKey: .sourcePaneId) + try container.encode(sourceProcessId, forKey: .sourceProcessId) + } + + static var transferRepresentation: some TransferRepresentation { + CodableRepresentation(contentType: .tabTransfer) + } +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutConfiguration.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutConfiguration.swift new file mode 100644 index 000000000000..edc720b925e4 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutConfiguration.swift @@ -0,0 +1,253 @@ +import Foundation +import SwiftUI + +enum WorkspaceLayoutTabKind: String, Codable, Sendable { + case terminal + case browser + case markdown +} + +struct WorkspaceLayoutConfiguration: Sendable { + var allowSplits: Bool + var allowCloseTabs: Bool + var allowCloseLastPane: Bool + var allowTabReordering: Bool + var allowCrossPaneTabMove: Bool + var autoCloseEmptyPanes: Bool + var newTabPosition: NewTabPosition + var appearance: Appearance + + static let `default` = WorkspaceLayoutConfiguration() + + init( + allowSplits: Bool = true, + allowCloseTabs: Bool = true, + allowCloseLastPane: Bool = false, + allowTabReordering: Bool = true, + allowCrossPaneTabMove: Bool = true, + autoCloseEmptyPanes: Bool = true, + newTabPosition: NewTabPosition = .current, + appearance: Appearance = .default + ) { + self.allowSplits = allowSplits + self.allowCloseTabs = allowCloseTabs + self.allowCloseLastPane = allowCloseLastPane + self.allowTabReordering = allowTabReordering + self.allowCrossPaneTabMove = allowCrossPaneTabMove + self.autoCloseEmptyPanes = autoCloseEmptyPanes + self.newTabPosition = newTabPosition + self.appearance = appearance + } + + struct SplitButtonTooltips: Sendable, Equatable { + var newTerminal: String + var newBrowser: String + var splitRight: String + var splitDown: String + + static let `default` = SplitButtonTooltips() + + init( + newTerminal: String = String(localized: "workspace.tooltip.newTerminal", defaultValue: "New Terminal"), + newBrowser: String = String(localized: "workspace.tooltip.newBrowser", defaultValue: "New Browser"), + splitRight: String = String(localized: "workspace.tooltip.splitRight", defaultValue: "Split Right"), + splitDown: String = String(localized: "workspace.tooltip.splitDown", defaultValue: "Split Down") + ) { + self.newTerminal = newTerminal + self.newBrowser = newBrowser + self.splitRight = splitRight + self.splitDown = splitDown + } + } + + struct Appearance: Sendable { + enum SplitButtonBackdropStyle: Int, CaseIterable, Sendable { + case precompositedPaneBackground = 0 + case opaquePaneBackground = 1 + case opaqueBarBackground = 2 + case windowBackground = 3 + case controlBackground = 4 + case precompositedBarBackground = 5 + case translucentChrome = 6 + case hidden = 7 + } + + struct SplitButtonBackdropEffect: Sendable { + var style: SplitButtonBackdropStyle + var fadeWidth: CGFloat + var contentFadeWidth: CGFloat + var solidWidth: CGFloat + var fadeRampStartFraction: CGFloat + var leadingOpacity: CGFloat + var trailingOpacity: CGFloat + var contentOcclusionFraction: CGFloat + var masksTabContent: Bool + + init( + style: SplitButtonBackdropStyle = .translucentChrome, + fadeWidth: CGFloat = 136, + contentFadeWidth: CGFloat = 42, + solidWidth: CGFloat = 2, + fadeRampStartFraction: CGFloat = 0.80, + leadingOpacity: CGFloat = 0, + trailingOpacity: CGFloat = 0.80, + contentOcclusionFraction: CGFloat = 1.0, + masksTabContent: Bool = true + ) { + self.style = style + self.fadeWidth = max(0, fadeWidth) + self.contentFadeWidth = max(0, contentFadeWidth) + self.solidWidth = max(0, solidWidth) + self.fadeRampStartFraction = min(max(0, fadeRampStartFraction), 0.95) + self.leadingOpacity = min(max(0, leadingOpacity), 1) + self.trailingOpacity = min(max(0, trailingOpacity), 1) + self.contentOcclusionFraction = min(max(0, contentOcclusionFraction), 1) + self.masksTabContent = masksTabContent + } + + static let `default` = SplitButtonBackdropEffect() + } + + struct ChromeColors: Sendable { + var backgroundHex: String? + var tabBarBackgroundHex: String? + var splitButtonBackdropHex: String? + var paneBackgroundHex: String? + var borderHex: String? + + init( + backgroundHex: String? = nil, + tabBarBackgroundHex: String? = nil, + splitButtonBackdropHex: String? = nil, + paneBackgroundHex: String? = nil, + borderHex: String? = nil + ) { + self.backgroundHex = backgroundHex + self.tabBarBackgroundHex = tabBarBackgroundHex + self.splitButtonBackdropHex = splitButtonBackdropHex + self.paneBackgroundHex = paneBackgroundHex + self.borderHex = borderHex + } + } + + var tabBarHeight: CGFloat + var tabMinWidth: CGFloat + var tabMaxWidth: CGFloat + var tabTitleFontSize: CGFloat + var tabSpacing: CGFloat + var minimumPaneWidth: CGFloat + var minimumPaneHeight: CGFloat + var showSplitButtons: Bool + var splitButtonsOnHover: Bool + var splitButtonBackdropStyle: SplitButtonBackdropStyle? + var splitButtonBackdropEffect: SplitButtonBackdropEffect? + var tabBarLeadingInset: CGFloat + var splitButtonTooltips: SplitButtonTooltips + var animationDuration: Double + var enableAnimations: Bool + var chromeColors: ChromeColors + var usesSharedBackdrop: Bool + + static let `default` = Appearance() + + init( + tabBarHeight: CGFloat = 30, + tabMinWidth: CGFloat = 48, + tabMaxWidth: CGFloat = 220, + tabTitleFontSize: CGFloat = 11, + tabSpacing: CGFloat = 0, + minimumPaneWidth: CGFloat = 100, + minimumPaneHeight: CGFloat = 100, + showSplitButtons: Bool = true, + splitButtonsOnHover: Bool = false, + splitButtonBackdropStyle: SplitButtonBackdropStyle? = nil, + splitButtonBackdropEffect: SplitButtonBackdropEffect? = nil, + tabBarLeadingInset: CGFloat = 0, + splitButtonTooltips: SplitButtonTooltips = .default, + animationDuration: Double = 0.15, + enableAnimations: Bool = false, + chromeColors: ChromeColors = .init(), + usesSharedBackdrop: Bool = false + ) { + self.tabBarHeight = tabBarHeight + self.tabMinWidth = tabMinWidth + self.tabMaxWidth = tabMaxWidth + self.tabTitleFontSize = tabTitleFontSize + self.tabSpacing = tabSpacing + self.minimumPaneWidth = minimumPaneWidth + self.minimumPaneHeight = minimumPaneHeight + self.showSplitButtons = showSplitButtons + self.splitButtonsOnHover = splitButtonsOnHover + self.splitButtonBackdropStyle = splitButtonBackdropStyle + self.splitButtonBackdropEffect = splitButtonBackdropEffect + self.tabBarLeadingInset = tabBarLeadingInset + self.splitButtonTooltips = splitButtonTooltips + self.animationDuration = animationDuration + self.enableAnimations = enableAnimations + self.chromeColors = chromeColors + self.usesSharedBackdrop = usesSharedBackdrop + } + } +} + +enum WorkspaceLayout { + struct Tab: Identifiable, Hashable, Codable, Sendable { + var id: TabID + var title: String + var hasCustomTitle: Bool + var icon: String? + var iconImageData: Data? + var kind: WorkspaceLayoutTabKind? + var isDirty: Bool + var showsNotificationBadge: Bool + var isLoading: Bool + var isPinned: Bool + + init( + id: TabID = TabID(), + title: String, + hasCustomTitle: Bool = false, + icon: String? = nil, + iconImageData: Data? = nil, + kind: WorkspaceLayoutTabKind? = nil, + isDirty: Bool = false, + showsNotificationBadge: Bool = false, + isLoading: Bool = false, + isPinned: Bool = false + ) { + self.id = id + self.title = title + self.hasCustomTitle = hasCustomTitle + self.icon = icon + self.iconImageData = iconImageData + self.kind = kind + self.isDirty = isDirty + self.showsNotificationBadge = showsNotificationBadge + self.isLoading = isLoading + self.isPinned = isPinned + } + + static func rendered( + id: TabID = TabID(), + title: String, + hasCustomTitle: Bool = false, + icon: String? = nil, + iconImageData: Data? = nil, + kind: WorkspaceLayoutTabKind? = nil, + isDirty: Bool = false, + showsNotificationBadge: Bool = false, + isLoading: Bool = false, + isPinned: Bool = false + ) -> Self { + var tab = Self(id: id, title: title, isPinned: isPinned) + tab.hasCustomTitle = hasCustomTitle + tab.icon = icon + tab.iconImageData = iconImageData + tab.kind = kind + tab.isDirty = isDirty + tab.showsNotificationBadge = showsNotificationBadge + tab.isLoading = isLoading + return tab + } + } +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutContentSnapshots.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutContentSnapshots.swift new file mode 100644 index 000000000000..40acfd1ff060 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutContentSnapshots.swift @@ -0,0 +1,367 @@ +import AppKit +import Foundation +import SwiftUI + + +@MainActor +enum WorkspacePaneContent { + case terminal(WorkspaceTerminalPaneContent) + case browser(WorkspaceBrowserPaneContent) + case markdown(WorkspaceMarkdownPaneContent) + case placeholder(WorkspacePlaceholderPaneContent) +} + +enum WorkspacePaneMountIdentity: Hashable { + case terminal(UUID) + case browser(UUID) + case markdown(UUID) + case placeholder(UUID) +} + +extension WorkspacePaneContent { + var usesDirectPaneHost: Bool { + switch self { + case .terminal, .browser: + return true + case .markdown, .placeholder: + return false + } + } + + func mountIdentity(contentId: UUID) -> WorkspacePaneMountIdentity { + switch self { + case .terminal(let descriptor): + return .terminal(descriptor.surfaceId) + case .browser(let descriptor): + return .browser(descriptor.surfaceId) + case .markdown(let descriptor): + return .markdown(descriptor.surfaceId) + case .placeholder: + return .placeholder(contentId) + } + } +} + +enum WorkspaceSurfaceRevealPhase: Equatable, Sendable { + case hidden + case waitingForWindow + case waitingForGeometry + case waitingForRuntime + case waitingForFirstFrame + case visible + + init(lifecycleFacts: WorkspaceSurfaceLifecycleFacts) { + if !lifecycleFacts.isVisibleInUI { + self = .hidden + } else if !lifecycleFacts.isWindowed { + self = .waitingForWindow + } else if !lifecycleFacts.hasUsableGeometry { + self = .waitingForGeometry + } else if !lifecycleFacts.hasRuntime { + self = .waitingForRuntime + } else if !lifecycleFacts.hasPresentedFrame { + self = .waitingForFirstFrame + } else { + self = .visible + } + } + + var showsLoadingCover: Bool { + switch self { + case .hidden, .visible: + false + case .waitingForWindow, .waitingForGeometry, .waitingForRuntime, .waitingForFirstFrame: + true + } + } +} + +struct WorkspaceSurfaceLifecycleFacts: Equatable, Sendable { + let isVisibleInUI: Bool + let isWindowed: Bool + let hasUsableGeometry: Bool + let hasRuntime: Bool + let hasPresentedFrame: Bool +} + +struct WorkspaceSurfacePresentationFacts: Equatable, Sendable { + let revealPhase: WorkspaceSurfaceRevealPhase + + var isVisible: Bool { + revealPhase == .visible + } + + var showsLoadingCover: Bool { + revealPhase.showsLoadingCover + } + + static let hidden = WorkspaceSurfacePresentationFacts(revealPhase: .hidden) + static let visible = WorkspaceSurfacePresentationFacts(revealPhase: .visible) + + static func terminal(_ facts: WorkspaceSurfaceLifecycleFacts) -> WorkspaceSurfacePresentationFacts { + WorkspaceSurfacePresentationFacts(revealPhase: WorkspaceSurfaceRevealPhase(lifecycleFacts: facts)) + } +} + +@MainActor +struct WorkspaceTerminalPaneAppearance { + let unfocusedOverlayNSColor: NSColor + let unfocusedOverlayOpacity: Double +} + +@MainActor +struct WorkspaceTerminalPaneContent { + let surfaceId: UUID + let isFocused: Bool + let isVisibleInUI: Bool + let isSplit: Bool + let appearance: WorkspaceTerminalPaneAppearance + let hasUnreadNotification: Bool + let onFocus: () -> Void + let onTriggerFlash: () -> Void +} + +@MainActor +struct WorkspaceBrowserPaneContent { + let surfaceId: UUID + let paneId: PaneID + let isFocused: Bool + let isVisibleInUI: Bool + let prefersLocalInlineHosting: Bool + let portalPriority: Int + let onRequestPanelFocus: () -> Void +} + +@MainActor +struct WorkspaceMarkdownPaneContent { + let surfaceId: UUID + let isVisibleInUI: Bool + let onRequestPanelFocus: () -> Void +} + +@MainActor +struct WorkspacePlaceholderPaneContent { + let paneId: PaneID + let onCreateTerminal: () -> Void + let onCreateBrowser: () -> Void +} + +struct WorkspaceLayoutTabChromeSnapshot { + let tab: WorkspaceLayout.Tab + let contextMenuState: TabContextMenuState + let isSelected: Bool + let showsZoomIndicator: Bool +} + +struct WorkspaceLayoutPaneChromeSnapshot { + let paneId: PaneID + let tabs: [WorkspaceLayoutTabChromeSnapshot] + let selectedTabId: UUID? + let isFocused: Bool + let showSplitButtons: Bool + let chromeRevision: UInt64 +} + +struct WorkspaceLayoutPaneRenderSnapshot { + let paneId: PaneID + let chrome: WorkspaceLayoutPaneChromeSnapshot + let contentId: UUID + let content: WorkspacePaneContent +} + +struct WorkspaceLayoutViewportSnapshot { + let paneId: PaneID + let contentId: UUID + let mountIdentity: WorkspacePaneMountIdentity + let content: WorkspacePaneContent + let frame: CGRect +} + +struct WorkspaceLayoutSplitRenderSnapshot { + let splitId: UUID + let orientation: SplitOrientation + let dividerPosition: CGFloat + let animationOrigin: SplitAnimationOrigin? + let first: WorkspaceLayoutRenderNodeSnapshot + let second: WorkspaceLayoutRenderNodeSnapshot +} + +indirect enum WorkspaceLayoutRenderNodeSnapshot { + case pane(WorkspaceLayoutPaneRenderSnapshot) + case split(WorkspaceLayoutSplitRenderSnapshot) + + var paneIds: Set { + switch self { + case .pane(let pane): + return [pane.paneId.id] + case .split(let split): + return split.first.paneIds.union(split.second.paneIds) + } + } + + var splitIds: Set { + switch self { + case .pane: + return [] + case .split(let split): + return Set([split.splitId]) + .union(split.first.splitIds) + .union(split.second.splitIds) + } + } +} + +struct WorkspaceLayoutLocalDragSnapshot: Equatable { + let tabId: TabID + let sourcePaneId: PaneID +} + +func workspaceLayoutEffectiveTabDropTargetIndex( + rawTargetIndex: Int, + tabIds: [TabID], + paneId: PaneID, + localTabDrag: WorkspaceLayoutLocalDragSnapshot? +) -> Int? { + guard let localTabDrag, + localTabDrag.sourcePaneId == paneId, + let sourceIndex = tabIds.firstIndex(of: localTabDrag.tabId) else { + return rawTargetIndex + } + + if rawTargetIndex == sourceIndex || rawTargetIndex == sourceIndex + 1 { + return nil + } + + return rawTargetIndex +} + +enum WorkspacePaneDropOverlayPhase: Equatable { + case hidden + case visible + case hiding +} + +struct WorkspacePaneDropOverlayPresentation: Equatable { + let phase: WorkspacePaneDropOverlayPhase + let zone: DropZone? + let generation: UInt64 + + static let hidden = WorkspacePaneDropOverlayPresentation( + phase: .hidden, + zone: nil, + generation: 0 + ) + + static func visible(zone: DropZone, generation: UInt64) -> WorkspacePaneDropOverlayPresentation { + WorkspacePaneDropOverlayPresentation( + phase: .visible, + zone: zone, + generation: generation + ) + } + + static func hiding(zone: DropZone, generation: UInt64) -> WorkspacePaneDropOverlayPresentation { + WorkspacePaneDropOverlayPresentation( + phase: .hiding, + zone: zone, + generation: generation + ) + } +} + +struct WorkspacePaneDropOverlayCoordinator { + private(set) var activeDropZones: [UUID: DropZone] = [:] + private(set) var presentations: [UUID: WorkspacePaneDropOverlayPresentation] = [:] + var nextGenerationValue: UInt64 = 1 + + mutating func setZone(_ zone: DropZone, for paneId: PaneID) -> Bool { + let key = paneId.id + let previousZone = activeDropZones[key] + let previousPresentation = presentations[key] ?? .hidden + activeDropZones[key] = zone + if previousPresentation.phase != .visible || previousPresentation.zone != zone { + presentations[key] = .visible(zone: zone, generation: nextGeneration()) + } + let nextPresentation = presentations[key] ?? .hidden + return previousZone != zone || previousPresentation != nextPresentation + } + + mutating func clearZone(for paneId: PaneID) -> Bool { + let key = paneId.id + let previousZone = activeDropZones.removeValue(forKey: key) + let previousPresentation = presentations[key] ?? .hidden + let lastZone = previousPresentation.zone ?? previousZone + if let lastZone, previousPresentation.phase != .hiding { + presentations[key] = .hiding(zone: lastZone, generation: nextGeneration()) + } else if lastZone == nil { + presentations.removeValue(forKey: key) + } + let nextPresentation = presentations[key] ?? .hidden + return previousZone != nil || previousPresentation != nextPresentation + } + + mutating func clearZoneImmediately(for paneId: PaneID) -> Bool { + let key = paneId.id + let previousZone = activeDropZones.removeValue(forKey: key) + let previousPresentation = presentations.removeValue(forKey: key) ?? .hidden + return previousZone != nil || previousPresentation != .hidden + } + + mutating func completeHide(for paneId: PaneID, generation: UInt64) -> Bool { + let key = paneId.id + guard let presentation = presentations[key], + presentation.phase == .hiding, + presentation.generation == generation else { + return false + } + presentations.removeValue(forKey: key) + return true + } + + func activeDropZone(for paneId: PaneID) -> DropZone? { + activeDropZones[paneId.id] + } + + func overlayPresentation(for paneId: PaneID) -> WorkspacePaneDropOverlayPresentation { + presentations[paneId.id] ?? .hidden + } + + func viewportDropZones() -> [UUID: DropZone?] { + activeDropZones.reduce(into: [:]) { partialResult, entry in + partialResult[entry.key] = entry.value + } + } + + mutating func removePane(_ paneId: PaneID) { + activeDropZones.removeValue(forKey: paneId.id) + presentations.removeValue(forKey: paneId.id) + } + + mutating func clearAll() { + activeDropZones.removeAll() + presentations.removeAll() + } + + mutating func nextGeneration() -> UInt64 { + defer { nextGenerationValue &+= 1 } + return nextGenerationValue + } +} + +struct WorkspaceLayoutPresentationSnapshot { + let appearance: WorkspaceLayoutConfiguration.Appearance + let isInteractive: Bool + let isMinimalMode: Bool + let tabShortcutHintsEnabled: Bool + let localTabDrag: WorkspaceLayoutLocalDragSnapshot? + + var isHandlingLocalTabDrag: Bool { + localTabDrag != nil + } +} + +struct WorkspaceLayoutRenderSnapshot { + let presentation: WorkspaceLayoutPresentationSnapshot + let root: WorkspaceLayoutRenderNodeSnapshot + let viewports: [WorkspaceLayoutViewportSnapshot] +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutController.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutController.swift new file mode 100644 index 000000000000..b42737acf169 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutController.swift @@ -0,0 +1,722 @@ +import Foundation +import CoreGraphics +import SwiftUI + +@MainActor +struct WorkspaceLayoutExternalTabDropRequest { + enum Destination { + case insert(targetPane: PaneID, targetIndex: Int?) + case split(targetPane: PaneID, orientation: SplitOrientation, insertFirst: Bool) + } + + let tabId: TabID + let sourcePaneId: PaneID + let destination: Destination +} + +@MainActor +final class WorkspaceLayoutController { + + // MARK: - Delegate + + /// Delegate for receiving callbacks about tab bar events + weak var delegate: WorkspaceLayoutDelegate? + + // MARK: - Configuration + + /// Configuration for behavior and appearance + var configuration: WorkspaceLayoutConfiguration + + // MARK: - Layout State + + /// The root node of the split tree. + var rootNode: SplitNode + + /// Currently zoomed pane. When set, rendering should only show this pane. + var zoomedPaneId: PaneID? + + /// Currently focused pane ID. + var focusedPaneId: PaneID? + + /// Tab currently being dragged (for visual feedback and hit-testing). + var draggingTabId: TabID? + + /// Monotonic counter incremented on each drag start. + var dragGeneration: Int = 0 + + /// Source pane of the dragging tab. + var dragSourcePaneId: PaneID? + + /// Non-observable drag session state used by drop delegates. + var activeDragTabId: TabID? + var activeDragSourcePaneId: PaneID? + + /// Current frame of the entire split view container. + var containerFrame: CGRect = .zero + + /// Whether command-hold shortcut hints should be shown for this workspace. + var tabShortcutHintsEnabled: Bool = true { + didSet { + guard tabShortcutHintsEnabled != oldValue else { return } + notifyGeometryChange() + } + } + + /// Flag to prevent notification loops during external updates. + var isExternalUpdateInProgress: Bool = false + + /// Workspace-owned sink for published layout geometry snapshots. + var onGeometryChanged: ((LayoutSnapshot) -> Void)? + + // MARK: - Initialization + + /// Create a new controller with the specified configuration + init( + configuration: WorkspaceLayoutConfiguration = .default, + rootNode: SplitNode? = nil + ) { + self.configuration = configuration + if let rootNode { + self.rootNode = rootNode + } else { + let initialPane = PaneState() + self.rootNode = .pane(initialPane) + self.focusedPaneId = initialPane.id + } + } + + // MARK: - Renderer-facing state + + var renderRootNode: SplitNode { + zoomedNode ?? rootNode + } + + var isHandlingLocalTabDrag: Bool { + currentDragTabId != nil + } + + var currentDragTabId: TabID? { + activeDragTabId ?? draggingTabId + } + + var currentDragSourcePaneId: PaneID? { + activeDragSourcePaneId ?? dragSourcePaneId + } + + func beginTabDrag(tabId: TabID, sourcePaneId: PaneID) { + dragGeneration += 1 + draggingTabId = tabId + dragSourcePaneId = sourcePaneId + activeDragTabId = tabId + activeDragSourcePaneId = sourcePaneId + } + + func clearDragState() { + draggingTabId = nil + dragSourcePaneId = nil + activeDragTabId = nil + activeDragSourcePaneId = nil + } + + // MARK: - WorkspaceLayout.Tab Operations + + /// Create a new tab in the focused pane (or specified pane) + /// - Parameters: + /// - id: Optional stable surface ID to use for the tab + /// - title: The tab title + /// - isPinned: Whether the tab should be treated as pinned + /// - pane: Optional pane to add the tab to (defaults to focused pane) + /// - Returns: The TabID of the created tab, or nil if creation was vetoed by delegate + @discardableResult + func createTab( + id: TabID? = nil, + title: String, + isPinned: Bool = false, + inPane pane: PaneID? = nil, + select: Bool = true + ) -> TabID? { + let tabId = id ?? TabID() + guard let targetPane = pane ?? focusedPaneId ?? rootNode.allPaneIds.first.map({ PaneID(id: $0.id) }) else { + return nil + } + guard rootNode.findPane(PaneID(id: targetPane.id)) != nil else { + return nil + } + + // Check with delegate + if delegate?.workspaceSplit(shouldCreateTab: tabId, inPane: targetPane) == false { + return nil + } + + // Calculate insertion index based on configuration + let insertIndex: Int? + switch configuration.newTabPosition { + case .current: + // Insert after the currently selected tab + if let paneState = rootNode.findPane(PaneID(id: targetPane.id)), + let selectedTabId = paneState.selectedTabId, + let currentIndex = paneState.tabIds.firstIndex(of: selectedTabId) { + insertIndex = currentIndex + 1 + } else { + // No selected tab, append to end + insertIndex = nil + } + case .end: + insertIndex = nil + } + + addTabInternal( + tabId.id, + toPane: PaneID(id: targetPane.id), + atIndex: insertIndex, + select: select + ) + + // Notify delegate + delegate?.workspaceSplit(didCreateTab: tabId, inPane: targetPane) + + return tabId + } + + /// Request the delegate to create a new tab of the given kind in a pane. + /// The delegate is responsible for the actual creation logic. + func requestNewTab(kind: WorkspaceLayoutTabKind, inPane pane: PaneID) { + delegate?.workspaceSplit(didRequestNewTab: kind, inPane: pane) + } + + /// Request the delegate to handle a tab context-menu action. + func requestTabContextAction(_ action: TabContextAction, for tabId: TabID, inPane pane: PaneID) { + guard findTabInternal(tabId) != nil else { return } + delegate?.workspaceSplit(didRequestTabContextAction: action, for: tabId, inPane: pane) + } + + /// Update an existing tab's layout-affecting metadata + /// - Parameters: + /// - tabId: The tab to update + /// - title: New fallback title (pass nil to keep current) + /// - isPinned: New pinned state (pass nil to keep current) + func updateTab( + _ tabId: TabID, + title: String? = nil, + isPinned: Bool? = nil + ) { + guard let (paneId, _) = findTabInternal(tabId) else { return } + guard title != nil || isPinned != nil else { return } + rootNode.updatePane(paneId) { pane in + pane.chromeRevision &+= 1 + } + } + + /// Close a tab by ID + /// - Parameter tabId: The tab to close + /// - Returns: true if the tab was closed, false if vetoed by delegate + @discardableResult + func closeTab(_ tabId: TabID) -> Bool { + guard let (paneId, tabIndex) = findTabInternal(tabId), + let pane = rootNode.findPane(paneId) else { return false } + return closeTab(tabId, with: tabIndex, inPane: pane.id) + } + + /// Close a tab by ID in a specific pane. + /// - Parameter tabId: The tab to close + /// - Parameter paneId: The pane in which to close the tab + func closeTab(_ tabId: TabID, inPane paneId: PaneID) -> Bool { + guard let pane = rootNode.findPane(paneId), + let tabIndex = pane.tabIds.firstIndex(of: tabId.id) else { + return false + } + + return closeTab(tabId, with: tabIndex, inPane: pane.id) + } + + /// Internal helper to close a tab given its index in a pane + /// - Parameter tabId: The tab to close + /// - Parameter tabIndex: The position of the tab within the pane + /// - Parameter paneId: The pane in which to close the tab + func closeTab(_ tabId: TabID, with tabIndex: Int, inPane paneId: PaneID) -> Bool { + guard configuration.allowCloseTabs else { return false } + + // Check with delegate + if delegate?.workspaceSplit(shouldCloseTab: tabId, inPane: paneId) == false { + return false + } + + performCloseTab(tabId.id, inPane: paneId) + + // Notify delegate + delegate?.workspaceSplit(didCloseTab: tabId, fromPane: paneId) + notifyGeometryChange() + + return true + } + + /// Select a tab by ID + /// - Parameter tabId: The tab to select + func selectTab(_ tabId: TabID) { + guard let (paneId, _) = findTabInternal(tabId) else { return } + + rootNode.updatePane(paneId) { pane in + pane.selectTab(tabId.id) + } + setFocusedPane(paneId) + + // Notify delegate + delegate?.workspaceSplit(didSelectTab: tabId, inPane: paneId) + } + + /// Move a tab to a specific pane (and optional index) inside this controller. + /// - Parameters: + /// - tabId: The tab to move. + /// - targetPaneId: Destination pane. + /// - index: Optional destination index. When nil, appends at the end. + /// - Returns: true if moved. + @discardableResult + func moveTab(_ tabId: TabID, toPane targetPaneId: PaneID, atIndex index: Int? = nil) -> Bool { + guard let (sourcePaneId, sourceIndex) = findTabInternal(tabId), + let sourcePane = rootNode.findPane(sourcePaneId), + let targetPane = rootNode.findPane(PaneID(id: targetPaneId.id)) else { return false } + + let surfaceId = sourcePane.tabIds[sourceIndex] + if sourcePaneId == targetPane.id { + guard configuration.allowTabReordering else { return false } + + // Reorder within same pane. + let destinationIndex: Int = { + if let index { return max(0, min(index, sourcePane.tabIds.count)) } + return sourcePane.tabIds.count + }() + rootNode.updatePane(sourcePaneId) { pane in + pane.moveTab(from: sourceIndex, to: destinationIndex) + pane.selectTab(surfaceId) + } + setFocusedPane(sourcePaneId) + delegate?.workspaceSplit(didSelectTab: tabId, inPane: sourcePaneId) + notifyGeometryChange() + return true + } + + guard configuration.allowCrossPaneTabMove else { return false } + + performMoveTab(surfaceId, from: sourcePaneId, to: targetPane.id, atIndex: index) + delegate?.workspaceSplit(didMoveTab: tabId, fromPane: sourcePaneId, toPane: targetPane.id) + notifyGeometryChange() + return true + } + + /// Reorder a tab within its pane. + /// - Parameters: + /// - tabId: The tab to reorder. + /// - toIndex: Destination index. + /// - Returns: true if reordered. + @discardableResult + func reorderTab(_ tabId: TabID, toIndex: Int) -> Bool { + guard configuration.allowTabReordering else { return false } + + guard let (paneId, sourceIndex) = findTabInternal(tabId), + let pane = rootNode.findPane(paneId) else { return false } + let destinationIndex = max(0, min(toIndex, pane.tabIds.count)) + rootNode.updatePane(paneId) { pane in + pane.moveTab(from: sourceIndex, to: destinationIndex) + pane.selectTab(tabId.id) + } + setFocusedPane(paneId) + delegate?.workspaceSplit(didSelectTab: tabId, inPane: paneId) + notifyGeometryChange() + return true + } + + /// Move to previous tab in focused pane + func selectPreviousTab() { + selectPreviousTabInternal() + notifyTabSelection() + } + + /// Move to next tab in focused pane + func selectNextTab() { + selectNextTabInternal() + notifyTabSelection() + } + + // MARK: - Split Operations + + /// Split the focused pane (or specified pane) + /// - Parameters: + /// - paneId: Optional pane to split (defaults to focused pane) + /// - orientation: Direction to split (horizontal = side-by-side, vertical = stacked) + /// - tab: Optional tab to add to the new pane + /// - Returns: The new pane ID, or nil if vetoed by delegate + @discardableResult + func splitPane( + _ paneId: PaneID? = nil, + orientation: SplitOrientation, + withTabId tabId: TabID? = nil, + focusNewPane: Bool = true + ) -> PaneID? { + guard configuration.allowSplits else { return nil } + + let targetPaneId = paneId ?? focusedPaneId + guard let targetPaneId, + rootNode.findPane(targetPaneId) != nil else { return nil } + let previousPaneIds = Set(rootNode.allPaneIds) + + // Check with delegate + if delegate?.workspaceSplit(shouldSplitPane: targetPaneId, orientation: orientation) == false { + return nil + } + + // Perform split + performSplitPane( + PaneID(id: targetPaneId.id), + orientation: orientation, + with: tabId?.id, + focusNewPane: focusNewPane + ) + + guard let newPaneId = Set(rootNode.allPaneIds) + .subtracting(previousPaneIds) + .first else { return nil } + + // Notify delegate + delegate?.workspaceSplit(didSplitPane: targetPaneId, newPane: newPaneId, orientation: orientation) + + notifyGeometryChange() + + return newPaneId + } + + /// Split a pane and place a specific tab in the newly created pane, choosing which side to insert on. + /// + /// This is like `splitPane(_:orientation:withTab:)`, but allows choosing left/top vs right/bottom insertion + /// without needing to create then move a tab. + /// + /// - Parameters: + /// - paneId: Optional pane to split (defaults to focused pane). + /// - orientation: Direction to split (horizontal = side-by-side, vertical = stacked). + /// - tab: The tab to add to the new pane. + /// - insertFirst: If true, insert the new pane first (left/top). Otherwise insert second (right/bottom). + /// - Returns: The new pane ID, or nil if vetoed by delegate. + @discardableResult + func splitPane( + _ paneId: PaneID? = nil, + orientation: SplitOrientation, + withTabId tabId: TabID, + insertFirst: Bool, + focusNewPane: Bool = true + ) -> PaneID? { + guard configuration.allowSplits else { return nil } + + let targetPaneId = paneId ?? focusedPaneId + guard let targetPaneId, + rootNode.findPane(targetPaneId) != nil else { return nil } + let previousPaneIds = Set(rootNode.allPaneIds) + + // Check with delegate + if delegate?.workspaceSplit(shouldSplitPane: targetPaneId, orientation: orientation) == false { + return nil + } + + // Perform split with insertion side. + performSplitPaneWithTab( + PaneID(id: targetPaneId.id), + orientation: orientation, + tabId: tabId.id, + insertFirst: insertFirst, + focusNewPane: focusNewPane + ) + + guard let newPaneId = Set(rootNode.allPaneIds) + .subtracting(previousPaneIds) + .first else { return nil } + + // Notify delegate + delegate?.workspaceSplit(didSplitPane: targetPaneId, newPane: newPaneId, orientation: orientation) + + notifyGeometryChange() + + return newPaneId + } + + /// Split a pane by moving an existing tab into the new pane. + /// + /// This mirrors the "drag a tab to a pane edge to create a split" interaction: + /// the tab is removed from its source pane first, then inserted into the newly + /// created pane on the chosen edge. + /// + /// - Parameters: + /// - paneId: Optional target pane to split (defaults to the tab's current pane). + /// - orientation: Direction to split (horizontal = side-by-side, vertical = stacked). + /// - tabId: The existing tab to move into the new pane. + /// - insertFirst: If true, the new pane is inserted first (left/top). Otherwise it is inserted second (right/bottom). + /// - Returns: The new pane ID, or nil if the tab couldn't be found or the split was vetoed. + @discardableResult + func splitPane( + _ paneId: PaneID? = nil, + orientation: SplitOrientation, + movingTab tabId: TabID, + insertFirst: Bool, + focusNewPane: Bool = true + ) -> PaneID? { + guard configuration.allowSplits else { return nil } + let previousPaneIds = Set(rootNode.allPaneIds) + + // Find the existing tab and its source pane. + guard let (sourcePaneId, tabIndex) = findTabInternal(tabId), + let sourcePane = rootNode.findPane(sourcePaneId) else { return nil } + let surfaceId = sourcePane.tabIds[tabIndex] + let sourceWasSelected = sourcePane.selectedTabId == surfaceId + + // Default target to the tab's current pane to match edge-drop behavior on the source pane. + let targetPaneId = paneId ?? sourcePaneId + guard rootNode.findPane(targetPaneId) != nil else { return nil } + + // Check with delegate + if delegate?.workspaceSplit(shouldSplitPane: targetPaneId, orientation: orientation) == false { + return nil + } + + // Remove from source first. + rootNode.updatePane(sourcePaneId) { pane in + pane.removeTab(surfaceId) + } + + // Perform split with the moved tab. + performSplitPaneWithTab( + PaneID(id: targetPaneId.id), + orientation: orientation, + tabId: surfaceId, + insertFirst: insertFirst, + focusNewPane: focusNewPane + ) + + guard let newPaneId = Set(rootNode.allPaneIds) + .subtracting(previousPaneIds) + .first else { + rootNode.updatePane(sourcePaneId) { pane in + pane.insertTab(surfaceId, at: tabIndex, select: sourceWasSelected) + } + return nil + } + + let updatedSourcePane = rootNode.findPane(sourcePaneId) + if configuration.autoCloseEmptyPanes, + updatedSourcePane?.tabIds.isEmpty == true, + sourcePaneId != targetPaneId, + rootNode.allPaneIds.count > 1 { + // If the source pane is now empty, close it after the split succeeds. + performClosePane(sourcePaneId) + } + + let desiredFocusPaneId = focusNewPane ? newPaneId : targetPaneId + if rootNode.findPane(desiredFocusPaneId) != nil { + focusPane(desiredFocusPaneId) + } + + // Notify delegate + delegate?.workspaceSplit(didSplitPane: targetPaneId, newPane: newPaneId, orientation: orientation) + + notifyGeometryChange() + + return newPaneId + } + + /// Close a specific pane + /// - Parameter paneId: The pane to close + /// - Returns: true if the pane was closed, false if vetoed by delegate + @discardableResult + func closePane(_ paneId: PaneID) -> Bool { + let paneId = PaneID(id: paneId.id) + guard rootNode.findPane(paneId) != nil else { + return false + } + + // Don't close if it's the last pane and not allowed + if !configuration.allowCloseLastPane && rootNode.allPaneIds.count <= 1 { + return false + } + + // Check with delegate + if delegate?.workspaceSplit(shouldClosePane: paneId) == false { + return false + } + + performClosePane(paneId) + guard rootNode.findPane(paneId) == nil else { + return false + } + + // Notify delegate + delegate?.workspaceSplit(didClosePane: paneId) + + notifyGeometryChange() + + return true + } + + // MARK: - Focus Management + + /// Focus a specific pane + func focusPane(_ paneId: PaneID) { + setFocusedPane(PaneID(id: paneId.id)) + delegate?.workspaceSplit(didFocusPane: paneId) + } + + /// Navigate focus in a direction + func navigateFocus(direction: NavigationDirection) { + performNavigateFocus(direction: direction) + if let focusedPaneId { + delegate?.workspaceSplit(didFocusPane: focusedPaneId) + } + } + + /// Find the closest pane in the requested direction from the given pane. + func adjacentPane(to paneId: PaneID, direction: NavigationDirection) -> PaneID? { + adjacentPaneInternal(to: paneId, direction: direction) + } + + // MARK: - Split Zoom + + var isSplitZoomed: Bool { + zoomedPaneId != nil + } + + @discardableResult + func clearPaneZoom() -> Bool { + clearPaneZoomInternal() + } + + /// Toggle zoom for a pane. When zoomed, only that pane is rendered in the split area. + /// Passing nil toggles the currently focused pane. + @discardableResult + func togglePaneZoom(inPane paneId: PaneID? = nil) -> Bool { + let targetPaneId = paneId ?? focusedPaneId + guard let targetPaneId else { return false } + return togglePaneZoomInternal(targetPaneId) + } + + // MARK: - Context Menu Shortcut Hints + + /// Keyboard shortcuts to display in tab context menus, keyed by context action. + /// Set by the host app to sync with its customizable keyboard shortcut settings. + var contextMenuShortcuts: [TabContextAction: KeyboardShortcut] = [:] + + // MARK: - Query Methods + + /// Get all tab IDs + var allTabIds: [TabID] { + rootNode.allPanes.flatMap { pane in + pane.tabIds.map { TabID(id: $0) } + } + } + + /// Get all pane IDs + var allPaneIds: [PaneID] { + rootNode.allPaneIds + } + + /// Get all tab IDs in a specific pane. + func tabIds(inPane paneId: PaneID) -> [TabID] { + guard let pane = rootNode.findPane(PaneID(id: paneId.id)) else { + return [] + } + return pane.tabIds.map { TabID(id: $0) } + } + + /// Get the selected tab ID in a pane. + func selectedTabId(inPane paneId: PaneID) -> TabID? { + guard let pane = rootNode.findPane(PaneID(id: paneId.id)), + let selectedTabId = pane.selectedTabId else { + return nil + } + return TabID(id: selectedTabId) + } + + // MARK: - Geometry Query API + + /// Get current layout snapshot with pixel coordinates + func layoutSnapshot() -> LayoutSnapshot { + let containerFrame = containerFrame + let paneBounds = rootNode.computePaneBounds() + + let paneGeometries = paneBounds.map { bounds -> PaneGeometry in + let pane = rootNode.findPane(bounds.paneId) + let pixelFrame = PixelRect( + x: Double(bounds.bounds.minX * containerFrame.width + containerFrame.origin.x), + y: Double(bounds.bounds.minY * containerFrame.height + containerFrame.origin.y), + width: Double(bounds.bounds.width * containerFrame.width), + height: Double(bounds.bounds.height * containerFrame.height) + ) + return PaneGeometry( + paneId: bounds.paneId.id.uuidString, + frame: pixelFrame, + selectedTabId: pane?.selectedTabId?.uuidString, + tabIds: pane?.tabIds.map { $0.uuidString } ?? [] + ) + } + + return LayoutSnapshot( + containerFrame: PixelRect(from: containerFrame), + panes: paneGeometries, + focusedPaneId: focusedPaneId?.id.uuidString, + timestamp: Date().timeIntervalSince1970 + ) + } + + /// Check if a split exists by ID + func findSplit(_ splitId: UUID) -> Bool { + return splitState(splitId) != nil + } + + // MARK: - Geometry Update API + + /// Set divider position for a split node (0.0-1.0) + /// - Parameters: + /// - position: The new divider position (clamped to 0.1-0.9) + /// - splitId: The UUID of the split to update + /// - fromExternal: Set to true to suppress outgoing notifications (prevents loops) + /// - Returns: true if the split was found and updated + @discardableResult + func setDividerPosition(_ position: CGFloat, forSplit splitId: UUID, fromExternal: Bool = false) -> Bool { + guard splitState(splitId) != nil else { return false } + + if fromExternal { + isExternalUpdateInProgress = true + } + + // Clamp position to valid range + let clampedPosition = min(max(position, 0.1), 0.9) + rootNode.updateSplit(splitId) { split in + split.dividerPosition = clampedPosition + } + + if fromExternal { + // External restore/config loads should suppress only the immediate geometry echo + // from the same update turn, not an arbitrary timed window. + DispatchQueue.main.async { [weak self] in + self?.isExternalUpdateInProgress = false + } + } + + return true + } + + func consumeSplitEntryAnimation(_ splitId: UUID) { + guard let split = splitState(splitId), + split.animationOrigin != nil else { return } + rootNode.updateSplit(splitId) { split in + split.animationOrigin = nil + } + } + + /// Update container frame (called when window moves/resizes) + func setContainerFrame(_ frame: CGRect) { + containerFrame = frame + } + + /// Notify geometry change to delegate (internal use) + /// - Parameter isDragging: Whether the change is due to active divider dragging + internal func notifyGeometryChange(isDragging: Bool = false) { + guard !isExternalUpdateInProgress, !isDragging else { return } + onGeometryChanged?(layoutSnapshot()) + } + +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutControllerInternals.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutControllerInternals.swift new file mode 100644 index 000000000000..85871e2eb3c3 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutControllerInternals.swift @@ -0,0 +1,435 @@ +import CoreGraphics +import Foundation + +extension WorkspaceLayoutController { + // MARK: - Private Helpers + + var focusedPane: PaneState? { + guard let focusedPaneId else { return nil } + return rootNode.findPane(focusedPaneId) + } + + var zoomedNode: SplitNode? { + guard let zoomedPaneId else { return nil } + return rootNode.findNode(containing: zoomedPaneId) + } + + func setFocusedPane(_ paneId: PaneID) { + guard rootNode.findPane(paneId) != nil else { return } +#if DEBUG + dlog("focus.WorkspaceLayout pane=\(paneId.id.uuidString.prefix(5))") +#endif + focusedPaneId = paneId + } + + @discardableResult + func clearPaneZoomInternal() -> Bool { + guard zoomedPaneId != nil else { return false } + zoomedPaneId = nil + return true + } + + @discardableResult + func togglePaneZoomInternal(_ paneId: PaneID) -> Bool { + guard rootNode.findPane(paneId) != nil else { return false } + + if zoomedPaneId == paneId { + zoomedPaneId = nil + return true + } + + guard rootNode.allPaneIds.count > 1 else { return false } + zoomedPaneId = paneId + focusedPaneId = paneId + return true + } + + func performSplitPane( + _ paneId: PaneID, + orientation: SplitOrientation, + with newTabId: UUID? = nil, + focusNewPane: Bool = true + ) { + clearPaneZoomInternal() + rootNode = splitNodeRecursively( + node: rootNode, + targetPaneId: paneId, + orientation: orientation, + newTabId: newTabId, + focusNewPane: focusNewPane + ) + } + + func splitNodeRecursively( + node: SplitNode, + targetPaneId: PaneID, + orientation: SplitOrientation, + newTabId: UUID?, + focusNewPane: Bool + ) -> SplitNode { + switch node { + case .pane(let paneState): + if paneState.id == targetPaneId { + let newPane: PaneState + if let newTabId { + newPane = PaneState(tabIds: [newTabId]) + } else { + newPane = PaneState() + } + + let splitState = SplitState( + orientation: orientation, + first: .pane(paneState), + second: .pane(newPane), + dividerPosition: 0.5, + animationOrigin: .fromSecond + ) + + if focusNewPane { + focusedPaneId = newPane.id + } + + return .split(splitState) + } + return node + + case .split(let splitState): + var splitState = splitState + splitState.first = splitNodeRecursively( + node: splitState.first, + targetPaneId: targetPaneId, + orientation: orientation, + newTabId: newTabId, + focusNewPane: focusNewPane + ) + splitState.second = splitNodeRecursively( + node: splitState.second, + targetPaneId: targetPaneId, + orientation: orientation, + newTabId: newTabId, + focusNewPane: focusNewPane + ) + return .split(splitState) + } + } + + func performSplitPaneWithTab( + _ paneId: PaneID, + orientation: SplitOrientation, + tabId: UUID, + insertFirst: Bool, + focusNewPane: Bool = true + ) { + clearPaneZoomInternal() + rootNode = splitNodeWithTabRecursively( + node: rootNode, + targetPaneId: paneId, + orientation: orientation, + tabId: tabId, + insertFirst: insertFirst, + focusNewPane: focusNewPane + ) + } + + func splitNodeWithTabRecursively( + node: SplitNode, + targetPaneId: PaneID, + orientation: SplitOrientation, + tabId: UUID, + insertFirst: Bool, + focusNewPane: Bool + ) -> SplitNode { + switch node { + case .pane(let paneState): + if paneState.id == targetPaneId { + let newPane = PaneState(tabIds: [tabId]) + let splitState: SplitState + if insertFirst { + splitState = SplitState( + orientation: orientation, + first: .pane(newPane), + second: .pane(paneState), + dividerPosition: 0.5, + animationOrigin: .fromFirst + ) + } else { + splitState = SplitState( + orientation: orientation, + first: .pane(paneState), + second: .pane(newPane), + dividerPosition: 0.5, + animationOrigin: .fromSecond + ) + } + + if focusNewPane { + focusedPaneId = newPane.id + } + + return .split(splitState) + } + return node + + case .split(let splitState): + var splitState = splitState + splitState.first = splitNodeWithTabRecursively( + node: splitState.first, + targetPaneId: targetPaneId, + orientation: orientation, + tabId: tabId, + insertFirst: insertFirst, + focusNewPane: focusNewPane + ) + splitState.second = splitNodeWithTabRecursively( + node: splitState.second, + targetPaneId: targetPaneId, + orientation: orientation, + tabId: tabId, + insertFirst: insertFirst, + focusNewPane: focusNewPane + ) + return .split(splitState) + } + } + + func performClosePane(_ paneId: PaneID) { + guard rootNode.findPane(paneId) != nil else { return } + if rootNode.allPaneIds.count <= 1 { + guard configuration.allowCloseLastPane else { return } + let replacementPane = PaneState() + rootNode = .pane(replacementPane) + focusedPaneId = replacementPane.id + zoomedPaneId = nil + return + } + + let (newRoot, siblingPaneId) = closePaneRecursively(node: rootNode, targetPaneId: paneId) + + if let newRoot { + rootNode = newRoot + } + + if let siblingPaneId { + focusedPaneId = siblingPaneId + } else if let firstPane = rootNode.allPaneIds.first { + focusedPaneId = firstPane + } + + if let zoomedPaneId, rootNode.findPane(zoomedPaneId) == nil { + self.zoomedPaneId = nil + } + } + + func closePaneRecursively( + node: SplitNode, + targetPaneId: PaneID + ) -> (SplitNode?, PaneID?) { + switch node { + case .pane(let paneState): + if paneState.id == targetPaneId { + return (nil, nil) + } + return (node, nil) + + case .split(let splitState): + if case .pane(let firstPane) = splitState.first, firstPane.id == targetPaneId { + let focusTarget = splitState.second.allPaneIds.first + return (splitState.second, focusTarget) + } + + if case .pane(let secondPane) = splitState.second, secondPane.id == targetPaneId { + let focusTarget = splitState.first.allPaneIds.first + return (splitState.first, focusTarget) + } + + let (newFirst, focusFromFirst) = closePaneRecursively(node: splitState.first, targetPaneId: targetPaneId) + if newFirst == nil { + return (splitState.second, splitState.second.allPaneIds.first) + } + + let (newSecond, focusFromSecond) = closePaneRecursively(node: splitState.second, targetPaneId: targetPaneId) + if newSecond == nil { + return (splitState.first, splitState.first.allPaneIds.first) + } + + var updatedSplit = splitState + if let newFirst { updatedSplit.first = newFirst } + if let newSecond { updatedSplit.second = newSecond } + + return (.split(updatedSplit), focusFromFirst ?? focusFromSecond) + } + } + + func addTabInternal( + _ tabId: UUID, + toPane paneId: PaneID? = nil, + atIndex index: Int? = nil, + select: Bool = true + ) { + let targetPaneId = paneId ?? focusedPaneId + guard let targetPaneId else { return } + + rootNode.updatePane(targetPaneId) { pane in + if let index { + pane.insertTab(tabId, at: index, select: select) + } else { + pane.addTab(tabId, select: select) + } + } + } + + func performMoveTab(_ tabId: UUID, from sourcePaneId: PaneID, to targetPaneId: PaneID, atIndex index: Int? = nil) { + guard rootNode.findPane(sourcePaneId) != nil, + rootNode.findPane(targetPaneId) != nil else { return } + + rootNode.updatePane(sourcePaneId) { pane in + pane.removeTab(tabId) + } + + rootNode.updatePane(targetPaneId) { pane in + if let index { + pane.insertTab(tabId, at: index) + } else { + pane.addTab(tabId) + } + } + + setFocusedPane(targetPaneId) + + if configuration.autoCloseEmptyPanes, + rootNode.findPane(sourcePaneId)?.tabIds.isEmpty == true, + rootNode.allPaneIds.count > 1 { + performClosePane(sourcePaneId) + } + } + + func performCloseTab(_ tabId: UUID, inPane paneId: PaneID) { + guard rootNode.findPane(paneId) != nil else { return } + + rootNode.updatePane(paneId) { pane in + pane.removeTab(tabId) + } + + if configuration.autoCloseEmptyPanes, + rootNode.findPane(paneId)?.tabIds.isEmpty == true, + (configuration.allowCloseLastPane || rootNode.allPaneIds.count > 1) { + performClosePane(paneId) + } + } + + func performNavigateFocus(direction: NavigationDirection) { + guard let currentPaneId = focusedPaneId else { return } + + let allPaneBounds = rootNode.computePaneBounds() + guard let currentBounds = allPaneBounds.first(where: { $0.paneId == currentPaneId })?.bounds else { return } + + if let targetPaneId = findBestNeighbor( + from: currentBounds, + currentPaneId: currentPaneId, + direction: direction, + allPaneBounds: allPaneBounds + ) { + setFocusedPane(targetPaneId) + } + } + + func adjacentPaneInternal(to paneId: PaneID, direction: NavigationDirection) -> PaneID? { + let allPaneBounds = rootNode.computePaneBounds() + guard let currentBounds = allPaneBounds.first(where: { $0.paneId == paneId })?.bounds else { + return nil + } + return findBestNeighbor( + from: currentBounds, + currentPaneId: paneId, + direction: direction, + allPaneBounds: allPaneBounds + ) + } + + func findBestNeighbor( + from currentBounds: CGRect, + currentPaneId: PaneID, + direction: NavigationDirection, + allPaneBounds: [PaneBounds] + ) -> PaneID? { + let epsilon: CGFloat = 0.001 + + let candidates = allPaneBounds.filter { paneBounds in + guard paneBounds.paneId != currentPaneId else { return false } + let bounds = paneBounds.bounds + switch direction { + case .left: return bounds.maxX <= currentBounds.minX + epsilon + case .right: return bounds.minX >= currentBounds.maxX - epsilon + case .up: return bounds.maxY <= currentBounds.minY + epsilon + case .down: return bounds.minY >= currentBounds.maxY - epsilon + } + } + + guard !candidates.isEmpty else { return nil } + + let scored: [(PaneID, CGFloat, CGFloat)] = candidates.map { candidate in + let overlap: CGFloat + let distance: CGFloat + + switch direction { + case .left, .right: + overlap = max(0, min(currentBounds.maxY, candidate.bounds.maxY) - max(currentBounds.minY, candidate.bounds.minY)) + distance = direction == .left + ? (currentBounds.minX - candidate.bounds.maxX) + : (candidate.bounds.minX - currentBounds.maxX) + case .up, .down: + overlap = max(0, min(currentBounds.maxX, candidate.bounds.maxX) - max(currentBounds.minX, candidate.bounds.minX)) + distance = direction == .up + ? (currentBounds.minY - candidate.bounds.maxY) + : (candidate.bounds.minY - currentBounds.maxY) + } + + return (candidate.paneId, overlap, distance) + } + + return scored.sorted { lhs, rhs in + if abs(lhs.1 - rhs.1) > epsilon { + return lhs.1 > rhs.1 + } + return lhs.2 < rhs.2 + }.first?.0 + } + + func selectPreviousTabInternal() { + guard let pane = focusedPane, + let selectedTabId = pane.selectedTabId, + let currentIndex = pane.tabIds.firstIndex(of: selectedTabId), + !pane.tabIds.isEmpty else { return } + + let newIndex = currentIndex > 0 ? currentIndex - 1 : pane.tabIds.count - 1 + rootNode.updatePane(pane.id) { pane in + pane.selectTab(pane.tabIds[newIndex]) + } + } + + func selectNextTabInternal() { + guard let pane = focusedPane, + let selectedTabId = pane.selectedTabId, + let currentIndex = pane.tabIds.firstIndex(of: selectedTabId), + !pane.tabIds.isEmpty else { return } + + let newIndex = currentIndex < pane.tabIds.count - 1 ? currentIndex + 1 : 0 + rootNode.updatePane(pane.id) { pane in + pane.selectTab(pane.tabIds[newIndex]) + } + } + + func splitState(_ splitId: UUID) -> SplitState? { + rootNode.findSplit(splitId) + } + + func findTabInternal(_ tabId: TabID) -> (PaneID, Int)? { + rootNode.findTab(tabId) + } + + func notifyTabSelection() { + guard let pane = focusedPane, + let selectedTabId = pane.selectedTabId else { return } + delegate?.workspaceSplit(didSelectTab: TabID(id: selectedTabId), inPane: pane.id) + } +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutCore.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutCore.swift new file mode 100644 index 000000000000..fd01550d49b5 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutCore.swift @@ -0,0 +1,243 @@ +import Foundation +import SwiftUI + +struct WorkspacePaneID: Hashable, Codable, Sendable, CustomStringConvertible { + let id: UUID + + init() { + self.id = UUID() + } + + init(id: UUID) { + self.id = id + } + + var description: String { + id.uuidString + } +} + +typealias PaneID = WorkspacePaneID + +struct WorkspaceTabID: Hashable, Codable, Sendable, CustomStringConvertible { + let rawValue: UUID + + enum CodingKeys: String, CodingKey { + case id + case rawValue + } + + init() { + self.rawValue = UUID() + } + + init(uuid: UUID) { + self.rawValue = uuid + } + + var uuid: UUID { + rawValue + } + + var description: String { + rawValue.uuidString + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if let id = try container.decodeIfPresent(UUID.self, forKey: .id) { + self.rawValue = id + return + } + // Backward compatibility for older payloads encoded as {"rawValue": ...}. + self.rawValue = try container.decode(UUID.self, forKey: .rawValue) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + // Keep field name stable for workspace/session restore compatibility. + try container.encode(rawValue, forKey: .id) + } +} + +typealias TabID = WorkspaceTabID + +enum WorkspaceLayoutOrientation: String, Codable, Sendable { + case horizontal + case vertical +} + +typealias SplitOrientation = WorkspaceLayoutOrientation + +enum WorkspaceNavigationDirection: String, Codable, Sendable { + case left + case right + case up + case down +} + +typealias NavigationDirection = WorkspaceNavigationDirection + +enum WorkspaceTabContextAction: String, CaseIterable, Sendable { + case rename + case clearName + case copyIdentifiers + case closeToLeft + case closeToRight + case closeOthers + case move + case moveToLeftPane + case moveToRightPane + case newTerminalToRight + case newBrowserToRight + case reload + case duplicate + case togglePin + case markAsRead + case markAsUnread + case toggleZoom +} + +typealias TabContextAction = WorkspaceTabContextAction + +struct WorkspacePixelRect: Codable, Sendable, Equatable { + let x: Double + let y: Double + let width: Double + let height: Double + + init(x: Double, y: Double, width: Double, height: Double) { + self.x = x + self.y = y + self.width = width + self.height = height + } + + init(from cgRect: CGRect) { + self.x = Double(cgRect.origin.x) + self.y = Double(cgRect.origin.y) + self.width = Double(cgRect.size.width) + self.height = Double(cgRect.size.height) + } +} + +typealias PixelRect = WorkspacePixelRect + +struct PaneGeometry: Codable, Sendable, Equatable { + let paneId: String + let frame: PixelRect + let selectedTabId: String? + let tabIds: [String] +} + +struct LayoutSnapshot: Codable, Sendable, Equatable { + let containerFrame: PixelRect + let panes: [PaneGeometry] + let focusedPaneId: String? + let timestamp: TimeInterval +} + +struct ExternalTab: Codable, Sendable, Equatable { + let id: String + let title: String +} + +struct ExternalPaneNode: Codable, Sendable, Equatable { + let id: String + let frame: PixelRect + let tabs: [ExternalTab] + let selectedTabId: String? +} + +struct ExternalSplitNode: Codable, Sendable, Equatable { + let id: String + let orientation: String + let dividerPosition: Double + let first: ExternalTreeNode + let second: ExternalTreeNode +} + +indirect enum ExternalTreeNode: Codable, Sendable, Equatable { + case pane(ExternalPaneNode) + case split(ExternalSplitNode) + + enum CodingKeys: String, CodingKey { + case type + case pane + case split + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(String.self, forKey: .type) { + case "pane": + self = .pane(try container.decode(ExternalPaneNode.self, forKey: .pane)) + case "split": + self = .split(try container.decode(ExternalSplitNode.self, forKey: .split)) + default: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unsupported external tree node" + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .pane(let pane): + try container.encode("pane", forKey: .type) + try container.encode(pane, forKey: .pane) + case .split(let split): + try container.encode("split", forKey: .type) + try container.encode(split, forKey: .split) + } + } +} + +enum WorkspaceDropZone: Equatable, Sendable { + case center + case left + case right + case top + case bottom + + var orientation: SplitOrientation? { + switch self { + case .left, .right: + return .horizontal + case .top, .bottom: + return .vertical + case .center: + return nil + } + } + + var insertFirst: Bool { + switch self { + case .left, .top: + return true + case .center, .right, .bottom: + return false + } + } +} + +typealias DropZone = WorkspaceDropZone + +struct PaneDropZoneEnvironmentKey: EnvironmentKey { + static let defaultValue: DropZone? = nil +} + +extension EnvironmentValues { + var paneDropZone: DropZone? { + get { self[PaneDropZoneEnvironmentKey.self] } + set { self[PaneDropZoneEnvironmentKey.self] = newValue } + } +} + +enum NewTabPosition: Sendable { + case current + case end +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutDebugLogging.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutDebugLogging.swift new file mode 100644 index 000000000000..d62359651720 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutDebugLogging.swift @@ -0,0 +1,120 @@ +import AppKit +import Foundation + +#if DEBUG +enum WorkspaceLayoutDebugCounters { + private(set) static var arrangedSubviewUnderflowCount: Int = 0 + + static func reset() { + arrangedSubviewUnderflowCount = 0 + } + + static func recordArrangedSubviewUnderflow() { + arrangedSubviewUnderflowCount += 1 + } +} +#else +enum WorkspaceLayoutDebugCounters { + static let arrangedSubviewUnderflowCount: Int = 0 + + static func reset() {} + static func recordArrangedSubviewUnderflow() {} +} +#endif + +#if DEBUG +let cmuxDebugLogLock = NSLock() + +private func workspaceLayoutAppendDebugLog(path: String, data: Data) { + if let handle = FileHandle(forWritingAtPath: path) { + defer { try? handle.close() } + guard (try? handle.seekToEnd()) != nil else { return } + try? handle.write(contentsOf: data) + } else { + FileManager.default.createFile(atPath: path, contents: data) + } +} +#endif + +func dlog(_ message: String) { + NSLog("%@", message) +#if DEBUG + guard let rawLogPath = ProcessInfo.processInfo.environment["CMUX_DEBUG_LOG"], + !rawLogPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return + } + + let timestamp = ISO8601DateFormatter().string(from: Date()) + let line = "\(timestamp) \(message)\n" + let data = Data(line.utf8) + + cmuxDebugLogLock.lock() + defer { cmuxDebugLogLock.unlock() } + + workspaceLayoutAppendDebugLog(path: rawLogPath, data: data) +#endif +} + +#if DEBUG +func startupLog(_ message: String) { + let ts = ISO8601DateFormatter().string(from: Date()) + let line = "[\(ts)] \(message)\n" + let logPath = "/tmp/cmux-startup-debug.log" + workspaceLayoutAppendDebugLog(path: logPath, data: Data(line.utf8)) +} +#else +func startupLog(_ message: String) { + _ = message +} +#endif + +#if DEBUG +let cmuxLatencyLogPath = "/tmp/cmux-key-latency-debug.log" +let cmuxLatencyLogLock = NSLock() +var cmuxLatencyLogSequence: UInt64 = 0 + +func latencyLog(_ name: String, data: [String: String] = [:]) { + let ts = ISO8601DateFormatter().string(from: Date()) + cmuxLatencyLogLock.lock() + cmuxLatencyLogSequence &+= 1 + let seq = cmuxLatencyLogSequence + cmuxLatencyLogLock.unlock() + + let monoMs = Int((ProcessInfo.processInfo.systemUptime * 1000.0).rounded()) + let payload = data + .sorted { $0.key < $1.key } + .map { "\($0.key)=\($0.value)" } + .joined(separator: " ") + let suffix = payload.isEmpty ? "" : " " + payload + let line = "[\(ts)] seq=\(seq) mono_ms=\(monoMs) event=\(name)\(suffix)\n" + + cmuxLatencyLogLock.lock() + defer { cmuxLatencyLogLock.unlock() } + workspaceLayoutAppendDebugLog(path: cmuxLatencyLogPath, data: Data(line.utf8)) +} + +func isDebugCmdD(_ event: NSEvent) -> Bool { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + return flags == [.command] && (event.charactersIgnoringModifiers ?? "").lowercased() == "d" +} + +func isDebugCtrlD(_ event: NSEvent) -> Bool { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + return flags == [.control] && (event.charactersIgnoringModifiers ?? "").lowercased() == "d" +} +#else +func latencyLog(_ name: String, data: [String: String] = [:]) { + _ = name + _ = data +} + +func isDebugCmdD(_ event: NSEvent) -> Bool { + _ = event + return false +} + +func isDebugCtrlD(_ event: NSEvent) -> Bool { + _ = event + return false +} +#endif diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutDelegate.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutDelegate.swift new file mode 100644 index 000000000000..86240deb8bd2 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutDelegate.swift @@ -0,0 +1,106 @@ +import AppKit +import Foundation + +@MainActor +protocol WorkspaceLayoutDelegate: AnyObject { + func workspaceSplit(shouldCreateTab tabId: TabID, inPane pane: PaneID) -> Bool + func workspaceSplit(shouldCloseTab tabId: TabID, inPane pane: PaneID) -> Bool + func workspaceSplit(didCreateTab tabId: TabID, inPane pane: PaneID) + func workspaceSplit(didCloseTab tabId: TabID, fromPane pane: PaneID) + func workspaceSplit(didSelectTab tabId: TabID, inPane pane: PaneID) + func workspaceSplit(didMoveTab tabId: TabID, fromPane source: PaneID, toPane destination: PaneID) + func workspaceSplit(shouldSplitPane pane: PaneID, orientation: SplitOrientation) -> Bool + func workspaceSplit(shouldClosePane pane: PaneID) -> Bool + func workspaceSplit(didSplitPane originalPane: PaneID, newPane: PaneID, orientation: SplitOrientation) + func workspaceSplit(didClosePane paneId: PaneID) + func workspaceSplit(didFocusPane pane: PaneID) + func workspaceSplit(didRequestNewTab kind: WorkspaceLayoutTabKind, inPane pane: PaneID) + func workspaceSplit(didRequestTabContextAction action: TabContextAction, for tabId: TabID, inPane pane: PaneID) +} + +struct WorkspacePanelPresentationFacts: Equatable, Sendable { + let paneId: PaneID + let panelId: UUID + let isWorkspaceVisible: Bool + let isWorkspaceInputActive: Bool + let isSelectedInPane: Bool + let isFocused: Bool + + var isVisibleInUI: Bool { + guard isWorkspaceVisible else { return false } + return isSelectedInPane || isFocused + } + + var wantsFirstResponder: Bool { + isVisibleInUI && isWorkspaceInputActive && isFocused + } +} + +enum WorkspaceTerminalPresentationOperation: Equatable, Sendable { + case setVisibleInUI(Bool) + case setActive(Bool) + case requestFirstResponderReconcile +} + +struct WorkspaceTerminalPresentationState: Equatable, Sendable { + let isVisibleInUI: Bool + let isActive: Bool + + var wantsFirstResponder: Bool { + isVisibleInUI && isActive + } +} + +enum WorkspaceTerminalPresentationTransitionResolver { + static func operations( + previous: WorkspaceTerminalPresentationState?, + next: WorkspaceTerminalPresentationState + ) -> [WorkspaceTerminalPresentationOperation] { + var operations: [WorkspaceTerminalPresentationOperation] = [] + + if previous?.isVisibleInUI != next.isVisibleInUI { + operations.append(.setVisibleInUI(next.isVisibleInUI)) + } + + if previous?.isActive != next.isActive { + operations.append(.setActive(next.isActive)) + } + + if next.wantsFirstResponder && previous?.wantsFirstResponder != true { + operations.append(.requestFirstResponderReconcile) + } + + return operations + } +} + +extension WorkspaceLayoutDelegate { + func workspaceSplit(shouldCreateTab tabId: TabID, inPane pane: PaneID) -> Bool { true } + func workspaceSplit(shouldCloseTab tabId: TabID, inPane pane: PaneID) -> Bool { true } + func workspaceSplit(didCreateTab tabId: TabID, inPane pane: PaneID) {} + func workspaceSplit(didCloseTab tabId: TabID, fromPane pane: PaneID) {} + func workspaceSplit(didSelectTab tabId: TabID, inPane pane: PaneID) {} + func workspaceSplit(didMoveTab tabId: TabID, fromPane source: PaneID, toPane destination: PaneID) {} + func workspaceSplit(shouldSplitPane pane: PaneID, orientation: SplitOrientation) -> Bool { true } + func workspaceSplit(shouldClosePane pane: PaneID) -> Bool { true } + func workspaceSplit(didSplitPane originalPane: PaneID, newPane: PaneID, orientation: SplitOrientation) {} + func workspaceSplit(didClosePane paneId: PaneID) {} + func workspaceSplit(didFocusPane pane: PaneID) {} + func workspaceSplit(didRequestNewTab kind: WorkspaceLayoutTabKind, inPane pane: PaneID) {} + func workspaceSplit(didRequestTabContextAction action: TabContextAction, for tabId: TabID, inPane pane: PaneID) {} +} +extension WorkspaceTabID { + init(id: UUID) { + self.rawValue = id + } + + var id: UUID { + rawValue + } +} + +extension WorkspaceDropZone { + var insertsFirst: Bool { + insertFirst + } +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutImageBuffer.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutImageBuffer.swift new file mode 100644 index 000000000000..758fd27a0bbc --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutImageBuffer.swift @@ -0,0 +1,205 @@ +import AppKit +import Foundation +import UniformTypeIdentifiers + +struct WorkspaceLayoutRGBAImageBuffer { + let width: Int + let height: Int + let bytes: [UInt8] +} + +func workspaceLayoutRGBAImageBuffer(from image: NSImage) -> WorkspaceLayoutRGBAImageBuffer? { + guard let cgImage = workspaceLayoutCGImage(from: image) else { return nil } + return workspaceLayoutRGBAImageBuffer(from: cgImage) +} + +func workspaceLayoutRGBAImageBuffer(from cgImage: CGImage) -> WorkspaceLayoutRGBAImageBuffer? { + let width = cgImage.width + let height = cgImage.height + var bytes = [UInt8](repeating: 0, count: width * height * 4) + guard let context = CGContext( + data: &bytes, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue + ) else { + return nil + } + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) + return WorkspaceLayoutRGBAImageBuffer(width: width, height: height, bytes: bytes) +} + +func workspaceLayoutResizeImageBuffer( + _ buffer: WorkspaceLayoutRGBAImageBuffer, + width: Int, + height: Int +) -> WorkspaceLayoutRGBAImageBuffer? { + guard let image = workspaceLayoutImageFromRGBABytes(buffer.bytes, width: buffer.width, height: buffer.height), + let cgImage = workspaceLayoutCGImage(from: image) else { + return nil + } + var bytes = [UInt8](repeating: 0, count: width * height * 4) + guard let context = CGContext( + data: &bytes, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue + ) else { + return nil + } + context.interpolationQuality = .none + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) + return WorkspaceLayoutRGBAImageBuffer(width: width, height: height, bytes: bytes) +} + +func workspaceLayoutImageFromRGBABytes( + _ bytes: [UInt8], + width: Int, + height: Int +) -> NSImage? { + let data = Data(bytes) + guard let provider = CGDataProvider(data: data as CFData), + let cgImage = CGImage( + width: width, + height: height, + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue), + provider: provider, + decode: nil, + shouldInterpolate: false, + intent: .defaultIntent + ) else { + return nil + } + let image = NSImage(cgImage: cgImage, size: CGSize(width: width, height: height)) + return image +} + +func workspaceLayoutCGImage(from image: NSImage) -> CGImage? { + var proposed = CGRect(origin: .zero, size: image.size) + if let cgImage = image.cgImage(forProposedRect: &proposed, context: nil, hints: nil) { + return cgImage + } + guard let tiff = image.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff) else { + return nil + } + return rep.cgImage +} + +func workspaceLayoutWritePNG(image: NSImage, to url: URL) throws { + guard let cgImage = workspaceLayoutCGImage(from: image) else { + throw NSError(domain: "WorkspaceTabChromeDebug", code: 1, userInfo: nil) + } + let rep = NSBitmapImageRep(cgImage: cgImage) + guard let data = rep.representation(using: .png, properties: [:]) else { + throw NSError(domain: "WorkspaceTabChromeDebug", code: 2, userInfo: nil) + } + try data.write(to: url, options: .atomic) +} + +func workspaceSplitSnapshotImage( + for view: NSView, + scale: CGFloat = 1, + backgroundColor: NSColor? = nil +) -> NSImage? { + guard view.bounds.width > 0, view.bounds.height > 0 else { return nil } + let width = max(1, Int(ceil(view.bounds.width * scale))) + let height = max(1, Int(ceil(view.bounds.height * scale))) + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: width, + pixelsHigh: height, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + ) else { + return nil + } + rep.size = view.bounds.size + if let backgroundColor, + let context = NSGraphicsContext(bitmapImageRep: rep) { + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + backgroundColor.setFill() + NSBezierPath(rect: view.bounds).fill() + context.flushGraphics() + NSGraphicsContext.restoreGraphicsState() + } + view.cacheDisplay(in: view.bounds, to: rep) + let image = NSImage(size: view.bounds.size) + image.addRepresentation(rep) + return image +} + +func workspaceSplitDecodeTransfer(from pasteboard: NSPasteboard) -> TabTransferData? { + let type = NSPasteboard.PasteboardType(UTType.tabTransfer.identifier) + if let data = pasteboard.data(forType: type), + let transfer = try? JSONDecoder().decode(TabTransferData.self, from: data) { + return transfer + } + if let raw = pasteboard.string(forType: type), + let data = raw.data(using: .utf8), + let transfer = try? JSONDecoder().decode(TabTransferData.self, from: data) { + return transfer + } + return nil +} + +func workspaceSplitHoveredTabBackground( + for appearance: WorkspaceLayoutConfiguration.Appearance +) -> NSColor { + guard let backgroundHex = appearance.chromeColors.backgroundHex, + let custom = NSColor(workspaceSplitHex: backgroundHex) else { + return NSColor.controlBackgroundColor.withAlphaComponent(0.5) + } + + let adjusted = workspaceSplitIsLightColor(custom) + ? workspaceSplitAdjustColor(custom, by: -0.03) + : workspaceSplitAdjustColor(custom, by: 0.07) + return adjusted.withAlphaComponent(0.78) +} + +func workspaceSplitPressedTabBackground( + for appearance: WorkspaceLayoutConfiguration.Appearance +) -> NSColor { + guard let backgroundHex = appearance.chromeColors.backgroundHex, + let custom = NSColor(workspaceSplitHex: backgroundHex) else { + return NSColor.controlBackgroundColor.withAlphaComponent(0.72) + } + + let adjusted = workspaceSplitIsLightColor(custom) + ? workspaceSplitAdjustColor(custom, by: -0.065) + : workspaceSplitAdjustColor(custom, by: 0.12) + return adjusted.withAlphaComponent(0.9) +} + +func workspaceSplitIsLightColor(_ color: NSColor) -> Bool { + guard let rgb = color.usingColorSpace(.deviceRGB) else { return false } + let luminance = (0.299 * rgb.redComponent) + (0.587 * rgb.greenComponent) + (0.114 * rgb.blueComponent) + return luminance > 0.6 +} + +func workspaceSplitAdjustColor(_ color: NSColor, by delta: CGFloat) -> NSColor { + guard let rgb = color.usingColorSpace(.deviceRGB) else { return color } + let clamp: (CGFloat) -> CGFloat = { min(max($0, 0), 1) } + return NSColor( + red: clamp(rgb.redComponent + delta), + green: clamp(rgb.greenComponent + delta), + blue: clamp(rgb.blueComponent + delta), + alpha: rgb.alphaComponent + ) +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutInteractionHandlers.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutInteractionHandlers.swift new file mode 100644 index 000000000000..77fb92e08802 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutInteractionHandlers.swift @@ -0,0 +1,119 @@ +import AppKit +import Foundation + +@MainActor +struct WorkspaceLayoutInteractionHandlers { + let notifyGeometryChangeHandler: (Bool) -> Void + let setContainerFrameHandler: (CGRect) -> Void + let setDividerPositionHandler: (CGFloat, UUID) -> Bool + let consumeSplitEntryAnimationHandler: (UUID) -> Void + let beginTabDragHandler: (TabID, PaneID) -> Void + let clearDragStateHandler: () -> Void + let focusPaneHandler: (PaneID) -> Bool + let selectTabHandler: (TabID) -> Void + let requestCloseTabHandler: (TabID, PaneID) -> Bool + let togglePaneZoomHandler: (PaneID) -> Bool + let requestTabContextActionHandler: (TabContextAction, TabID, PaneID) -> Void + let requestNewTabHandler: (WorkspaceLayoutTabKind, PaneID) -> Void + let splitPaneHandler: (PaneID?, SplitOrientation) -> PaneID? + let splitPaneMovingTabHandler: (PaneID?, SplitOrientation, TabID, Bool, Bool) -> PaneID? + let moveTabHandler: (TabID, PaneID, Int?) -> Bool + let handleExternalTabDropHandler: (WorkspaceLayoutExternalTabDropRequest) -> Bool + let handleFileDropHandler: ([URL], PaneID) -> Bool + + func notifyGeometryChange(isDragging: Bool) { + notifyGeometryChangeHandler(isDragging) + } + + func setContainerFrame(_ frame: CGRect) { + setContainerFrameHandler(frame) + } + + func setDividerPosition(_ position: CGFloat, forSplit splitId: UUID) -> Bool { + setDividerPositionHandler(position, splitId) + } + + func consumeSplitEntryAnimation(_ splitId: UUID) { + consumeSplitEntryAnimationHandler(splitId) + } + + func beginTabDrag(tabId: TabID, sourcePaneId: PaneID) { + beginTabDragHandler(tabId, sourcePaneId) + } + + func clearDragState() { + clearDragStateHandler() + } + + func focusPane(_ paneId: PaneID) -> Bool { + focusPaneHandler(paneId) + } + + func selectTab(_ tabId: TabID) { + selectTabHandler(tabId) + } + + func requestCloseTab(_ tabId: TabID, inPane paneId: PaneID) -> Bool { + requestCloseTabHandler(tabId, paneId) + } + + func togglePaneZoom(inPane paneId: PaneID) -> Bool { + togglePaneZoomHandler(paneId) + } + + func requestTabContextAction(_ action: TabContextAction, for tabId: TabID, inPane paneId: PaneID) { + requestTabContextActionHandler(action, tabId, paneId) + } + + func requestNewTab(kind: WorkspaceLayoutTabKind, inPane paneId: PaneID) { + requestNewTabHandler(kind, paneId) + } + + func splitPane(_ paneId: PaneID?, orientation: SplitOrientation) -> PaneID? { + splitPaneHandler(paneId, orientation) + } + + func splitPane( + _ paneId: PaneID?, + orientation: SplitOrientation, + movingTab tabId: TabID, + insertFirst: Bool, + focusNewPane: Bool + ) -> PaneID? { + splitPaneMovingTabHandler(paneId, orientation, tabId, insertFirst, focusNewPane) + } + + func moveTab(_ tabId: TabID, toPane paneId: PaneID, atIndex index: Int?) -> Bool { + moveTabHandler(tabId, paneId, index) + } + + func handleExternalTabDrop(_ request: WorkspaceLayoutExternalTabDropRequest) -> Bool { + handleExternalTabDropHandler(request) + } + + func handleFileDrop(_ urls: [URL], in paneId: PaneID) -> Bool { + handleFileDropHandler(urls, paneId) + } + + static let noop = WorkspaceLayoutInteractionHandlers( + notifyGeometryChangeHandler: { _ in }, + setContainerFrameHandler: { _ in }, + setDividerPositionHandler: { _, _ in false }, + consumeSplitEntryAnimationHandler: { _ in }, + beginTabDragHandler: { _, _ in }, + clearDragStateHandler: {}, + focusPaneHandler: { _ in false }, + selectTabHandler: { _ in }, + requestCloseTabHandler: { _, _ in false }, + togglePaneZoomHandler: { _ in false }, + requestTabContextActionHandler: { _, _, _ in }, + requestNewTabHandler: { _, _ in }, + splitPaneHandler: { _, _ in nil }, + splitPaneMovingTabHandler: { _, _, _, _, _ in nil }, + moveTabHandler: { _, _, _ in false }, + handleExternalTabDropHandler: { _ in false }, + handleFileDropHandler: { _, _ in false } + ) +} + +/// Main entry point for the WorkspaceLayout library. diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNativeHost.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNativeHost.swift new file mode 100644 index 000000000000..bb8efac86793 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNativeHost.swift @@ -0,0 +1,482 @@ +import AppKit +import SwiftUI + +@MainActor +struct WorkspaceLayoutNativeHost: NSViewRepresentable { + let hostBridge: WorkspaceLayoutInteractionHandlers + let renderSnapshot: WorkspaceLayoutRenderSnapshot + let surfaceRegistry: any WorkspaceSurfaceRegistryProtocol + + init( + hostBridge: WorkspaceLayoutInteractionHandlers, + renderSnapshot: WorkspaceLayoutRenderSnapshot, + surfaceRegistry: any WorkspaceSurfaceRegistryProtocol + ) { + self.hostBridge = hostBridge + self.renderSnapshot = renderSnapshot + self.surfaceRegistry = surfaceRegistry + } + + func makeNSView(context: Context) -> WorkspaceLayoutRootHostView { + let view = WorkspaceLayoutRootHostView( + hostBridge: hostBridge, + renderSnapshot: renderSnapshot, + surfaceRegistry: surfaceRegistry + ) + view.translatesAutoresizingMaskIntoConstraints = false + return view + } + + func updateNSView(_ nsView: WorkspaceLayoutRootHostView, context: Context) { + nsView.update( + hostBridge: hostBridge, + renderSnapshot: renderSnapshot, + surfaceRegistry: surfaceRegistry + ) + } + + static func dismantleNSView(_ nsView: WorkspaceLayoutRootHostView, coordinator: ()) { + nsView.prepareForRemoval() + } +} + +@MainActor +final class WorkspaceLayoutRootHostView: NSView { + var hostBridge: WorkspaceLayoutInteractionHandlers + var desiredRenderSnapshot: WorkspaceLayoutRenderSnapshot + var displayedRenderSnapshot: WorkspaceLayoutRenderSnapshot + let surfaceRegistry: any WorkspaceSurfaceRegistryProtocol + var activeLocalTabDrag: WorkspaceLayoutLocalDragSnapshot? + + let viewportCanvas: WorkspaceLayoutViewportCanvasView + var currentRootView: NSView? + var paneHosts: [UUID: WorkspaceLayoutPaneHostView] = [:] + var splitHosts: [UUID: WorkspaceLayoutNativeSplitView] = [:] + var dropOverlayCoordinator = WorkspacePaneDropOverlayCoordinator() + var renderedPaneIds: Set = [] + var renderedSplitIds: Set = [] + var lastContainerFrame: CGRect = .zero + + init( + hostBridge: WorkspaceLayoutInteractionHandlers, + renderSnapshot: WorkspaceLayoutRenderSnapshot, + surfaceRegistry: any WorkspaceSurfaceRegistryProtocol + ) { + self.hostBridge = hostBridge + desiredRenderSnapshot = renderSnapshot + displayedRenderSnapshot = renderSnapshot + self.surfaceRegistry = surfaceRegistry + activeLocalTabDrag = renderSnapshot.presentation.localTabDrag + viewportCanvas = WorkspaceLayoutViewportCanvasView( + surfaceRegistry: surfaceRegistry, + debugName: "live" + ) + super.init(frame: .zero) + wantsLayer = true + layer?.masksToBounds = true + addSubview(viewportCanvas) + updateBackground() + rebuildTree() + applyViewportScene() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update( + hostBridge: WorkspaceLayoutInteractionHandlers, + renderSnapshot: WorkspaceLayoutRenderSnapshot, + surfaceRegistry: any WorkspaceSurfaceRegistryProtocol + ) { + self.hostBridge = hostBridge + desiredRenderSnapshot = renderSnapshot + syncLocalTabDragIfNeeded(from: renderSnapshot) + isHidden = false + refreshPresentation() + syncContainerFrameIfNeeded(isDragging: false) + } + + override func layout() { + super.layout() + viewportCanvas.frame = bounds + currentRootView?.frame = bounds + syncContainerFrameIfNeeded(isDragging: false) + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() +#if DEBUG + startupLog( + "startup.root.windowMove host=\(Unmanaged.passUnretained(self).toOpaque()) " + + "inWindow=\(window != nil ? 1 : 0)" + ) +#endif + refreshPresentation() + syncContainerFrameIfNeeded(isDragging: false) + } + + func prepareForRemoval() { + clearLocalTabDrag(propagateToWorkspace: false) + viewportCanvas.clear(surfaceRegistry: surfaceRegistry) + + for (_, host) in paneHosts { + host.prepareForRemoval() + if host.superview != nil { + host.removeFromSuperview() + } + } + paneHosts.removeAll() + + for (_, host) in splitHosts { + host.removeAllChildren() + if host.superview != nil { + host.removeFromSuperview() + } + } + splitHosts.removeAll() + + dropOverlayCoordinator.clearAll() + renderedPaneIds.removeAll() + renderedSplitIds.removeAll() + + currentRootView?.removeFromSuperview() + currentRootView = nil + } + + func updateBackground() { + layer?.backgroundColor = TabBarColors.nsColorPaneBackground( + for: displayedRenderSnapshot.presentation.appearance + ).cgColor + } + + func notifyGeometryChanged(isDragging: Bool) { + syncContainerFrameIfNeeded(isDragging: isDragging) + hostBridge.notifyGeometryChange(isDragging: isDragging) + } + + func updatePaneDropZone(_ zone: DropZone, for paneId: PaneID) { + guard dropOverlayCoordinator.setZone(zone, for: paneId) else { return } + syncPaneDropState(for: paneId) + } + + func clearPaneDropZone(for paneId: PaneID) { + guard dropOverlayCoordinator.clearZone(for: paneId) else { return } + syncPaneDropState(for: paneId) + } + + func clearPaneDropZoneImmediately(for paneId: PaneID) { + guard dropOverlayCoordinator.clearZoneImmediately(for: paneId) else { return } + syncPaneDropState(for: paneId) + } + + func completePaneDropOverlayHide(for paneId: PaneID, generation: UInt64) { + guard dropOverlayCoordinator.completeHide(for: paneId, generation: generation) else { return } + syncPaneDropState(for: paneId) + } + + func syncContainerFrameIfNeeded(isDragging: Bool) { + let frame = convert(bounds, to: nil) + guard frame != lastContainerFrame else { return } + lastContainerFrame = frame + hostBridge.setContainerFrame(frame) + if !isDragging { + hostBridge.notifyGeometryChange(isDragging: false) + } + } + + func rebuildTree() { + let nextPaneIds = displayedRenderSnapshot.root.paneIds + let nextSplitIds = displayedRenderSnapshot.root.splitIds + + let nextRootView = hostView(for: displayedRenderSnapshot.root) + + if currentRootView !== nextRootView { + if nextRootView.superview !== self { + addSubview(nextRootView) + } + if currentRootView?.superview === self { + currentRootView?.removeFromSuperview() + } + currentRootView = nextRootView + } + + currentRootView?.frame = bounds + renderedPaneIds = nextPaneIds + renderedSplitIds = nextSplitIds + cleanupUnusedHosts() + } + + func applyViewportScene() { + viewportCanvas.update( + viewports: displayedRenderSnapshot.viewports, + presentation: displayedRenderSnapshot.presentation, + paneDropZones: dropOverlayCoordinator.viewportDropZones() + ) + } + + func cleanupUnusedHosts() { + let livePaneIds = displayedRenderSnapshot.root.paneIds + let liveSplitIds = displayedRenderSnapshot.root.splitIds + + for (id, host) in paneHosts where !livePaneIds.contains(id) { + host.prepareForRemoval() + dropOverlayCoordinator.removePane(PaneID(id: id)) + if host.superview != nil { + host.removeFromSuperview() + } + paneHosts.removeValue(forKey: id) + } + + for (id, host) in splitHosts where !liveSplitIds.contains(id) { + host.removeAllChildren() + if host.superview != nil { + host.removeFromSuperview() + } + splitHosts.removeValue(forKey: id) + } + } + + func hostView(for node: WorkspaceLayoutRenderNodeSnapshot) -> NSView { + switch node { + case .pane(let snapshot): + return paneHost(for: snapshot) + case .split(let snapshot): + return splitHost(for: snapshot) + } + } + + func paneHost(for snapshot: WorkspaceLayoutPaneRenderSnapshot) -> WorkspaceLayoutPaneHostView { + let subviewHostBridge = interactionHandlersForSubviews() + if let existing = paneHosts[snapshot.paneId.id] { + existing.update( + snapshot: snapshot, + hostBridge: subviewHostBridge, + presentation: displayedRenderSnapshot.presentation, + localTabDrag: activeLocalTabDrag, + overlayPresentation: dropOverlayCoordinator.overlayPresentation(for: snapshot.paneId), + contentDropZone: dropOverlayCoordinator.activeDropZone(for: snapshot.paneId), + surfaceRegistry: surfaceRegistry + ) + return existing + } + + let host = WorkspaceLayoutPaneHostView( + rootHost: self, + snapshot: snapshot, + hostBridge: subviewHostBridge, + presentation: displayedRenderSnapshot.presentation, + localTabDrag: activeLocalTabDrag, + overlayPresentation: dropOverlayCoordinator.overlayPresentation(for: snapshot.paneId), + contentDropZone: dropOverlayCoordinator.activeDropZone(for: snapshot.paneId), + surfaceRegistry: surfaceRegistry + ) + paneHosts[snapshot.paneId.id] = host + return host + } + + func splitHost(for snapshot: WorkspaceLayoutSplitRenderSnapshot) -> WorkspaceLayoutNativeSplitView { + if let existing = splitHosts[snapshot.splitId] { + existing.update( + snapshot: snapshot, + hostBridge: hostBridge, + rootHost: self, + firstChild: hostView(for: snapshot.first), + secondChild: hostView(for: snapshot.second), + appearance: displayedRenderSnapshot.presentation.appearance + ) + return existing + } + + let host = WorkspaceLayoutNativeSplitView( + snapshot: snapshot, + hostBridge: hostBridge, + rootHost: self, + firstChild: hostView(for: snapshot.first), + secondChild: hostView(for: snapshot.second), + appearance: displayedRenderSnapshot.presentation.appearance + ) + splitHosts[snapshot.splitId] = host + return host + } + + func isCurrentSplitHost(_ host: WorkspaceLayoutNativeSplitView, for splitId: UUID) -> Bool { + splitHosts[splitId] === host + } + + func refreshPresentation() { + displayedRenderSnapshot = desiredRenderSnapshot + updateBackground() + rebuildTree() + applyViewportScene() + } + + func syncLocalTabDragIfNeeded(from renderSnapshot: WorkspaceLayoutRenderSnapshot) { + guard let snapshotLocalTabDrag = renderSnapshot.presentation.localTabDrag else { + guard activeLocalTabDrag != nil else { return } + clearLocalTabDrag(propagateToWorkspace: false) + return + } + guard snapshotLocalTabDrag != activeLocalTabDrag else { return } + activeLocalTabDrag = snapshotLocalTabDrag + propagateLocalTabDrag() + } + + func interactionHandlersForSubviews() -> WorkspaceLayoutInteractionHandlers { + WorkspaceLayoutInteractionHandlers( + notifyGeometryChangeHandler: { [weak self] isDragging in + self?.hostBridge.notifyGeometryChange(isDragging: isDragging) + }, + setContainerFrameHandler: { [weak self] frame in + self?.hostBridge.setContainerFrame(frame) + }, + setDividerPositionHandler: { [weak self] position, splitId in + self?.hostBridge.setDividerPosition(position, forSplit: splitId) ?? false + }, + consumeSplitEntryAnimationHandler: { [weak self] splitId in + self?.hostBridge.consumeSplitEntryAnimation(splitId) + }, + beginTabDragHandler: { [weak self] tabId, sourcePaneId in + self?.beginLocalTabDrag(tabId: tabId, sourcePaneId: sourcePaneId) + }, + clearDragStateHandler: { [weak self] in + self?.clearLocalTabDrag() + }, + focusPaneHandler: { [weak self] paneId in + self?.hostBridge.focusPane(paneId) ?? false + }, + selectTabHandler: { [weak self] tabId in + self?.hostBridge.selectTab(tabId) + }, + requestCloseTabHandler: { [weak self] tabId, paneId in + self?.hostBridge.requestCloseTab(tabId, inPane: paneId) ?? false + }, + togglePaneZoomHandler: { [weak self] paneId in + self?.hostBridge.togglePaneZoom(inPane: paneId) ?? false + }, + requestTabContextActionHandler: { [weak self] action, tabId, paneId in + self?.hostBridge.requestTabContextAction(action, for: tabId, inPane: paneId) + }, + requestNewTabHandler: { [weak self] kind, paneId in + self?.hostBridge.requestNewTab(kind: kind, inPane: paneId) + }, + splitPaneHandler: { [weak self] paneId, orientation in + self?.hostBridge.splitPane(paneId, orientation: orientation) + }, + splitPaneMovingTabHandler: { [weak self] paneId, orientation, tabId, insertFirst, focusNewPane in + self?.hostBridge.splitPane( + paneId, + orientation: orientation, + movingTab: tabId, + insertFirst: insertFirst, + focusNewPane: focusNewPane + ) + }, + moveTabHandler: { [weak self] tabId, paneId, index in + self?.hostBridge.moveTab(tabId, toPane: paneId, atIndex: index) ?? false + }, + handleExternalTabDropHandler: { [weak self] request in + self?.hostBridge.handleExternalTabDrop(request) ?? false + }, + handleFileDropHandler: { [weak self] urls, paneId in + self?.hostBridge.handleFileDrop(urls, in: paneId) ?? false + } + ) + } + + func beginLocalTabDrag(tabId: TabID, sourcePaneId: PaneID) { + let drag = WorkspaceLayoutLocalDragSnapshot(tabId: tabId, sourcePaneId: sourcePaneId) + guard activeLocalTabDrag != drag else { return } + activeLocalTabDrag = drag + propagateLocalTabDrag() + hostBridge.beginTabDrag(tabId: tabId, sourcePaneId: sourcePaneId) + } + + func clearLocalTabDrag(propagateToWorkspace: Bool = true) { + let hadActiveLocalTabDrag = activeLocalTabDrag != nil + activeLocalTabDrag = nil + if hadActiveLocalTabDrag { + propagateLocalTabDrag() + clearAllPaneDropOverlays() + } + if propagateToWorkspace { + hostBridge.clearDragState() + } + } + + func propagateLocalTabDrag() { + for host in paneHosts.values { + host.updateLocalTabDrag(activeLocalTabDrag) + } + } + + func clearAllPaneDropOverlays() { + let paneIds = Array(paneHosts.keys).map(PaneID.init(id:)) + var didChange = false + for paneId in paneIds { + didChange = dropOverlayCoordinator.clearZone(for: paneId) || didChange + paneHosts[paneId.id]?.updateDropPresentation( + overlayPresentation: dropOverlayCoordinator.overlayPresentation(for: paneId), + contentDropZone: dropOverlayCoordinator.activeDropZone(for: paneId), + surfaceRegistry: surfaceRegistry + ) + } + if didChange { + viewportCanvas.update( + viewports: displayedRenderSnapshot.viewports, + presentation: displayedRenderSnapshot.presentation, + paneDropZones: dropOverlayCoordinator.viewportDropZones() + ) + } + } + + func syncPaneDropState(for paneId: PaneID) { + paneHosts[paneId.id]?.updateDropPresentation( + overlayPresentation: dropOverlayCoordinator.overlayPresentation(for: paneId), + contentDropZone: dropOverlayCoordinator.activeDropZone(for: paneId), + surfaceRegistry: surfaceRegistry + ) + viewportCanvas.update( + viewports: displayedRenderSnapshot.viewports, + presentation: displayedRenderSnapshot.presentation, + paneDropZones: dropOverlayCoordinator.viewportDropZones() + ) + } + +#if DEBUG + var debugViewportMountIdentities: Set { + viewportCanvas.debugMountedIdentities + } + + func debugPaneMountedDirectContentIdentity(_ paneId: PaneID) -> WorkspacePaneMountIdentity? { + paneHosts[paneId.id]?.debugMountedDirectContentIdentity + } + + func debugPaneDirectContentBounds(_ paneId: PaneID) -> CGRect { + paneHosts[paneId.id]?.debugDirectContentBounds ?? .zero + } + + func debugPaneUsesDirectTerminalHost(_ paneId: PaneID) -> Bool { + paneHosts[paneId.id]?.debugUsesDirectTerminalHost ?? false + } + + func debugPaneLocalTabDrag(_ paneId: PaneID) -> WorkspaceLayoutLocalDragSnapshot? { + paneHosts[paneId.id]?.debugLocalTabDrag + } + + func debugBeginLocalTabDrag(tabId: TabID, sourcePaneId: PaneID) { + beginLocalTabDrag(tabId: tabId, sourcePaneId: sourcePaneId) + } + + func debugClearLocalTabDrag() { + clearLocalTabDrag() + } +#endif +} + +func workspaceLayoutRemoveSubviewIfOwned(_ child: NSView?, from container: NSView) { + guard let child, + child.superview === container else { return } + child.removeFromSuperview() +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNativeSplitView.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNativeSplitView.swift new file mode 100644 index 000000000000..25a349c1f39e --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNativeSplitView.swift @@ -0,0 +1,245 @@ +import AppKit + +final class WorkspaceLayoutNativeSplitView: NSSplitView, NSSplitViewDelegate { + let hostBridge: WorkspaceLayoutInteractionHandlers + weak var rootHost: WorkspaceLayoutRootHostView? + var splitId: UUID + var splitOrientation: SplitOrientation + var splitDividerPosition: CGFloat + var splitAnimationOrigin: SplitAnimationOrigin? + var splitAppearance: WorkspaceLayoutConfiguration.Appearance + + let firstContainer = NSView(frame: .zero) + let secondContainer = NSView(frame: .zero) + weak var firstChild: NSView? + weak var secondChild: NSView? + + var lastAppliedPosition: CGFloat + var isSyncingProgrammatically = false + var didApplyInitialDividerPosition = false + var initialDividerApplyAttempts = 0 + var isAnimatingEntry = false + + init( + snapshot: WorkspaceLayoutSplitRenderSnapshot, + hostBridge: WorkspaceLayoutInteractionHandlers, + rootHost: WorkspaceLayoutRootHostView, + firstChild: NSView, + secondChild: NSView, + appearance: WorkspaceLayoutConfiguration.Appearance + ) { + self.hostBridge = hostBridge + self.rootHost = rootHost + self.splitId = snapshot.splitId + self.splitOrientation = snapshot.orientation + self.splitDividerPosition = snapshot.dividerPosition + self.splitAnimationOrigin = snapshot.animationOrigin + self.splitAppearance = appearance + self.lastAppliedPosition = snapshot.dividerPosition + super.init(frame: .zero) + delegate = self + dividerStyle = .thin + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + isVertical = snapshot.orientation == .horizontal + addArrangedSubview(firstContainer) + addArrangedSubview(secondContainer) + configure(container: firstContainer) + configure(container: secondContainer) + install(child: firstChild, in: firstContainer, current: &self.firstChild) + install(child: secondChild, in: secondContainer, current: &self.secondChild) + updateDividerColor() + applyInitialDividerPositionIfNeeded() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update( + snapshot: WorkspaceLayoutSplitRenderSnapshot, + hostBridge: WorkspaceLayoutInteractionHandlers, + rootHost: WorkspaceLayoutRootHostView, + firstChild: NSView, + secondChild: NSView, + appearance: WorkspaceLayoutConfiguration.Appearance + ) { + if self.splitId != snapshot.splitId { + didApplyInitialDividerPosition = false + initialDividerApplyAttempts = 0 + isAnimatingEntry = false + } + + self.rootHost = rootHost + self.splitId = snapshot.splitId + self.splitOrientation = snapshot.orientation + self.splitDividerPosition = snapshot.dividerPosition + self.splitAnimationOrigin = snapshot.animationOrigin + self.splitAppearance = appearance + isHidden = rootHost.isHidden + isVertical = snapshot.orientation == .horizontal + updateDividerColor() + guard rootHost.isCurrentSplitHost(self, for: snapshot.splitId) else { return } + install(child: firstChild, in: firstContainer, current: &self.firstChild) + install(child: secondChild, in: secondContainer, current: &self.secondChild) + syncDividerPosition() + } + + func removeAllChildren() { + workspaceLayoutRemoveSubviewIfOwned(firstChild, from: firstContainer) + workspaceLayoutRemoveSubviewIfOwned(secondChild, from: secondContainer) + firstChild = nil + secondChild = nil + } + + override func layout() { + super.layout() + firstContainer.frame = arrangedSubviews.first?.frame ?? .zero + secondContainer.frame = arrangedSubviews.dropFirst().first?.frame ?? .zero + applyInitialDividerPositionIfNeeded() + } + + func configure(container: NSView) { + container.wantsLayer = true + container.layer?.backgroundColor = NSColor.clear.cgColor + container.layer?.masksToBounds = true + } + + func install(child: NSView, in container: NSView, current: inout NSView?) { + if current !== child { + workspaceLayoutRemoveSubviewIfOwned(current, from: container) + if child.superview !== container { + child.removeFromSuperview() + container.addSubview(child) + } + current = child + } else if child.superview !== container { + child.removeFromSuperview() + container.addSubview(child) + } + child.frame = container.bounds + child.autoresizingMask = [.width, .height] + } + + func updateDividerColor() { + if let layer { + layer.backgroundColor = NSColor.clear.cgColor + } + needsDisplay = true + } + + override var dividerColor: NSColor { + TabBarColors.nsColorSeparator(for: splitAppearance) + } + + func applyInitialDividerPositionIfNeeded() { + guard !didApplyInitialDividerPosition else { return } + + let available = availableSplitSize + guard available > 0 else { + initialDividerApplyAttempts += 1 + guard initialDividerApplyAttempts < 8 else { + didApplyInitialDividerPosition = true + consumePendingAnimationOriginIfNeeded() + return + } + Task { @MainActor [weak self] in + self?.applyInitialDividerPositionIfNeeded() + } + return + } + + didApplyInitialDividerPosition = true + let targetPosition = round(available * splitDividerPosition) + + guard splitAppearance.enableAnimations, + let animationOrigin = splitAnimationOrigin else { + consumePendingAnimationOriginIfNeeded() + setDividerPosition(targetPosition, layout: false) + return + } + + let startPosition: CGFloat = animationOrigin == .fromFirst ? 0 : available + consumePendingAnimationOriginIfNeeded() + isAnimatingEntry = true + setDividerPosition(startPosition, layout: true) + + Task { @MainActor [weak self] in + guard let self else { return } + SplitAnimator.shared.animate( + splitView: self, + from: startPosition, + to: targetPosition, + duration: self.splitAppearance.animationDuration + ) { [weak self] in + guard let self else { return } + self.isAnimatingEntry = false + self.lastAppliedPosition = self.splitDividerPosition + self.rootHost?.notifyGeometryChanged(isDragging: false) + } + } + } + + var availableSplitSize: CGFloat { + let total = isVertical ? bounds.width : bounds.height + return max(0, total - dividerThickness) + } + + func setDividerPosition(_ position: CGFloat, layout: Bool) { + guard arrangedSubviews.count >= 2 else { return } + isSyncingProgrammatically = true + setPosition(position, ofDividerAt: 0) + if layout { + layoutSubtreeIfNeeded() + } + isSyncingProgrammatically = false + lastAppliedPosition = availableSplitSize > 0 ? position / availableSplitSize : splitDividerPosition + } + + func syncDividerPosition() { + guard !isAnimatingEntry else { return } + let available = availableSplitSize + guard available > 0 else { return } + let desired = min(max(splitDividerPosition, 0.1), 0.9) + guard abs(desired - lastAppliedPosition) > 0.0005 else { return } + setDividerPosition(round(available * desired), layout: false) + } + + func normalizedDividerPosition() -> CGFloat { + guard arrangedSubviews.count >= 2 else { return splitDividerPosition } + let firstFrame = arrangedSubviews[0].frame + let available = availableSplitSize + guard available > 0 else { return splitDividerPosition } + let occupied = isVertical ? firstFrame.width : firstFrame.height + return min(max(occupied / available, 0.1), 0.9) + } + + func splitViewDidResizeSubviews(_ notification: Notification) { + guard !isSyncingProgrammatically else { return } + let next = normalizedDividerPosition() + splitDividerPosition = next + _ = hostBridge.setDividerPosition(next, forSplit: splitId) + lastAppliedPosition = next + let eventType = NSApp.currentEvent?.type + let isDragging = eventType == .leftMouseDragged + rootHost?.notifyGeometryChanged(isDragging: isDragging) + } + + func splitView(_ splitView: NSSplitView, constrainMinCoordinate proposedMinimumPosition: CGFloat, ofSubviewAt dividerIndex: Int) -> CGFloat { + let minimum = isVertical ? splitAppearance.minimumPaneWidth : splitAppearance.minimumPaneHeight + return minimum + } + + func splitView(_ splitView: NSSplitView, constrainMaxCoordinate proposedMaximumPosition: CGFloat, ofSubviewAt dividerIndex: Int) -> CGFloat { + let minimum = isVertical ? splitAppearance.minimumPaneWidth : splitAppearance.minimumPaneHeight + let total = isVertical ? splitView.bounds.width : splitView.bounds.height + return max(minimum, total - minimum - splitView.dividerThickness) + } + + func consumePendingAnimationOriginIfNeeded() { + guard splitAnimationOrigin != nil else { return } + splitAnimationOrigin = nil + hostBridge.consumeSplitEntryAnimation(splitId) + } +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNativeTabBarView.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNativeTabBarView.swift new file mode 100644 index 000000000000..7bf0ed7281f2 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNativeTabBarView.swift @@ -0,0 +1,595 @@ +import AppKit +import UniformTypeIdentifiers + +@MainActor +final class WorkspaceLayoutNativeTabBarView: NSView { + var snapshot: WorkspaceLayoutPaneChromeSnapshot? + var hostBridge: (WorkspaceLayoutInteractionHandlers)? + var presentation: WorkspaceLayoutPresentationSnapshot? + var localTabDrag: WorkspaceLayoutLocalDragSnapshot? + let shortcutHintMonitor = WorkspaceLayoutPaneTabShortcutHintMonitor() + + let scrollView = NSScrollView(frame: .zero) + let documentView = WorkspaceLayoutTabDocumentView(frame: .zero) + let splitButtonsView = NSStackView(frame: .zero) + var tabButtons: [WorkspaceLayoutNativeTabButtonView] = [] + var trackingArea: NSTrackingArea? + var isHovering = false +#if DEBUG + var debugLastSnapshotSignature: String? +#endif + + var onTabMutation: (() -> Void)? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + scrollView.drawsBackground = false + scrollView.hasHorizontalScroller = false + scrollView.hasVerticalScroller = false + scrollView.autohidesScrollers = true + scrollView.borderType = .noBorder + scrollView.documentView = documentView + addSubview(scrollView) + + splitButtonsView.orientation = .horizontal + splitButtonsView.spacing = 4 + addSubview(splitButtonsView) + + documentView.onRequestRebuild = { [weak self] in + self?.rebuildButtons() + } + documentView.onDropPerformed = { [weak self] in + self?.onTabMutation?() + } + shortcutHintMonitor.onChange = { [weak self] in + self?.rebuildButtons() + self?.needsLayout = true + self?.needsDisplay = true + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func updateTrackingAreas() { + if let trackingArea { + removeTrackingArea(trackingArea) + } + let next = NSTrackingArea( + rect: .zero, + options: [.inVisibleRect, .activeAlways, .mouseEnteredAndExited], + owner: self, + userInfo: nil + ) + addTrackingArea(next) + trackingArea = next + super.updateTrackingAreas() + } + + override func mouseEntered(with event: NSEvent) { + isHovering = true + updateSplitButtonsVisibility() + } + + override func mouseExited(with event: NSEvent) { + isHovering = false + updateSplitButtonsVisibility() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + shortcutHintMonitor.setHostWindow(window) + if window != nil { + shortcutHintMonitor.start() + } else { + shortcutHintMonitor.stop() + } + } + + func update( + snapshot: WorkspaceLayoutPaneChromeSnapshot, + hostBridge: WorkspaceLayoutInteractionHandlers, + presentation: WorkspaceLayoutPresentationSnapshot, + localTabDrag: WorkspaceLayoutLocalDragSnapshot? + ) { + self.snapshot = snapshot + self.hostBridge = hostBridge + self.presentation = presentation + self.localTabDrag = localTabDrag + wantsLayer = true + layer?.backgroundColor = TabBarColors.nsColorPaneBackground( + for: presentation.appearance + ).cgColor +#if DEBUG + let selectedTitle = snapshot.tabs.first(where: { $0.tab.id.id == snapshot.selectedTabId })?.tab.title ?? "" + let selectedPreview = snapshot.selectedTabId.map { String($0.uuidString.prefix(8)) } ?? "none" + let signature = + "pane=\(snapshot.paneId.id.uuidString.prefix(8)) " + + "rev=\(snapshot.chromeRevision) " + + "selected=\(selectedPreview) " + + "title=\"\(workspaceLayoutDebugPreview(selectedTitle))\" " + + "count=\(snapshot.tabs.count)" + if debugLastSnapshotSignature != signature { + debugLastSnapshotSignature = signature + } +#endif + documentView.update( + snapshot: snapshot, + hostBridge: hostBridge, + presentation: presentation, + localTabDrag: localTabDrag + ) + rebuildButtons() + rebuildSplitButtons() + updateSplitButtonsVisibility() + needsLayout = true + needsDisplay = true + } + + func updateLocalTabDrag(_ localTabDrag: WorkspaceLayoutLocalDragSnapshot?) { + guard self.localTabDrag != localTabDrag else { return } + self.localTabDrag = localTabDrag + documentView.updateLocalTabDrag(localTabDrag) + } + + override func layout() { + super.layout() + guard let presentation else { return } + let buttonWidth = splitButtonsView.isHidden ? 0 : splitButtonsView.fittingSize.width + 8 + scrollView.frame = CGRect(x: 0, y: 0, width: max(0, bounds.width - buttonWidth), height: bounds.height) + splitButtonsView.frame = CGRect( + x: max(0, bounds.width - buttonWidth), + y: 0, + width: buttonWidth, + height: bounds.height + ) + documentView.frame = CGRect(origin: .zero, size: CGSize(width: max(scrollView.contentSize.width, documentView.preferredContentWidth), height: bounds.height)) + documentView.needsLayout = true + documentView.layoutSubtreeIfNeeded() + layer?.backgroundColor = TabBarColors.nsColorPaneBackground( + for: presentation.appearance + ).cgColor + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + guard let presentation else { return } + let separatorColor = TabBarColors.nsColorSeparator(for: presentation.appearance) + separatorColor.setFill() + let segments = separatorSegments( + totalWidth: bounds.width, + gap: selectedTabSeparatorGap() + ) + if segments.left > 0 { + CGRect(x: 0, y: 0, width: segments.left, height: 1).fill() + } + if segments.right > 0 { + CGRect( + x: bounds.width - segments.right, + y: 0, + width: segments.right, + height: 1 + ).fill() + } + } + + func separatorSegments( + totalWidth: CGFloat, + gap: ClosedRange? + ) -> (left: CGFloat, right: CGFloat) { + let clampedTotal = max(0, totalWidth) + guard let gap else { + return (left: clampedTotal, right: 0) + } + + let start = min(max(gap.lowerBound, 0), clampedTotal) + let end = min(max(gap.upperBound, 0), clampedTotal) + let normalizedStart = min(start, end) + let normalizedEnd = max(start, end) + return ( + left: max(0, normalizedStart), + right: max(0, clampedTotal - normalizedEnd) + ) + } + + func selectedTabSeparatorGap() -> ClosedRange? { + guard let snapshot else { return nil } + let selectedId = snapshot.selectedTabId + guard let selectedId, + let selectedButton = tabButtons.first(where: { $0.tab.id.id == selectedId }) else { + return nil + } + + let frameInBar = convert(selectedButton.bounds, from: selectedButton) + guard frameInBar.maxX > 0, frameInBar.minX < bounds.width else { + return nil + } + return frameInBar.minX...frameInBar.maxX + } + + func rebuildButtons() { + guard let snapshot, let hostBridge, let presentation else { return } + let showsControlShortcutHints = snapshot.isFocused && + presentation.tabShortcutHintsEnabled && + shortcutHintMonitor.isShortcutHintVisible + + let existingById = Dictionary(uniqueKeysWithValues: tabButtons.map { ($0.tab.id, $0) }) + var nextButtons: [WorkspaceLayoutNativeTabButtonView] = [] + + for (index, tabSnapshot) in snapshot.tabs.enumerated() { + let tab = tabSnapshot.tab + let button = existingById[tab.id] ?? WorkspaceLayoutNativeTabButtonView(frame: .zero) + button.update( + tab: tabSnapshot.tab, + paneId: snapshot.paneId, + isSelected: tabSnapshot.isSelected, + isPaneFocused: snapshot.isFocused, + showsZoomIndicator: tabSnapshot.showsZoomIndicator, + controlShortcutDigit: workspaceLayoutTabControlShortcutDigit(for: index, tabCount: snapshot.tabs.count), + showsControlShortcutHint: showsControlShortcutHints, + shortcutModifierSymbol: shortcutHintMonitor.shortcutModifierSymbol, + appearance: presentation.appearance, + contextMenuState: tabSnapshot.contextMenuState, + onSelect: { [weak self] in + guard let self else { return } + _ = hostBridge.focusPane(snapshot.paneId) + hostBridge.selectTab(tab.id) + self.onTabMutation?() + }, + onClose: { [weak self] in + guard let self else { return } + guard !tab.isPinned else { return } + _ = hostBridge.requestCloseTab(tab.id, inPane: snapshot.paneId) + self.onTabMutation?() + }, + onZoomToggle: { [weak self] in + guard let self else { return } + _ = hostBridge.togglePaneZoom(inPane: snapshot.paneId) + self.onTabMutation?() + }, + onContextAction: { [weak self] action in + guard let self else { return } + hostBridge.requestTabContextAction(action, for: tab.id, inPane: snapshot.paneId) + self.onTabMutation?() + }, + onBeginTabDrag: { + hostBridge.beginTabDrag(tabId: tab.id, sourcePaneId: snapshot.paneId) + }, + onCancelTabDrag: { + hostBridge.clearDragState() + } + ) + nextButtons.append(button) + } + + let nextIds = Set(nextButtons.map { $0.tab.id }) + for button in tabButtons where !nextIds.contains(button.tab.id) { + button.removeFromSuperview() + } + + tabButtons = nextButtons + documentView.setTabButtons(tabButtons) + documentView.needsLayout = true + documentView.needsDisplay = true + for button in tabButtons { + button.needsLayout = true + button.needsDisplay = true + } + needsLayout = true + needsDisplay = true + if let selected = snapshot.selectedTabId, + let selectedButton = tabButtons.first(where: { $0.tab.id.id == selected }) { + scrollView.contentView.scrollToVisible(selectedButton.frame.insetBy(dx: -32, dy: 0)) + } + } + + func rebuildSplitButtons() { + guard let snapshot, let hostBridge, let presentation else { return } + + splitButtonsView.subviews.forEach { $0.removeFromSuperview() } + guard snapshot.showSplitButtons else { return } + + let appearance = presentation.appearance + let tooltips = appearance.splitButtonTooltips + + splitButtonsView.addArrangedSubview( + workspaceSplitMakeSymbolButton( + symbolName: "terminal", + tooltip: tooltips.newTerminal, + color: TabBarColors.nsColorInactiveText(for: appearance) + ) { [weak self] in + hostBridge.requestNewTab(kind: .terminal, inPane: snapshot.paneId) + self?.onTabMutation?() + } + ) + + splitButtonsView.addArrangedSubview( + workspaceSplitMakeSymbolButton( + symbolName: "globe", + tooltip: tooltips.newBrowser, + color: TabBarColors.nsColorInactiveText(for: appearance) + ) { [weak self] in + hostBridge.requestNewTab(kind: .browser, inPane: snapshot.paneId) + self?.onTabMutation?() + } + ) + + splitButtonsView.addArrangedSubview( + workspaceSplitMakeSymbolButton( + symbolName: "square.split.2x1", + tooltip: tooltips.splitRight, + color: TabBarColors.nsColorInactiveText(for: appearance) + ) { [weak self] in + _ = hostBridge.splitPane(snapshot.paneId, orientation: .horizontal) + self?.onTabMutation?() + } + ) + + splitButtonsView.addArrangedSubview( + workspaceSplitMakeSymbolButton( + symbolName: "square.split.1x2", + tooltip: tooltips.splitDown, + color: TabBarColors.nsColorInactiveText(for: appearance) + ) { [weak self] in + _ = hostBridge.splitPane(snapshot.paneId, orientation: .vertical) + self?.onTabMutation?() + } + ) + } + + func updateSplitButtonsVisibility() { + guard let presentation, let snapshot else { return } + let shouldShow = snapshot.showSplitButtons + && (!presentation.isMinimalMode || isHovering || !presentation.appearance.splitButtonsOnHover) + splitButtonsView.isHidden = !shouldShow + needsLayout = true + } +} + +func workspaceSplitMakeSymbolButton( + symbolName: String, + tooltip: String, + color: NSColor, + action: @escaping () -> Void +) -> NSButton { + let button = NSButton(frame: .zero) + button.bezelStyle = .texturedRounded + button.isBordered = false + button.image = NSImage( + systemSymbolName: symbolName, + accessibilityDescription: tooltip + ) + button.contentTintColor = color + button.toolTip = tooltip + let target = ClosureSleeve(action) + button.target = target + button.action = #selector(ClosureSleeve.invoke) + objc_setAssociatedObject( + button, + &workspaceSplitClosureSleeveAssociationKey, + target, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + return button +} + +var workspaceSplitClosureSleeveAssociationKey: UInt8 = 0 + +final class ClosureSleeve: NSObject { + let action: () -> Void + + init(_ action: @escaping () -> Void) { + self.action = action + } + + @objc func invoke() { + action() + } +} + +@MainActor +final class WorkspaceLayoutTabDocumentView: NSView { + var snapshot: WorkspaceLayoutPaneChromeSnapshot? + var hostBridge: (WorkspaceLayoutInteractionHandlers)? + var presentation: WorkspaceLayoutPresentationSnapshot? + var localTabDrag: WorkspaceLayoutLocalDragSnapshot? + var tabButtons: [WorkspaceLayoutNativeTabButtonView] = [] + let dropIndicatorView = NSView(frame: .zero) + + var preferredContentWidth: CGFloat = 0 + var onRequestRebuild: (() -> Void)? + var onDropPerformed: (() -> Void)? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + registerForDraggedTypes([NSPasteboard.PasteboardType(UTType.tabTransfer.identifier)]) + dropIndicatorView.wantsLayer = true + dropIndicatorView.layer?.backgroundColor = NSColor.controlAccentColor.cgColor + dropIndicatorView.isHidden = true + addSubview(dropIndicatorView) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update( + snapshot: WorkspaceLayoutPaneChromeSnapshot, + hostBridge: WorkspaceLayoutInteractionHandlers, + presentation: WorkspaceLayoutPresentationSnapshot, + localTabDrag: WorkspaceLayoutLocalDragSnapshot? + ) { + self.snapshot = snapshot + self.hostBridge = hostBridge + self.presentation = presentation + self.localTabDrag = localTabDrag + } + + func updateLocalTabDrag(_ localTabDrag: WorkspaceLayoutLocalDragSnapshot?) { + self.localTabDrag = localTabDrag + } + + func setTabButtons(_ buttons: [WorkspaceLayoutNativeTabButtonView]) { + tabButtons.forEach { if !buttons.contains($0) { $0.removeFromSuperview() } } + tabButtons = buttons + for button in buttons where button.superview !== self { + addSubview(button) + } + addSubview(dropIndicatorView) + needsLayout = true + } + + override func layout() { + super.layout() + guard let presentation else { return } + let appearance = presentation.appearance + let leadingInset = appearance.tabBarLeadingInset + var x = leadingInset + + for button in tabButtons { + let width = button.preferredWidth( + minWidth: appearance.tabMinWidth, + maxWidth: appearance.tabMaxWidth + ) + button.frame = CGRect(x: x, y: 0, width: width, height: bounds.height) + x += width + appearance.tabSpacing + } + + preferredContentWidth = max(bounds.width, x + 30) + frame.size = CGSize(width: preferredContentWidth, height: bounds.height) + } + + func targetIndex(for point: NSPoint) -> Int { + for (index, button) in tabButtons.enumerated() { + if point.x < button.frame.midX { + return index + } + } + return tabButtons.count + } + + func effectiveTargetIndex(for point: NSPoint) -> Int? { + guard let snapshot else { return nil } + let rawTargetIndex = targetIndex(for: point) + return workspaceLayoutEffectiveTabDropTargetIndex( + rawTargetIndex: rawTargetIndex, + tabIds: snapshot.tabs.map(\.tab.id), + paneId: snapshot.paneId, + localTabDrag: localTabDrag + ) + } + + func updateDropIndicator(targetIndex: Int?) { + guard let targetIndex else { + dropIndicatorView.isHidden = true + return + } + + let x: CGFloat + if targetIndex >= tabButtons.count { + x = (tabButtons.last?.frame.maxX ?? 0) - 1 + } else { + x = tabButtons[targetIndex].frame.minX - 1 + } + + dropIndicatorView.frame = CGRect( + x: x, + y: max(0, (bounds.height - TabBarMetrics.dropIndicatorHeight) / 2), + width: TabBarMetrics.dropIndicatorWidth, + height: TabBarMetrics.dropIndicatorHeight + ) + dropIndicatorView.layer?.backgroundColor = NSColor.controlAccentColor.cgColor + dropIndicatorView.isHidden = false + } + + func validateSplitTabDrop(_ sender: NSDraggingInfo) -> Bool { + guard let presentation else { return false } + guard presentation.isInteractive else { return false } + if localTabDrag != nil { + return true + } + guard let transfer = workspaceSplitDecodeTransfer(from: sender.draggingPasteboard), + transfer.isFromCurrentProcess else { + return false + } + return sender.draggingPasteboard.availableType(from: [NSPasteboard.PasteboardType(UTType.tabTransfer.identifier)]) != nil + } + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + guard validateSplitTabDrop(sender) else { return [] } + let targetIndex = effectiveTargetIndex(for: convert(sender.draggingLocation, from: nil)) + updateDropIndicator(targetIndex: targetIndex) + return targetIndex == nil ? [] : .move + } + + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + guard validateSplitTabDrop(sender) else { return [] } + let targetIndex = effectiveTargetIndex(for: convert(sender.draggingLocation, from: nil)) + updateDropIndicator(targetIndex: targetIndex) + return targetIndex == nil ? [] : .move + } + + override func draggingExited(_ sender: NSDraggingInfo?) { + updateDropIndicator(targetIndex: nil) + } + + override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { + guard validateSplitTabDrop(sender) else { return false } + return effectiveTargetIndex(for: convert(sender.draggingLocation, from: nil)) != nil + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + guard let snapshot, let hostBridge else { return false } + guard let destinationIndex = effectiveTargetIndex(for: convert(sender.draggingLocation, from: nil)) else { + updateDropIndicator(targetIndex: nil) + return false + } + + if let localDrag = localTabDrag { + let draggedTabId = localDrag.tabId + let sourcePaneId = localDrag.sourcePaneId + if sourcePaneId == snapshot.paneId { + _ = hostBridge.moveTab( + draggedTabId, + toPane: snapshot.paneId, + atIndex: destinationIndex + ) + _ = hostBridge.focusPane(snapshot.paneId) + } else { + _ = hostBridge.moveTab( + draggedTabId, + toPane: snapshot.paneId, + atIndex: destinationIndex + ) + } + hostBridge.clearDragState() + updateDropIndicator(targetIndex: nil) + onDropPerformed?() + return true + } + + guard let transfer = workspaceSplitDecodeTransfer(from: sender.draggingPasteboard), + transfer.isFromCurrentProcess else { + updateDropIndicator(targetIndex: nil) + return false + } + + let request = WorkspaceLayoutExternalTabDropRequest( + tabId: transfer.tabId, + sourcePaneId: PaneID(id: transfer.sourcePaneId), + destination: .insert(targetPane: snapshot.paneId, targetIndex: destinationIndex) + ) + let handled = hostBridge.handleExternalTabDrop(request) + updateDropIndicator(targetIndex: nil) + if handled { + onDropPerformed?() + } + return handled + } +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNativeTabButtonView.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNativeTabButtonView.swift new file mode 100644 index 000000000000..dc0c0df1d1be --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNativeTabButtonView.swift @@ -0,0 +1,733 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +final class WorkspaceLayoutNativeTabButtonView: NSView, NSDraggingSource { + private(set) var tab: WorkspaceLayout.Tab = WorkspaceLayout.Tab(title: "") + var paneId: PaneID = PaneID() + var isSelected: Bool = false + var isPaneFocused: Bool = false + var showsZoomIndicator: Bool = false + var controlShortcutDigit: Int? + var showsControlShortcutHint = false + var shortcutModifierSymbol = "⌃" + var splitAppearance: WorkspaceLayoutConfiguration.Appearance = .default + var contextMenuState = TabContextMenuState( + isPinned: false, + isUnread: false, + isBrowser: false, + isTerminal: false, + hasCustomTitle: false, + canCloseToLeft: false, + canCloseToRight: false, + canCloseOthers: false, + canMoveToLeftPane: false, + canMoveToRightPane: false, + isZoomed: false, + hasSplits: false, + shortcuts: [:] + ) + var onSelect: (() -> Void)? + var onClose: (() -> Void)? + var onZoomToggle: (() -> Void)? + var onContextAction: ((TabContextAction) -> Void)? + var onBeginTabDrag: (() -> Void)? + var onCancelTabDrag: (() -> Void)? + + let iconView = NSImageView(frame: .zero) + let titleLabel = NSTextField(frame: .zero) + let closeButton = WorkspaceLayoutHoverButton(frame: .zero) + let zoomButton = WorkspaceLayoutHoverButton(frame: .zero) + let pinView = NSImageView(frame: .zero) + let dirtyDot = NSView(frame: .zero) + let unreadDot = NSView(frame: .zero) + let spinner = NSProgressIndicator(frame: .zero) + let shortcutHintView = WorkspaceLayoutShortcutHintPillView(frame: .zero) + var trackingArea: NSTrackingArea? + var isHovered = false + var isCloseHovered = false + var isZoomHovered = false + var isClosePressed = false + var isZoomPressed = false + var dragStartLocation: NSPoint? + var dragStarted = false + var spinnerTimer: Timer? + var debugFixedSpinnerPhase: CGFloat? + var iconUsesTemplateSymbol = false + + var usesSubviewChrome: Bool { + WorkspaceLayoutTabChromeContentRenderer.current == .appKitSubviews + } + + var usesSubviewTitleLabel: Bool { + switch WorkspaceLayoutTabChromeTitleSource.current { + case .label: + return true + case .draw: + return false + case .auto: + return usesSubviewChrome && WorkspaceLayoutTabChromeSubviewTitleRenderer.current == .label + } + } + + var titleFontSize: CGFloat { +#if DEBUG + splitAppearance.tabTitleFontSize + WorkspaceLayoutTabChromeDebugTuning.current.titlePointSizeDelta +#else + splitAppearance.tabTitleFontSize +#endif + } + + var accessoryFontSize: CGFloat { + max(8, splitAppearance.tabTitleFontSize - 2) + } + + var accessorySlotSize: CGFloat { + min(TabBarMetrics.tabHeight, max(TabBarMetrics.closeButtonSize, ceil(accessoryFontSize + 4))) + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.masksToBounds = true + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDebugSettingsChanged), + name: WorkspaceLayoutNotifications.tabChromeDebugSettingsDidChange, + object: nil + ) + + titleLabel.cell = WorkspaceLayoutZeroPaddingTextFieldCell(textCell: "") + titleLabel.font = .systemFont(ofSize: splitAppearance.tabTitleFontSize) + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.isBordered = false + titleLabel.backgroundColor = .clear + titleLabel.alphaValue = 0 + titleLabel.isBezeled = false + titleLabel.isEditable = false + titleLabel.isSelectable = false + titleLabel.usesSingleLineMode = true + addSubview(titleLabel) + + iconView.imageScaling = .scaleProportionallyDown + iconView.imageAlignment = .alignCenter + iconView.alphaValue = 0 + addSubview(iconView) + + closeButton.drawsCloseGlyph = false + closeButton.rendersVisuals = false + closeButton.target = self + closeButton.action = #selector(handleCloseButton) + closeButton.layer?.cornerRadius = TabBarMetrics.closeButtonSize / 2 + closeButton.layer?.masksToBounds = true + closeButton.onHoverChanged = { [weak self] hovering in + guard let self else { return } + self.isCloseHovered = hovering + self.refreshChrome() + } + closeButton.onPressedChanged = { [weak self] pressed in + guard let self else { return } + self.isClosePressed = pressed + self.refreshChrome() + } + addSubview(closeButton) + + zoomButton.iconImage = NSImage( + systemSymbolName: "arrow.up.left.and.arrow.down.right", + accessibilityDescription: String( + localized: "workspace.menu.exitZoomPane", + defaultValue: "Exit Zoom" + ) + ) + zoomButton.iconSize = TabBarMetrics.closeIconSize + zoomButton.rendersVisuals = false + zoomButton.target = self + zoomButton.action = #selector(handleZoomButton) + zoomButton.layer?.cornerRadius = TabBarMetrics.closeButtonSize / 2 + zoomButton.layer?.masksToBounds = true + zoomButton.onHoverChanged = { [weak self] hovering in + guard let self else { return } + self.isZoomHovered = hovering + self.refreshChrome() + } + zoomButton.onPressedChanged = { [weak self] pressed in + guard let self else { return } + self.isZoomPressed = pressed + self.refreshChrome() + } + addSubview(zoomButton) + + pinView.image = NSImage( + systemSymbolName: "pin.fill", + accessibilityDescription: String( + localized: "workspace.accessibility.pinnedTab", + defaultValue: "Pinned Tab" + ) + ) + pinView.imageScaling = .scaleProportionallyDown + pinView.alphaValue = 0 + addSubview(pinView) + + dirtyDot.wantsLayer = true + dirtyDot.layer?.cornerRadius = TabBarMetrics.dirtyIndicatorSize / 2 + dirtyDot.alphaValue = 0 + addSubview(dirtyDot) + + unreadDot.wantsLayer = true + unreadDot.layer?.cornerRadius = TabBarMetrics.notificationBadgeSize / 2 + unreadDot.alphaValue = 0 + addSubview(unreadDot) + + spinner.style = .spinning + spinner.controlSize = .small + spinner.isDisplayedWhenStopped = false + spinner.alphaValue = 0 + addSubview(spinner) + + shortcutHintView.alphaValue = 0 + shortcutHintView.isHidden = true + addSubview(shortcutHintView) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + NotificationCenter.default.removeObserver(self) + spinnerTimer?.invalidate() + } + + @objc private func handleDebugSettingsChanged() { + if superview != nil { + superview?.needsLayout = true + superview?.needsDisplay = true + } + needsLayout = true + refreshChrome() + } + + override func updateTrackingAreas() { + if let trackingArea { + removeTrackingArea(trackingArea) + } + let next = NSTrackingArea( + rect: .zero, + options: [.inVisibleRect, .activeAlways, .mouseEnteredAndExited], + owner: self, + userInfo: nil + ) + addTrackingArea(next) + trackingArea = next + super.updateTrackingAreas() + } + + func update( + tab: WorkspaceLayout.Tab, + paneId: PaneID, + isSelected: Bool, + isPaneFocused: Bool, + showsZoomIndicator: Bool, + controlShortcutDigit: Int?, + showsControlShortcutHint: Bool, + shortcutModifierSymbol: String, + appearance: WorkspaceLayoutConfiguration.Appearance, + contextMenuState: TabContextMenuState, + onSelect: @escaping () -> Void, + onClose: @escaping () -> Void, + onZoomToggle: @escaping () -> Void, + onContextAction: @escaping (TabContextAction) -> Void, + onBeginTabDrag: @escaping () -> Void, + onCancelTabDrag: @escaping () -> Void + ) { + self.tab = tab + self.paneId = paneId + self.isSelected = isSelected + self.isPaneFocused = isPaneFocused + self.showsZoomIndicator = showsZoomIndicator + self.controlShortcutDigit = controlShortcutDigit + self.showsControlShortcutHint = showsControlShortcutHint + self.shortcutModifierSymbol = shortcutModifierSymbol + self.splitAppearance = appearance + self.contextMenuState = contextMenuState + self.onSelect = onSelect + self.onClose = onClose + self.onZoomToggle = onZoomToggle + self.onContextAction = onContextAction + self.onBeginTabDrag = onBeginTabDrag + self.onCancelTabDrag = onCancelTabDrag + +#if DEBUG + let tuning = WorkspaceLayoutTabChromeDebugTuning.current +#else + let tuning = (titlePointSizeDelta: CGFloat(0), titleKern: CGFloat(0), iconPointSizeDelta: CGFloat(-0.5)) +#endif + + let titleFont = NSFont.systemFont(ofSize: titleFontSize) + let titleColor = isSelected + ? TabBarColors.nsColorActiveText(for: splitAppearance) + : TabBarColors.nsColorInactiveText(for: splitAppearance) + titleLabel.stringValue = tab.title + titleLabel.font = titleFont + titleLabel.textColor = titleColor + + if let imageData = tab.iconImageData, + let image = NSImage(data: imageData) { + image.isTemplate = false + iconView.image = image + iconView.contentTintColor = nil + iconView.imageScaling = .scaleProportionallyDown + iconUsesTemplateSymbol = false + } else if let icon = tab.icon { + iconView.image = workspaceSplitTemplateSymbolImage( + named: icon, + pointSize: symbolPointSize(for: icon) + tuning.iconPointSizeDelta, + weight: .regular, + fitting: CGSize(width: TabBarMetrics.iconSize, height: TabBarMetrics.iconSize) + ) + iconView.contentTintColor = isSelected + ? TabBarColors.nsColorActiveText(for: splitAppearance) + : TabBarColors.nsColorInactiveText(for: splitAppearance) + iconView.imageScaling = usesSubviewChrome ? .scaleNone : .scaleProportionallyDown + iconUsesTemplateSymbol = true + } else { + iconView.image = nil + iconUsesTemplateSymbol = false + } + + let nextShowsShortcutHint = showsShortcutHint + closeButton.isHidden = nextShowsShortcutHint || tab.isPinned || !(isSelected || isHovered || isCloseHovered) + if closeButton.isHidden { + isCloseHovered = false + isClosePressed = false + } + pinView.isHidden = nextShowsShortcutHint || !tab.isPinned || closeButton.isHidden == false + zoomButton.isHidden = !showsZoomIndicator + if zoomButton.isHidden { + isZoomHovered = false + isZoomPressed = false + } + + unreadDot.isHidden = nextShowsShortcutHint || isSelected || isHovered || isCloseHovered || !tab.showsNotificationBadge + dirtyDot.isHidden = nextShowsShortcutHint || isSelected || isHovered || isCloseHovered || !tab.isDirty + unreadDot.layer?.backgroundColor = NSColor.systemBlue.cgColor + dirtyDot.layer?.backgroundColor = TabBarColors.nsColorActiveText(for: splitAppearance).withAlphaComponent(0.72).cgColor + + if closeButton.rendersVisuals { + closeButton.iconTintColor = (isCloseHovered || isClosePressed) + ? TabBarColors.nsColorActiveText(for: splitAppearance) + : TabBarColors.nsColorInactiveText(for: splitAppearance) + closeButton.layer?.backgroundColor = closeButtonBackgroundColor().cgColor + } else { + closeButton.layer?.backgroundColor = NSColor.clear.cgColor + } + if zoomButton.rendersVisuals { + zoomButton.iconTintColor = (isZoomHovered || isZoomPressed) + ? TabBarColors.nsColorActiveText(for: splitAppearance) + : TabBarColors.nsColorInactiveText(for: splitAppearance) + zoomButton.layer?.backgroundColor = zoomButtonBackgroundColor().cgColor + } else { + zoomButton.layer?.backgroundColor = NSColor.clear.cgColor + } + pinView.contentTintColor = TabBarColors.nsColorInactiveText(for: splitAppearance) + titleLabel.alphaValue = usesSubviewTitleLabel ? 1 : 0 + iconView.alphaValue = usesSubviewChrome && !tab.isLoading && iconView.image != nil ? 1 : 0 + pinView.alphaValue = 0 + spinner.alphaValue = 0 + shortcutHintView.applyAppearance() + if let shortcutHintLabel { + shortcutHintView.update( + text: shortcutHintLabel, + fontSize: accessoryFontSize, + textColor: isSelected + ? TabBarColors.nsColorActiveText(for: splitAppearance) + : TabBarColors.nsColorInactiveText(for: splitAppearance) + ) + } + if nextShowsShortcutHint != !shortcutHintView.isHidden { + shortcutHintView.layer?.removeAllAnimations() + shortcutHintView.isHidden = false + let shouldRemainVisible = nextShowsShortcutHint + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.14 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + shortcutHintView.animator().alphaValue = nextShowsShortcutHint ? 1 : 0 + } completionHandler: { [weak self] in + guard let self else { return } + if !shouldRemainVisible { + self.shortcutHintView.isHidden = true + } + } + } else { + shortcutHintView.alphaValue = nextShowsShortcutHint ? 1 : 0 + shortcutHintView.isHidden = !nextShowsShortcutHint + } + syncSpinnerAnimation() + + needsLayout = true + needsDisplay = true + } + + func preferredWidth(minWidth: CGFloat, maxWidth: CGFloat) -> CGFloat { + let titleAttributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: titleFontSize) + ] + let titleWidth = ceil((tab.title as NSString).size(withAttributes: titleAttributes).width) + let trailingAccessoryWidth: CGFloat = showsZoomIndicator + ? (accessorySlotSize + trailingAccessorySlotWidth) + : trailingAccessorySlotWidth + let titleToAccessorySpacing: CGFloat = showsZoomIndicator ? TabBarMetrics.contentSpacing : 0 + let chromeWidth = + (TabBarMetrics.tabHorizontalPadding * 2) + + TabBarMetrics.iconSize + + TabBarMetrics.contentSpacing + + trailingAccessoryWidth + + titleToAccessorySpacing + return min(maxWidth, max(minWidth, titleWidth + chromeWidth)) + } + + override func layout() { + super.layout() + let contentX = TabBarMetrics.tabHorizontalPadding + let centerY = bounds.midY +#if DEBUG + let tuning = WorkspaceLayoutTabChromeDebugTuning.current +#else + let tuning = (titleDX: CGFloat(1), titleDY: CGFloat(0.375), iconDX: CGFloat(-1), iconDY: CGFloat(-0.875)) +#endif + + let iconSlotRect = CGRect( + x: contentX, + y: centerY - (TabBarMetrics.iconSize / 2), + width: TabBarMetrics.iconSize, + height: TabBarMetrics.iconSize + ) + if usesSubviewChrome, iconUsesTemplateSymbol, let image = iconView.image { + iconView.frame = CGRect( + x: round(iconSlotRect.midX - (image.size.width / 2) + tuning.iconDX), + y: round(iconSlotRect.midY - (image.size.height / 2) + tuning.iconDY), + width: image.size.width, + height: image.size.height + ) + } else { + iconView.frame = iconSlotRect.offsetBy(dx: tuning.iconDX, dy: tuning.iconDY) + } + spinner.frame = CGRect( + x: contentX, + y: centerY - (TabBarMetrics.iconSize / 2), + width: TabBarMetrics.iconSize, + height: TabBarMetrics.iconSize + ) + + closeButton.frame = CGRect( + x: bounds.maxX - TabBarMetrics.tabHorizontalPadding - trailingAccessorySlotWidth + ((trailingAccessorySlotWidth - accessorySlotSize) / 2), + y: centerY - (accessorySlotSize / 2), + width: accessorySlotSize, + height: accessorySlotSize + ) + closeButton.layer?.cornerRadius = closeButton.bounds.height / 2 + pinView.frame = closeButton.frame + let shortcutHintSlotFrame = CGRect( + x: bounds.maxX - TabBarMetrics.tabHorizontalPadding - trailingAccessorySlotWidth, + y: centerY - (accessorySlotSize / 2), + width: trailingAccessorySlotWidth, + height: accessorySlotSize + ) + shortcutHintView.frame = shortcutHintSlotFrame.offsetBy(dx: paneShortcutHintXOffset, dy: paneShortcutHintYOffset) + + if showsZoomIndicator { + zoomButton.frame = CGRect( + x: shortcutHintSlotFrame.minX - accessorySlotSize, + y: centerY - (accessorySlotSize / 2), + width: accessorySlotSize, + height: accessorySlotSize + ) + zoomButton.layer?.cornerRadius = zoomButton.bounds.height / 2 + } else { + zoomButton.frame = .zero + } + + let trailingAccessoryMinX = showsZoomIndicator ? zoomButton.frame.minX : shortcutHintSlotFrame.minX + let titleMinX = iconSlotRect.maxX + TabBarMetrics.contentSpacing + let titleMaxX = trailingAccessoryMinX - (showsZoomIndicator ? TabBarMetrics.contentSpacing : 0) + let titleFrameMinX = titleMinX + tuning.titleDX + tuning.iconDX + titleLabel.frame = CGRect( + x: titleFrameMinX, + y: centerY - 7 + tuning.titleDY, + width: max(0, titleMaxX - titleFrameMinX), + height: 14 + ) + let indicatorCenterX = closeButton.frame.midX + let indicatorStartX = indicatorCenterX - (TabBarMetrics.notificationBadgeSize / 2) + unreadDot.frame = CGRect( + x: indicatorStartX, + y: centerY - (TabBarMetrics.notificationBadgeSize / 2), + width: TabBarMetrics.notificationBadgeSize, + height: TabBarMetrics.notificationBadgeSize + ) + dirtyDot.frame = CGRect( + x: unreadDot.isHidden + ? indicatorStartX + : unreadDot.frame.maxX + 2, + y: centerY - (TabBarMetrics.dirtyIndicatorSize / 2), + width: TabBarMetrics.dirtyIndicatorSize, + height: TabBarMetrics.dirtyIndicatorSize + ) + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + let background: NSColor + if isSelected { + background = TabBarColors.nsColorPaneBackground(for: splitAppearance) + } else if isHovered { + background = workspaceSplitHoveredTabBackground(for: splitAppearance) + } else { + background = .clear + } + + background.setFill() + dirtyRect.fill() + + if isSelected { + TabBarColors.nsColorSelectedIndicator( + for: splitAppearance, + focused: isPaneFocused + ).setFill() + CGRect( + x: 0, + y: bounds.height - TabBarMetrics.activeIndicatorHeight, + width: max(0, bounds.width - 1), + height: TabBarMetrics.activeIndicatorHeight + ).fill() + } + + TabBarColors.nsColorSeparator(for: splitAppearance).setFill() + CGRect(x: bounds.width - 1, y: 0, width: 1, height: bounds.height).fill() + + drawTabChromeContent() + } + + override func mouseEntered(with event: NSEvent) { + isHovered = true + needsDisplay = true + refreshChrome() + } + + override func mouseExited(with event: NSEvent) { + isHovered = false + isCloseHovered = false + isZoomHovered = false + isClosePressed = false + isZoomPressed = false + needsDisplay = true + refreshChrome() + } + + override func mouseDown(with event: NSEvent) { + dragStartLocation = convert(event.locationInWindow, from: nil) + dragStarted = false + } + + override func mouseDragged(with event: NSEvent) { + guard let dragStartLocation, + !dragStarted, + let onBeginTabDrag else { return } + + let point = convert(event.locationInWindow, from: nil) + let distance = hypot(point.x - dragStartLocation.x, point.y - dragStartLocation.y) + guard distance >= 3 else { return } + dragStarted = true + + let transferTabId = tab.id + onBeginTabDrag() + + let pasteboardItem = NSPasteboardItem() + if let data = try? JSONEncoder().encode( + TabTransferData(tabId: transferTabId, sourcePaneId: paneId.id) + ) { + pasteboardItem.setData(data, forType: NSPasteboard.PasteboardType(UTType.tabTransfer.identifier)) + } + + let draggingItem = NSDraggingItem(pasteboardWriter: pasteboardItem) + let image = workspaceSplitSnapshotImage(for: self) ?? NSImage(size: bounds.size) + draggingItem.setDraggingFrame(bounds, contents: image) + let session = beginDraggingSession(with: [draggingItem], event: event, source: self) + session.animatesToStartingPositionsOnCancelOrFail = false + } + + override func mouseUp(with event: NSEvent) { + defer { + dragStartLocation = nil + dragStarted = false + } + guard !dragStarted else { return } + onSelect?() + } + + override func menu(for event: NSEvent) -> NSMenu? { + let menu = NSMenu() + + workspaceSplitAddMenuItem( + String(localized: "command.renameTab.title", defaultValue: "Rename Tab…"), + action: .rename, + to: menu, + handler: onContextAction + ) + + if contextMenuState.hasCustomTitle { + workspaceSplitAddMenuItem( + String(localized: "workspace.menu.removeCustomTabName", defaultValue: "Remove Custom Tab Name"), + action: .clearName, + to: menu, + handler: onContextAction + ) + } + + menu.addItem(.separator()) + workspaceSplitAddMenuItem( + String(localized: "workspace.menu.closeTabsToLeft", defaultValue: "Close Tabs to Left"), + action: .closeToLeft, + to: menu, + enabled: contextMenuState.canCloseToLeft, + handler: onContextAction + ) + workspaceSplitAddMenuItem( + String(localized: "workspace.menu.closeTabsToRight", defaultValue: "Close Tabs to Right"), + action: .closeToRight, + to: menu, + enabled: contextMenuState.canCloseToRight, + handler: onContextAction + ) + workspaceSplitAddMenuItem( + String(localized: "menu.file.closeOtherTabs", defaultValue: "Close Other Tabs in Pane"), + action: .closeOthers, + to: menu, + enabled: contextMenuState.canCloseOthers, + handler: onContextAction + ) + workspaceSplitAddMenuItem( + String(localized: "dialog.moveTab.title", defaultValue: "Move Tab"), + action: .move, + to: menu, + handler: onContextAction + ) + + if contextMenuState.isTerminal { + workspaceSplitAddMenuItem( + String(localized: "workspace.menu.moveToLeftPane", defaultValue: "Move to Left Pane"), + action: .moveToLeftPane, + to: menu, + enabled: contextMenuState.canMoveToLeftPane, + handler: onContextAction + ) + workspaceSplitAddMenuItem( + String(localized: "workspace.menu.moveToRightPane", defaultValue: "Move to Right Pane"), + action: .moveToRightPane, + to: menu, + enabled: contextMenuState.canMoveToRightPane, + handler: onContextAction + ) + } + + menu.addItem(.separator()) + workspaceSplitAddMenuItem( + String(localized: "workspace.menu.newTerminalTabToRight", defaultValue: "New Terminal Tab to Right"), + action: .newTerminalToRight, + to: menu, + handler: onContextAction + ) + workspaceSplitAddMenuItem( + String(localized: "workspace.menu.newBrowserTabToRight", defaultValue: "New Browser Tab to Right"), + action: .newBrowserToRight, + to: menu, + handler: onContextAction + ) + + if contextMenuState.isBrowser { + menu.addItem(.separator()) + workspaceSplitAddMenuItem( + String(localized: "workspace.menu.reloadTab", defaultValue: "Reload Tab"), + action: .reload, + to: menu, + handler: onContextAction + ) + workspaceSplitAddMenuItem( + String(localized: "workspace.menu.duplicateTab", defaultValue: "Duplicate Tab"), + action: .duplicate, + to: menu, + handler: onContextAction + ) + } + + menu.addItem(.separator()) + + if contextMenuState.hasSplits { + workspaceSplitAddMenuItem( + contextMenuState.isZoomed + ? String(localized: "workspace.menu.exitZoomPane", defaultValue: "Exit Zoom") + : String(localized: "workspace.menu.zoomPane", defaultValue: "Zoom Pane"), + action: .toggleZoom, + to: menu, + handler: onContextAction + ) + } + + workspaceSplitAddMenuItem( + contextMenuState.isPinned + ? String(localized: "command.unpinTab.title", defaultValue: "Unpin Tab") + : String(localized: "command.pinTab.title", defaultValue: "Pin Tab"), + action: .togglePin, + to: menu, + handler: onContextAction + ) + + if contextMenuState.isUnread { + workspaceSplitAddMenuItem( + String(localized: "command.markTabRead.title", defaultValue: "Mark Tab as Read"), + action: .markAsRead, + to: menu, + enabled: contextMenuState.canMarkAsRead, + handler: onContextAction + ) + } else { + workspaceSplitAddMenuItem( + String(localized: "command.markTabUnread.title", defaultValue: "Mark Tab as Unread"), + action: .markAsUnread, + to: menu, + enabled: contextMenuState.canMarkAsUnread, + handler: onContextAction + ) + } + + menu.addItem(.separator()) + workspaceSplitAddMenuItem( + String(localized: "command.copyIdentifiers.title", defaultValue: "Copy IDs"), + action: .copyIdentifiers, + to: menu, + handler: onContextAction + ) + + return menu + } + + @objc private func handleCloseButton() { + onClose?() + } + + @objc private func handleZoomButton() { + onZoomToggle?() + } + + func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { + .move + } + + func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { + if operation == [] { + onCancelTabDrag?() + } + } + +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNativeTabButtonViewDrawing.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNativeTabButtonViewDrawing.swift new file mode 100644 index 000000000000..29fbe8c1acce --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNativeTabButtonViewDrawing.swift @@ -0,0 +1,368 @@ +import AppKit + +extension WorkspaceLayoutNativeTabButtonView { + func refreshChrome() { + guard let onSelect, + let onClose, + let onZoomToggle, + let onContextAction, + let onBeginTabDrag, + let onCancelTabDrag else { return } + update( + tab: tab, + paneId: paneId, + isSelected: isSelected, + isPaneFocused: isPaneFocused, + showsZoomIndicator: showsZoomIndicator, + controlShortcutDigit: controlShortcutDigit, + showsControlShortcutHint: showsControlShortcutHint, + shortcutModifierSymbol: shortcutModifierSymbol, + appearance: splitAppearance, + contextMenuState: contextMenuState, + onSelect: onSelect, + onClose: onClose, + onZoomToggle: onZoomToggle, + onContextAction: onContextAction, + onBeginTabDrag: onBeginTabDrag, + onCancelTabDrag: onCancelTabDrag + ) + } + + func syncSpinnerAnimation() { + let shouldAnimate = tab.isLoading && debugFixedSpinnerPhase == nil + if shouldAnimate { + guard spinnerTimer == nil else { return } + let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in + self?.needsDisplay = true + } + RunLoop.main.add(timer, forMode: .common) + spinnerTimer = timer + } else { + spinnerTimer?.invalidate() + spinnerTimer = nil + } + } + + func drawTabChromeContent() { + if !usesSubviewChrome { + drawIconContent() + if !usesSubviewTitleLabel { + drawTitle() + } + } else { + let tint = isSelected + ? TabBarColors.nsColorActiveText(for: splitAppearance) + : TabBarColors.nsColorInactiveText(for: splitAppearance) + if tab.isLoading { + drawLoadingSpinner(in: spinner.frame, color: tint) + } + if !usesSubviewTitleLabel { + drawTitle() + } + } + if showsZoomIndicator { + drawAccessoryButton( + in: zoomButton.frame, + symbolName: "arrow.up.left.and.arrow.down.right", + pointSize: TabBarMetrics.closeIconSize, + isHovered: isZoomHovered, + isPressed: isZoomPressed + ) + } + drawTrailingAccessory() + } + + func drawIconContent() { +#if DEBUG + let iconPointSizeDelta = WorkspaceLayoutTabChromeDebugTuning.current.iconPointSizeDelta +#else + let iconPointSizeDelta = CGFloat(-0.5) +#endif + let tint = isSelected + ? TabBarColors.nsColorActiveText(for: splitAppearance) + : TabBarColors.nsColorInactiveText(for: splitAppearance) + + if tab.isLoading { + drawLoadingSpinner(in: iconView.frame, color: tint) + return + } + + if let imageData = tab.iconImageData, + let image = iconView.image ?? NSImage(data: imageData) { + drawRasterIcon(image, in: iconView.frame) + return + } + + guard let iconName = tab.icon else { return } + workspaceSplitDrawSymbol( + named: iconName, + pointSize: symbolPointSize(for: iconName) + iconPointSizeDelta, + weight: .regular, + color: tint, + in: iconView.frame + ) + } + + func drawTitle() { + guard !titleLabel.frame.isEmpty else { return } +#if DEBUG + let tuning = WorkspaceLayoutTabChromeDebugTuning.current +#else + let tuning = (titlePointSizeDelta: CGFloat(0), titleKern: CGFloat(0)) +#endif + let rect = titleLabel.frame + let font = NSFont.systemFont(ofSize: titleFontSize) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineBreakMode = .byTruncatingTail + var attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: isSelected + ? TabBarColors.nsColorActiveText(for: splitAppearance) + : TabBarColors.nsColorInactiveText(for: splitAppearance), + .paragraphStyle: paragraphStyle + ] + if abs(tuning.titleKern) > 0.0001 { + attributes[.kern] = tuning.titleKern + } +#if DEBUG + switch WorkspaceLayoutTabChromeTitleRenderer.current { + case .stringDraw: + (tab.title as NSString).draw( + with: rect, + options: WorkspaceLayoutTabChromeTitleDrawMode.selected.options, + attributes: attributes + ) + case .textKit: + drawTitleWithTextKit(in: rect, attributes: attributes) + } +#else + (tab.title as NSString).draw( + with: rect, + options: [.usesLineFragmentOrigin, .usesFontLeading, .truncatesLastVisibleLine], + attributes: attributes + ) +#endif + } + + func drawTitleWithTextKit(in rect: CGRect, attributes: [NSAttributedString.Key: Any]) { + let storage = NSTextStorage(string: tab.title, attributes: attributes) + let layoutManager = NSLayoutManager() + layoutManager.usesFontLeading = true + let container = NSTextContainer(size: rect.size) + container.maximumNumberOfLines = 1 + container.lineBreakMode = .byTruncatingTail + container.lineFragmentPadding = 0 + layoutManager.addTextContainer(container) + storage.addLayoutManager(layoutManager) + + let glyphRange = layoutManager.glyphRange(for: container) + let usedRect = layoutManager.usedRect(for: container) + let drawOrigin = CGPoint( + x: rect.minX, + y: rect.minY + floor((rect.height - usedRect.height) / 2) + ) + layoutManager.drawGlyphs(forGlyphRange: glyphRange, at: drawOrigin) + } + + func drawTrailingAccessory() { + if showsShortcutHint { + return + } + + if shouldShowIndicators { + if !unreadDot.isHidden { + NSColor.systemBlue.setFill() + NSBezierPath(ovalIn: unreadDot.frame).fill() + } + if !dirtyDot.isHidden { + TabBarColors.nsColorActiveText(for: splitAppearance) + .withAlphaComponent(0.72) + .setFill() + NSBezierPath(ovalIn: dirtyDot.frame).fill() + } + return + } + + if tab.isPinned { + guard !pinView.isHidden else { return } + workspaceSplitDrawSymbol( + named: "pin.fill", + pointSize: TabBarMetrics.closeIconSize, + weight: .semibold, + color: TabBarColors.nsColorInactiveText(for: splitAppearance), + in: pinView.frame + ) + return + } + + guard !closeButton.isHidden else { return } + drawAccessoryButton( + in: closeButton.frame, + symbolName: "xmark", + pointSize: TabBarMetrics.closeIconSize, + isHovered: isCloseHovered, + isPressed: isClosePressed + ) + } + + var shouldShowIndicators: Bool { + (!isSelected && !isHovered && !isCloseHovered) && (tab.isDirty || tab.showsNotificationBadge) + } + + var shortcutHintLabel: String? { + guard let controlShortcutDigit else { return nil } + return "\(shortcutModifierSymbol)\(controlShortcutDigit)" + } + + var showsShortcutHint: Bool { + (showsControlShortcutHint || alwaysShowShortcutHints) && shortcutHintLabel != nil + } + + var alwaysShowShortcutHints: Bool { + let defaults = UserDefaults.standard + guard defaults.object(forKey: WorkspaceLayoutShortcutHintSettings.alwaysShowHintsKey) != nil else { + return WorkspaceLayoutShortcutHintSettings.defaultAlwaysShowHints + } + return defaults.bool(forKey: WorkspaceLayoutShortcutHintSettings.alwaysShowHintsKey) + } + + var paneShortcutHintXOffset: CGFloat { + let defaults = UserDefaults.standard + let value: Double + if defaults.object(forKey: WorkspaceLayoutShortcutHintSettings.paneHintXKey) != nil { + value = defaults.double(forKey: WorkspaceLayoutShortcutHintSettings.paneHintXKey) + } else { + value = WorkspaceLayoutShortcutHintSettings.defaultPaneHintX + } + return CGFloat(WorkspaceLayoutShortcutHintSettings.clamped(value)) + } + + var paneShortcutHintYOffset: CGFloat { + let defaults = UserDefaults.standard + let value: Double + if defaults.object(forKey: WorkspaceLayoutShortcutHintSettings.paneHintYKey) != nil { + value = defaults.double(forKey: WorkspaceLayoutShortcutHintSettings.paneHintYKey) + } else { + value = WorkspaceLayoutShortcutHintSettings.defaultPaneHintY + } + return CGFloat(WorkspaceLayoutShortcutHintSettings.clamped(value)) + } + + var trailingAccessorySlotWidth: CGFloat { + guard let label = shortcutHintLabel else { + return accessorySlotSize + } + let font = shortcutHintFont() + let textWidth = ceil((label as NSString).size(withAttributes: [.font: font]).width) + let positiveDebugInset = max(0, paneShortcutHintXOffset) + 2 + return max(accessorySlotSize, textWidth + 8 + positiveDebugInset) + } + + func shortcutHintFont() -> NSFont { + NSFont.systemFont(ofSize: accessoryFontSize, weight: .semibold) + } + + func drawAccessoryButton( + in rect: CGRect, + symbolName: String, + pointSize: CGFloat, + isHovered: Bool, + isPressed: Bool + ) { + workspaceSplitDrawAccessoryButton( + in: rect, + symbolName: symbolName, + pointSize: pointSize, + isHovered: isHovered, + isPressed: isPressed, + appearance: splitAppearance + ) + } + + func drawRasterIcon(_ image: NSImage, in rect: CGRect) { + image.draw( + in: rect, + from: .zero, + operation: .sourceOver, + fraction: 1, + respectFlipped: true, + hints: [.interpolation: NSImageInterpolation.high] + ) + } + + func drawLoadingSpinner(in rect: CGRect, color: NSColor) { + let size = TabBarMetrics.iconSize * 0.86 + let spinnerRect = CGRect( + x: rect.midX - (size / 2), + y: rect.midY - (size / 2), + width: size, + height: size + ) + let lineWidth = max(1.6, size * 0.14) + let insetRect = spinnerRect.insetBy(dx: lineWidth / 2, dy: lineWidth / 2) + let phase = debugFixedSpinnerPhase ?? CGFloat( + (Date().timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 0.9) / 0.9) * 360.0 + ) + + let ringPath = NSBezierPath(ovalIn: insetRect) + ringPath.lineWidth = lineWidth + color.withAlphaComponent(0.20).setStroke() + ringPath.stroke() + + let arcPath = NSBezierPath() + arcPath.appendArc( + withCenter: CGPoint(x: insetRect.midX, y: insetRect.midY), + radius: insetRect.width / 2, + startAngle: phase, + endAngle: phase + (360.0 * 0.28), + clockwise: false + ) + arcPath.lineWidth = lineWidth + arcPath.lineCapStyle = .round + color.setStroke() + arcPath.stroke() + } + + func symbolPointSize(for iconName: String) -> CGFloat { + if iconName == "terminal.fill" || iconName == "terminal" || iconName == "globe" { + return max(10, TabBarMetrics.iconSize - 2.5) + } + return TabBarMetrics.iconSize + } + + func configureDebugInteractionState( + isHovered: Bool, + isCloseHovered: Bool, + isClosePressed: Bool, + isZoomHovered: Bool, + isZoomPressed: Bool + ) { + self.isHovered = isHovered + self.isCloseHovered = isCloseHovered + self.isClosePressed = isClosePressed + self.isZoomHovered = isZoomHovered + self.isZoomPressed = isZoomPressed + debugFixedSpinnerPhase = tab.isLoading ? 0 : nil + refreshChrome() + } + + func closeButtonBackgroundColor() -> NSColor { + if isClosePressed { + return workspaceSplitPressedTabBackground(for: splitAppearance) + } + if isCloseHovered { + return workspaceSplitHoveredTabBackground(for: splitAppearance) + } + return .clear + } + + func zoomButtonBackgroundColor() -> NSColor { + if isZoomPressed { + return workspaceSplitPressedTabBackground(for: splitAppearance) + } + if isZoomHovered { + return workspaceSplitHoveredTabBackground(for: splitAppearance) + } + return .clear + } +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNotifications.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNotifications.swift new file mode 100644 index 000000000000..35b5e4297b67 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutNotifications.swift @@ -0,0 +1,5 @@ +import Foundation + +enum WorkspaceLayoutNotifications { + static let tabChromeDebugSettingsDidChange = Notification.Name("workspaceTabChromeDebugSettingsDidChange") +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutPaneDropOverlayView.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutPaneDropOverlayView.swift new file mode 100644 index 000000000000..e1765e77a593 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutPaneDropOverlayView.swift @@ -0,0 +1,461 @@ +import AppKit +import UniformTypeIdentifiers + +final class WorkspaceLayoutPaneDropOverlayView: NSView { + var paneId: PaneID? + var hostBridge: (WorkspaceLayoutInteractionHandlers)? + var presentation: WorkspaceLayoutPresentationSnapshot? + var localTabDrag: WorkspaceLayoutLocalDragSnapshot? + var onZoneUpdated: ((DropZone) -> Void)? + var onZoneCleared: (() -> Void)? + var onDropSucceeded: (() -> Void)? + var onHideAnimationCompleted: ((UInt64) -> Void)? + var onDropPerformed: (() -> Void)? + let overlayShapeView = NSView(frame: .zero) + var overlayPresentation: WorkspacePaneDropOverlayPresentation = .hidden + + var hitTestPassthroughEnabled = false + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + overlayShapeView.wantsLayer = true + overlayShapeView.isHidden = true + overlayShapeView.alphaValue = 1 + if let layer = overlayShapeView.layer { + layer.cornerRadius = 8 + layer.backgroundColor = NSColor.controlAccentColor.withAlphaComponent(0.25).cgColor + layer.borderColor = NSColor.controlAccentColor.cgColor + layer.borderWidth = 2 + } + addSubview(overlayShapeView) + registerForDraggedTypes([ + NSPasteboard.PasteboardType(UTType.tabTransfer.identifier), + .fileURL, + .URL + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func hitTest(_ point: NSPoint) -> NSView? { + hitTestPassthroughEnabled ? nil : super.hitTest(point) + } + + override func layout() { + super.layout() + guard let zone = overlayPresentation.zone else { return } + let targetFrame = WorkspacePaneDropRouting.overlayFrame(for: zone, in: bounds.size) + if !Self.rectApproximatelyEqual(overlayShapeView.frame, targetFrame) { + overlayShapeView.frame = targetFrame + } + } + + func update( + paneId: PaneID, + hostBridge: WorkspaceLayoutInteractionHandlers, + presentation: WorkspaceLayoutPresentationSnapshot, + localTabDrag: WorkspaceLayoutLocalDragSnapshot?, + overlayPresentation: WorkspacePaneDropOverlayPresentation, + onZoneUpdated: @escaping (DropZone) -> Void, + onZoneCleared: @escaping () -> Void, + onDropSucceeded: @escaping () -> Void, + onHideAnimationCompleted: @escaping (UInt64) -> Void, + onDropPerformed: @escaping () -> Void + ) { + self.paneId = paneId + self.hostBridge = hostBridge + self.presentation = presentation + self.localTabDrag = localTabDrag + self.onZoneUpdated = onZoneUpdated + self.onZoneCleared = onZoneCleared + self.onDropSucceeded = onDropSucceeded + self.onHideAnimationCompleted = onHideAnimationCompleted + self.onDropPerformed = onDropPerformed + updateOverlayPresentation(overlayPresentation) + } + + func updateLocalTabDrag(_ localTabDrag: WorkspaceLayoutLocalDragSnapshot?) { + self.localTabDrag = localTabDrag + } + + func updateOverlayPresentation(_ overlayPresentation: WorkspacePaneDropOverlayPresentation) { + let previousPresentation = self.overlayPresentation + guard previousPresentation != overlayPresentation else { return } + self.overlayPresentation = overlayPresentation + applyOverlayPresentationTransition(from: previousPresentation, to: overlayPresentation) + } + + static func rectApproximatelyEqual( + _ lhs: CGRect, + _ rhs: CGRect, + epsilon: CGFloat = 0.5 + ) -> Bool { + abs(lhs.origin.x - rhs.origin.x) <= epsilon && + abs(lhs.origin.y - rhs.origin.y) <= epsilon && + abs(lhs.size.width - rhs.size.width) <= epsilon && + abs(lhs.size.height - rhs.size.height) <= epsilon + } + + func applyOverlayPresentationTransition( + from previousPresentation: WorkspacePaneDropOverlayPresentation, + to nextPresentation: WorkspacePaneDropOverlayPresentation + ) { + switch nextPresentation.phase { + case .hidden: + overlayShapeView.layer?.removeAllAnimations() + overlayShapeView.isHidden = true + overlayShapeView.alphaValue = 1 + + case .visible: + guard let zone = nextPresentation.zone else { return } + let targetFrame = WorkspacePaneDropRouting.overlayFrame(for: zone, in: bounds.size) + let isSameFrame = Self.rectApproximatelyEqual(overlayShapeView.frame, targetFrame) + let zoneChanged = previousPresentation.zone != zone + + if overlayShapeView.isHidden || previousPresentation.phase != .visible { + overlayShapeView.layer?.removeAllAnimations() + overlayShapeView.frame = targetFrame + overlayShapeView.alphaValue = 0 + overlayShapeView.isHidden = false + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.18 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + overlayShapeView.animator().alphaValue = 1 + } + return + } + + if zoneChanged && !isSameFrame { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.12 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + overlayShapeView.animator().frame = targetFrame + } + } else if !isSameFrame { + overlayShapeView.frame = targetFrame + } + + if overlayShapeView.alphaValue < 1 { + overlayShapeView.layer?.removeAllAnimations() + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.12 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + overlayShapeView.animator().alphaValue = 1 + } + } + + case .hiding: + guard let zone = nextPresentation.zone else { + onHideAnimationCompleted?(nextPresentation.generation) + return + } + + let targetFrame = WorkspacePaneDropRouting.overlayFrame(for: zone, in: bounds.size) + if !Self.rectApproximatelyEqual(overlayShapeView.frame, targetFrame) { + overlayShapeView.frame = targetFrame + } + overlayShapeView.layer?.removeAllAnimations() + guard !overlayShapeView.isHidden else { + onHideAnimationCompleted?(nextPresentation.generation) + return + } + + let generation = nextPresentation.generation + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.14 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + overlayShapeView.animator().alphaValue = 0 + } completionHandler: { [weak self] in + MainActor.assumeIsolated { + guard let self else { return } + guard self.overlayPresentation.phase == .hiding else { return } + guard self.overlayPresentation.generation == generation else { return } + self.overlayShapeView.isHidden = true + self.overlayShapeView.alphaValue = 1 + self.onHideAnimationCompleted?(generation) + } + } + } + } + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + return draggingUpdated(sender) + } + + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + guard let paneId, let presentation else { return [] } + guard presentation.isInteractive else { return [] } + + if sender.draggingPasteboard.availableType(from: [NSPasteboard.PasteboardType(UTType.tabTransfer.identifier)]) != nil { + guard localTabDrag != nil + || workspaceSplitDecodeTransfer(from: sender.draggingPasteboard)?.isFromCurrentProcess == true else { + return [] + } + let location = convert(sender.draggingLocation, from: nil) + let sourcePaneId = localTabDrag?.sourcePaneId + let decision = WorkspacePaneDropRouting.decision( + for: location, + in: bounds.size, + targetPaneId: paneId, + sourcePaneId: sourcePaneId + ) + let zone = decision.finalZone + onZoneUpdated?(zone) + return .move + } + + let urls = sender.draggingPasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] + if let urls, !urls.isEmpty { + onZoneUpdated?(.center) + return .copy + } + + return [] + } + + override func draggingExited(_ sender: NSDraggingInfo?) { + onZoneCleared?() + } + + override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { + true + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + guard let paneId, let hostBridge else { return false } + + if sender.draggingPasteboard.availableType(from: [NSPasteboard.PasteboardType(UTType.tabTransfer.identifier)]) != nil { + let zone = overlayPresentation.zone ?? WorkspacePaneDropRouting.zone( + for: convert(sender.draggingLocation, from: nil), + in: bounds.size + ) + + if let localDrag = localTabDrag { + let draggedTabId = localDrag.tabId + let sourcePaneId = localDrag.sourcePaneId + hostBridge.clearDragState() + + if zone == .center { + if sourcePaneId != paneId { + _ = hostBridge.moveTab(draggedTabId, toPane: paneId, atIndex: nil) + } + onDropSucceeded?() + onDropPerformed?() + return true + } + + guard let orientation = zone.orientation else { + onZoneCleared?() + return false + } + _ = hostBridge.splitPane( + paneId, + orientation: orientation, + movingTab: draggedTabId, + insertFirst: zone.insertsFirst, + focusNewPane: true + ) + onDropSucceeded?() + onDropPerformed?() + return true + } + + guard let transfer = workspaceSplitDecodeTransfer(from: sender.draggingPasteboard), + transfer.isFromCurrentProcess else { + onZoneCleared?() + return false + } + + let destination: WorkspaceLayoutExternalTabDropRequest.Destination + if zone == .center { + destination = .insert(targetPane: paneId, targetIndex: nil) + } else if let orientation = zone.orientation { + destination = .split(targetPane: paneId, orientation: orientation, insertFirst: zone.insertsFirst) + } else { + return false + } + + let request = WorkspaceLayoutExternalTabDropRequest( + tabId: transfer.tabId, + sourcePaneId: PaneID(id: transfer.sourcePaneId), + destination: destination + ) + let handled = hostBridge.handleExternalTabDrop(request) + if handled { + onDropSucceeded?() + onDropPerformed?() + } else { + onZoneCleared?() + } + return handled + } + + let urls = sender.draggingPasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] ?? [] + let handled = hostBridge.handleFileDrop(urls, in: paneId) + if handled { + onDropSucceeded?() + onDropPerformed?() + } else { + onZoneCleared?() + } + return handled + } +} + +func workspaceSplitSymbolImage(named name: String) -> NSImage? { + let size = (name == "terminal.fill" || name == "terminal" || name == "globe") + ? max(10, TabBarMetrics.iconSize - 2.5) + : TabBarMetrics.iconSize + let configuration = NSImage.SymbolConfiguration(pointSize: size, weight: .regular) + return NSImage(systemSymbolName: name, accessibilityDescription: nil)? + .withSymbolConfiguration(configuration) +} + +func workspaceSplitTemplateSymbolImage( + named name: String, + pointSize: CGFloat, + weight: NSFont.Weight, + fitting slotSize: CGSize? = nil +) -> NSImage? { + func image(for candidatePointSize: CGFloat) -> NSImage? { + let configuration = NSImage.SymbolConfiguration(pointSize: candidatePointSize, weight: weight) + return NSImage(systemSymbolName: name, accessibilityDescription: nil)? + .withSymbolConfiguration(configuration) + } + + guard let slotSize else { + return image(for: pointSize) + } + + var candidatePointSize = pointSize + var lastImage: NSImage? + while candidatePointSize >= 1 { + guard let candidate = image(for: candidatePointSize) else { break } + lastImage = candidate + if candidate.size.width <= slotSize.width, candidate.size.height <= slotSize.height { + return candidate + } + candidatePointSize -= 0.5 + } + return lastImage +} + +func workspaceSplitSymbolImage( + named name: String, + pointSize: CGFloat, + weight: NSFont.Weight, + color: NSColor +) -> NSImage? { + let pointConfiguration = NSImage.SymbolConfiguration(pointSize: pointSize, weight: weight) + let colorConfiguration = NSImage.SymbolConfiguration(paletteColors: [color]) + let configuration = pointConfiguration.applying(colorConfiguration) + return NSImage(systemSymbolName: name, accessibilityDescription: nil)? + .withSymbolConfiguration(configuration) +} + +func workspaceSplitDrawSymbol( + named name: String, + pointSize: CGFloat, + weight: NSFont.Weight, + color: NSColor, + in slotRect: CGRect +) { + guard let image = workspaceSplitSymbolImage( + named: name, + pointSize: pointSize, + weight: weight, + color: color + ) else { + return + } + let fittedSize: CGSize + if image.size.width > slotRect.width || image.size.height > slotRect.height { + let scale = min(slotRect.width / image.size.width, slotRect.height / image.size.height) + fittedSize = CGSize( + width: image.size.width * scale, + height: image.size.height * scale + ) + } else { + fittedSize = image.size + } + let fittedScale = image.size.width > 0 ? fittedSize.width / image.size.width : 1 + let opticalCenterOffset = name == "xmark" + ? workspaceLayoutVisibleAlphaCenterOffset(for: image).applying( + CGAffineTransform(scaleX: fittedScale, y: fittedScale) + ) + : .zero + let drawRect = CGRect( + x: slotRect.midX - (fittedSize.width / 2) + opticalCenterOffset.x, + y: slotRect.midY - (fittedSize.height / 2) + opticalCenterOffset.y, + width: fittedSize.width, + height: fittedSize.height + ) + image.draw( + in: drawRect, + from: .zero, + operation: .sourceOver, + fraction: 1, + respectFlipped: true, + hints: [.interpolation: NSImageInterpolation.high] + ) +} + +func workspaceLayoutVisibleAlphaCenterOffset( + for image: NSImage, + alphaThreshold: UInt8 = 8 +) -> CGPoint { + guard let buffer = workspaceLayoutRGBAImageBuffer(from: image) else { return .zero } + var minX = buffer.width + var minY = buffer.height + var maxX = -1 + var maxY = -1 + + for y in 0..= minX, maxY >= minY else { return .zero } + + let imageCenterX = CGFloat(buffer.width) / 2 + let imageCenterY = CGFloat(buffer.height) / 2 + let visibleCenterX = CGFloat(minX + maxX + 1) / 2 + let visibleCenterY = CGFloat(minY + maxY + 1) / 2 + let pointScaleX = image.size.width / CGFloat(buffer.width) + let pointScaleY = image.size.height / CGFloat(buffer.height) + + return CGPoint( + x: (imageCenterX - visibleCenterX) * pointScaleX, + y: (imageCenterY - visibleCenterY) * pointScaleY + ) +} + +func workspaceSplitAddMenuItem( + _ title: String, + action: TabContextAction, + to menu: NSMenu, + enabled: Bool = true, + handler: ((TabContextAction) -> Void)? +) { + let item = NSMenuItem(title: title, action: #selector(ClosureMenuTarget.invoke(_:)), keyEquivalent: "") + let target = ClosureMenuTarget { + handler?(action) + } + item.target = target + item.isEnabled = enabled + objc_setAssociatedObject(item, Unmanaged.passUnretained(item).toOpaque(), target, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + menu.addItem(item) +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutPaneHostView.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutPaneHostView.swift new file mode 100644 index 000000000000..73875b5ccf6f --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutPaneHostView.swift @@ -0,0 +1,293 @@ +import AppKit +import SwiftUI + +@MainActor +final class WorkspaceLayoutPaneHostView: NSView { + weak var rootHost: WorkspaceLayoutRootHostView? + var snapshot: WorkspaceLayoutPaneRenderSnapshot + let hostBridge: WorkspaceLayoutInteractionHandlers + var presentation: WorkspaceLayoutPresentationSnapshot + var localTabDrag: WorkspaceLayoutLocalDragSnapshot? + let surfaceRegistry: any WorkspaceSurfaceRegistryProtocol + + let directContentSlotView = WorkspaceLayoutPaneContentSlotView(frame: .zero) + let tabBarView = WorkspaceLayoutNativeTabBarView(frame: .zero) + let dropOverlayView = WorkspaceLayoutPaneDropOverlayView(frame: .zero) + var overlayPresentation: WorkspacePaneDropOverlayPresentation = .hidden + var contentDropZone: DropZone? = nil + var mountedDirectContent: WorkspaceLayoutMountedTabEntry? + + init( + rootHost: WorkspaceLayoutRootHostView, + snapshot: WorkspaceLayoutPaneRenderSnapshot, + hostBridge: WorkspaceLayoutInteractionHandlers, + presentation: WorkspaceLayoutPresentationSnapshot, + localTabDrag: WorkspaceLayoutLocalDragSnapshot?, + overlayPresentation: WorkspacePaneDropOverlayPresentation, + contentDropZone: DropZone?, + surfaceRegistry: any WorkspaceSurfaceRegistryProtocol + ) { + self.rootHost = rootHost + self.snapshot = snapshot + self.hostBridge = hostBridge + self.presentation = presentation + self.localTabDrag = localTabDrag + self.overlayPresentation = overlayPresentation + self.contentDropZone = contentDropZone + self.surfaceRegistry = surfaceRegistry + super.init(frame: .zero) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + directContentSlotView.isHidden = true + addSubview(directContentSlotView) + addSubview(tabBarView) + addSubview(dropOverlayView) + dropOverlayView.hitTestPassthroughEnabled = true + update( + snapshot: snapshot, + hostBridge: hostBridge, + presentation: presentation, + localTabDrag: localTabDrag, + overlayPresentation: overlayPresentation, + contentDropZone: contentDropZone, + surfaceRegistry: surfaceRegistry + ) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update( + snapshot: WorkspaceLayoutPaneRenderSnapshot, + hostBridge: WorkspaceLayoutInteractionHandlers, + presentation: WorkspaceLayoutPresentationSnapshot, + localTabDrag: WorkspaceLayoutLocalDragSnapshot?, + overlayPresentation: WorkspacePaneDropOverlayPresentation, + contentDropZone: DropZone?, + surfaceRegistry: any WorkspaceSurfaceRegistryProtocol + ) { + self.snapshot = snapshot + self.presentation = presentation + self.localTabDrag = localTabDrag + self.overlayPresentation = overlayPresentation + self.contentDropZone = contentDropZone + + tabBarView.update( + snapshot: snapshot.chrome, + hostBridge: hostBridge, + presentation: presentation, + localTabDrag: localTabDrag + ) + tabBarView.onTabMutation = { [weak self] in + self?.rootHost?.notifyGeometryChanged(isDragging: false) + } + + dropOverlayView.update( + paneId: snapshot.paneId, + hostBridge: hostBridge, + presentation: presentation, + localTabDrag: localTabDrag, + overlayPresentation: overlayPresentation, + onZoneUpdated: { [weak self] zone in + self?.rootHost?.updatePaneDropZone(zone, for: snapshot.paneId) + }, + onZoneCleared: { [weak self] in + self?.rootHost?.clearPaneDropZone(for: snapshot.paneId) + }, + onDropSucceeded: { [weak self] in + self?.rootHost?.clearPaneDropZoneImmediately(for: snapshot.paneId) + }, + onHideAnimationCompleted: { [weak self] generation in + self?.rootHost?.completePaneDropOverlayHide(for: snapshot.paneId, generation: generation) + }, + onDropPerformed: { [weak self] in + self?.rootHost?.notifyGeometryChanged(isDragging: false) + } + ) + refreshDirectContentMount(surfaceRegistry: surfaceRegistry, reconcileLifecycle: true) + needsLayout = true + } + + func updateLocalTabDrag(_ localTabDrag: WorkspaceLayoutLocalDragSnapshot?) { + guard self.localTabDrag != localTabDrag else { return } + self.localTabDrag = localTabDrag + tabBarView.updateLocalTabDrag(localTabDrag) + dropOverlayView.updateLocalTabDrag(localTabDrag) + } + + func prepareForRemoval() { + clearDirectContent(surfaceRegistry: surfaceRegistry) + } + + override func layout() { + super.layout() + let barHeight = presentation.appearance.tabBarHeight + let topInset = min(barHeight, max(0, bounds.height - 1)) + let contentHeight = max(0, bounds.height - topInset) + directContentSlotView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: contentHeight) + tabBarView.frame = CGRect(x: 0, y: contentHeight, width: bounds.width, height: topInset) + dropOverlayView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: contentHeight) + reconcileDirectContentLifecycle(reason: "layout") + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + reconcileDirectContentLifecycle(reason: "windowMove") + } + + func updateDropPresentation( + overlayPresentation: WorkspacePaneDropOverlayPresentation, + contentDropZone: DropZone?, + surfaceRegistry: any WorkspaceSurfaceRegistryProtocol + ) { + let didChangeOverlay = self.overlayPresentation != overlayPresentation + let didChangeContentDropZone = self.contentDropZone != contentDropZone + self.overlayPresentation = overlayPresentation + self.contentDropZone = contentDropZone + if didChangeOverlay { + dropOverlayView.updateOverlayPresentation(overlayPresentation) + } + if didChangeContentDropZone && !snapshot.content.usesDirectPaneHost { + refreshDirectContentMount(surfaceRegistry: surfaceRegistry, reconcileLifecycle: false) + } + } + + func refreshDirectContentMount( + surfaceRegistry: any WorkspaceSurfaceRegistryProtocol, + reconcileLifecycle: Bool + ) { + guard snapshot.content.usesDirectPaneHost else { + clearDirectContent(surfaceRegistry: surfaceRegistry) + return + } + + let nextMountedContent = WorkspaceLayoutMountedTabEntry( + contentId: snapshot.contentId, + content: snapshot.content, + mountIdentity: snapshot.content.mountIdentity(contentId: snapshot.contentId) + ) + + if let mountedDirectContent, + (mountedDirectContent.contentId != nextMountedContent.contentId + || mountedDirectContent.mountIdentity != nextMountedContent.mountIdentity) { + surfaceRegistry.unmountContent( + mountedDirectContent.content, + contentId: mountedDirectContent.contentId, + from: directContentSlotView + ) + self.mountedDirectContent = nil + } + + let effectiveContentDropZone = snapshot.content.usesDirectPaneHost ? nil : self.contentDropZone + surfaceRegistry.mountContent( + snapshot.content, + contentId: snapshot.contentId, + in: directContentSlotView, + activeDropZone: effectiveContentDropZone, + onPresentationChange: nil + ) + mountedDirectContent = nextMountedContent + directContentSlotView.isHidden = false + if reconcileLifecycle { + reconcileDirectContentLifecycle(reason: "update") + } + } + + func reconcileDirectContentLifecycle(reason: String) { + guard let mountedDirectContent else { return } + surfaceRegistry.reconcileViewportLifecycle( + mountedDirectContent.content, + contentId: mountedDirectContent.contentId, + in: directContentSlotView, + reason: "pane.\(reason)" + ) + } + + func clearDirectContent(surfaceRegistry: any WorkspaceSurfaceRegistryProtocol) { + if let mountedDirectContent { + surfaceRegistry.unmountContent( + mountedDirectContent.content, + contentId: mountedDirectContent.contentId, + from: directContentSlotView + ) + } + mountedDirectContent = nil + directContentSlotView.clearContentView() + directContentSlotView.isHidden = true + } + +#if DEBUG + var debugMountedDirectContentIdentity: WorkspacePaneMountIdentity? { + mountedDirectContent?.mountIdentity + } + + var debugDirectContentBounds: CGRect { + directContentSlotView.bounds + } + + var debugUsesDirectTerminalHost: Bool { + if case .terminal = mountedDirectContent?.content { + return true + } + return false + } + + var debugLocalTabDrag: WorkspaceLayoutLocalDragSnapshot? { + localTabDrag + } +#endif +} + +@MainActor +final class WorkspaceLayoutPaneContentSlotView: NSView { + var installedContentView: NSView? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func installContentView(_ view: NSView) { + if installedContentView !== view { + if let previous = installedContentView, previous.superview === self { + previous.removeFromSuperview() + } + if view.superview !== self { + view.removeFromSuperview() + addSubview(view) + } + installedContentView = view + } else if view.superview !== self { + view.removeFromSuperview() + addSubview(view) + } + + view.frame = bounds + view.autoresizingMask = [.width, .height] + } + + func clearContentView() { + if let installedContentView, installedContentView.superview === self { + installedContentView.removeFromSuperview() + } + installedContentView = nil + } + + override func layout() { + super.layout() + guard let installedContentView else { return } + guard installedContentView.superview === self else { + self.installedContentView = nil + return + } + installedContentView.frame = bounds + } +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutShortcutHintPillBackground.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutShortcutHintPillBackground.swift new file mode 100644 index 000000000000..e5d4f0ebfe98 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutShortcutHintPillBackground.swift @@ -0,0 +1,15 @@ +import SwiftUI + +struct WorkspaceLayoutShortcutHintPillBackground: View { + var emphasis: Double = 1.0 + + var body: some View { + Capsule(style: .continuous) + .fill(.regularMaterial) + .overlay( + Capsule(style: .continuous) + .stroke(Color.white.opacity(0.30 * emphasis), lineWidth: 0.8) + ) + .shadow(color: Color.black.opacity(0.22 * emphasis), radius: 2, x: 0, y: 1) + } +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutShortcutHintSettings.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutShortcutHintSettings.swift new file mode 100644 index 000000000000..f21c5e5549b1 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutShortcutHintSettings.swift @@ -0,0 +1,41 @@ +import AppKit +import Foundation + +struct WorkspaceLayoutNumberedShortcutHint { + let hasChord: Bool + let modifierFlags: NSEvent.ModifierFlags + let modifierDisplayString: String +} + +enum WorkspaceLayoutShortcutHintSettings { + static let paneHintXKey = "shortcutHintPaneTabXOffset" + static let paneHintYKey = "shortcutHintPaneTabYOffset" + static let alwaysShowHintsKey = "shortcutHintAlwaysShow" + static let showHintsOnCommandHoldKey = "shortcutHintShowOnCommandHold" + + static let defaultPaneHintX = 0.0 + static let defaultPaneHintY = 0.0 + static let defaultAlwaysShowHints = false + static let defaultShowHintsOnCommandHold = true + + static let offsetRange: ClosedRange = -20...20 + + static var selectSurfaceByNumberShortcutProvider: () -> WorkspaceLayoutNumberedShortcutHint = { + WorkspaceLayoutNumberedShortcutHint( + hasChord: false, + modifierFlags: [.control], + modifierDisplayString: "⌃" + ) + } + + static func clamped(_ value: Double) -> Double { + min(max(value, offsetRange.lowerBound), offsetRange.upperBound) + } + + static func showHintsOnCommandHoldEnabled(defaults: UserDefaults = .standard) -> Bool { + guard defaults.object(forKey: showHintsOnCommandHoldKey) != nil else { + return defaultShowHintsOnCommandHold + } + return defaults.bool(forKey: showHintsOnCommandHoldKey) + } +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutShortcutHints.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutShortcutHints.swift new file mode 100644 index 000000000000..9e6b3d943e4d --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutShortcutHints.swift @@ -0,0 +1,280 @@ +import AppKit +import SwiftUI + +@MainActor +struct WorkspaceLayoutMountedTabEntry { + let contentId: UUID + let content: WorkspacePaneContent + let mountIdentity: WorkspacePaneMountIdentity +} + +struct WorkspaceLayoutPaneTabShortcutHintModifier: Equatable { + let modifierFlags: NSEvent.ModifierFlags + let symbol: String +} + +enum WorkspaceLayoutPaneTabShortcutHintPolicy { + static let intentionalHoldDelay: TimeInterval = 0.30 + + static func hintModifier( + for modifierFlags: NSEvent.ModifierFlags, + defaults: UserDefaults = .standard + ) -> WorkspaceLayoutPaneTabShortcutHintModifier? { + guard WorkspaceLayoutShortcutHintSettings.showHintsOnCommandHoldEnabled(defaults: defaults) else { + return nil + } + let shortcut = WorkspaceLayoutShortcutHintSettings.selectSurfaceByNumberShortcutProvider() + guard !shortcut.hasChord else { return nil } + let normalized = modifierFlags.intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + guard normalized == shortcut.modifierFlags || normalized == [.command] else { return nil } + return WorkspaceLayoutPaneTabShortcutHintModifier( + modifierFlags: shortcut.modifierFlags, + symbol: shortcut.modifierDisplayString + ) + } + + 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?, + defaults: UserDefaults = .standard + ) -> Bool { + hintModifier(for: modifierFlags, defaults: defaults) != nil && + isCurrentWindow( + hostWindowNumber: hostWindowNumber, + hostWindowIsKey: hostWindowIsKey, + eventWindowNumber: eventWindowNumber, + keyWindowNumber: keyWindowNumber + ) + } +} + +func workspaceLayoutTabControlShortcutDigit(for index: Int, tabCount: Int) -> Int? { + for digit in 1...9 { + if workspaceLayoutTabIndexForControlShortcutDigit(digit, tabCount: tabCount) == index { + return digit + } + } + return nil +} + +func workspaceLayoutTabIndexForControlShortcutDigit(_ digit: Int, tabCount: Int) -> Int? { + guard tabCount > 0, digit >= 1, digit <= 9 else { return nil } + if digit == 9 { + return tabCount - 1 + } + let index = digit - 1 + return index < tabCount ? index : nil +} + +@MainActor +final class WorkspaceLayoutPaneTabShortcutHintMonitor { + private(set) var isShortcutHintVisible = false { + didSet { + guard isShortcutHintVisible != oldValue else { return } + onChange?() + } + } + + private(set) var shortcutModifierSymbol = "⌃" { + didSet { + guard shortcutModifierSymbol != oldValue else { return } + onChange?() + } + } + + var onChange: (() -> Void)? + + weak var hostWindow: NSWindow? + var hostWindowDidBecomeKeyObserver: NSObjectProtocol? + var hostWindowDidResignKeyObserver: NSObjectProtocol? + var flagsMonitor: Any? + var keyDownMonitor: Any? + var resignObserver: NSObjectProtocol? + var pendingShowWorkItem: DispatchWorkItem? + var pendingModifier: WorkspaceLayoutPaneTabShortcutHintModifier? + + 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, eventWindow: nil) + return + } + + flagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in + self?.update(from: event.modifierFlags, eventWindow: event.window) + return event + } + + keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard self?.isCurrentWindow(eventWindow: event.window) == true else { return event } + self?.cancelPendingHintShow(resetVisible: true) + return event + } + + resignObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.cancelPendingHintShow(resetVisible: true) + } + } + + update(from: NSEvent.modifierFlags, eventWindow: nil) + } + + func stop() { + if let flagsMonitor { + NSEvent.removeMonitor(flagsMonitor) + self.flagsMonitor = nil + } + if let keyDownMonitor { + NSEvent.removeMonitor(keyDownMonitor) + self.keyDownMonitor = nil + } + if let resignObserver { + NotificationCenter.default.removeObserver(resignObserver) + self.resignObserver = nil + } + removeHostWindowObservers() + cancelPendingHintShow(resetVisible: true) + } + + func isCurrentWindow(eventWindow: NSWindow?) -> Bool { + WorkspaceLayoutPaneTabShortcutHintPolicy.isCurrentWindow( + hostWindowNumber: hostWindow?.windowNumber, + hostWindowIsKey: hostWindow?.isKeyWindow ?? false, + eventWindowNumber: eventWindow?.windowNumber, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) + } + + func update(from modifierFlags: NSEvent.ModifierFlags, eventWindow: NSWindow?) { + guard WorkspaceLayoutPaneTabShortcutHintPolicy.shouldShowHints( + for: modifierFlags, + hostWindowNumber: hostWindow?.windowNumber, + hostWindowIsKey: hostWindow?.isKeyWindow ?? false, + eventWindowNumber: eventWindow?.windowNumber, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) else { + cancelPendingHintShow(resetVisible: true) + return + } + + guard let modifier = WorkspaceLayoutPaneTabShortcutHintPolicy.hintModifier(for: modifierFlags) else { + cancelPendingHintShow(resetVisible: true) + return + } + + if isShortcutHintVisible { + shortcutModifierSymbol = modifier.symbol + return + } + + queueHintShow(for: modifier) + } + + func queueHintShow(for modifier: WorkspaceLayoutPaneTabShortcutHintModifier) { + if pendingModifier == modifier, pendingShowWorkItem != nil { + return + } + + pendingShowWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.pendingShowWorkItem = nil + self.pendingModifier = nil + guard WorkspaceLayoutPaneTabShortcutHintPolicy.shouldShowHints( + for: NSEvent.modifierFlags, + hostWindowNumber: self.hostWindow?.windowNumber, + hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false, + eventWindowNumber: nil, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) else { + return + } + guard let currentModifier = WorkspaceLayoutPaneTabShortcutHintPolicy.hintModifier(for: NSEvent.modifierFlags) else { + return + } + self.shortcutModifierSymbol = currentModifier.symbol + self.isShortcutHintVisible = true + } + + pendingModifier = modifier + pendingShowWorkItem = workItem + DispatchQueue.main.asyncAfter( + deadline: .now() + WorkspaceLayoutPaneTabShortcutHintPolicy.intentionalHoldDelay, + execute: workItem + ) + } + + func cancelPendingHintShow(resetVisible: Bool) { + pendingShowWorkItem?.cancel() + pendingShowWorkItem = nil + pendingModifier = nil + if resetVisible { + isShortcutHintVisible = false + } + } + + func removeHostWindowObservers() { + if let hostWindowDidBecomeKeyObserver { + NotificationCenter.default.removeObserver(hostWindowDidBecomeKeyObserver) + self.hostWindowDidBecomeKeyObserver = nil + } + if let hostWindowDidResignKeyObserver { + NotificationCenter.default.removeObserver(hostWindowDidResignKeyObserver) + self.hostWindowDidResignKeyObserver = nil + } + } +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutTabAccessories.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutTabAccessories.swift new file mode 100644 index 000000000000..8774c91e36b4 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutTabAccessories.swift @@ -0,0 +1,228 @@ +import AppKit +import SwiftUI + +@MainActor +final class WorkspaceLayoutHoverButton: NSControl { + var hoverTrackingArea: NSTrackingArea? + let iconView = NSImageView(frame: .zero) + var onHoverChanged: ((Bool) -> Void)? + var onPressedChanged: ((Bool) -> Void)? + var rendersVisuals = true { + didSet { + if !rendersVisuals { + layer?.backgroundColor = NSColor.clear.cgColor + } + needsLayout = true + needsDisplay = true + } + } + var drawsCloseGlyph = false { + didSet { + iconView.isHidden = drawsCloseGlyph + needsLayout = true + needsDisplay = true + } + } + var iconImage: NSImage? { + didSet { + iconView.image = iconImage + needsLayout = true + needsDisplay = true + } + } + var iconTintColor: NSColor? { + didSet { + iconView.contentTintColor = iconTintColor + needsDisplay = true + } + } + var iconSize: CGFloat = TabBarMetrics.closeIconSize { + didSet { + needsLayout = true + needsDisplay = true + } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.masksToBounds = true + iconView.imageScaling = .scaleProportionallyDown + iconView.imageAlignment = .alignCenter + addSubview(iconView) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layout() { + super.layout() + layer?.cornerRadius = bounds.height / 2 + guard rendersVisuals else { + iconView.frame = .zero + return + } + guard !drawsCloseGlyph else { + iconView.frame = .zero + return + } + let iconFrame = CGRect( + x: (bounds.width - iconSize) / 2, + y: (bounds.height - iconSize) / 2, + width: iconSize, + height: iconSize + ) + iconView.frame = iconFrame + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + guard rendersVisuals, drawsCloseGlyph else { return } + + let strokeWidth = max(1.35, iconSize * 0.15) + let maxGlyphDimension = min(bounds.width, bounds.height, iconSize) + let armLength = max(0, min(iconSize * 0.325, (maxGlyphDimension - strokeWidth) / 2 - 0.35)) +#if DEBUG + let closeGlyphDX = WorkspaceLayoutTabChromeDebugTuning.current.closeGlyphDX + let closeGlyphDY = WorkspaceLayoutTabChromeDebugTuning.current.closeGlyphDY +#else + let closeGlyphDX: CGFloat = 0 + let closeGlyphDY: CGFloat = 0 +#endif + let center = CGPoint(x: bounds.midX + closeGlyphDX, y: bounds.midY + closeGlyphDY) + let path = NSBezierPath() + path.lineWidth = strokeWidth + path.lineCapStyle = .round + path.lineJoinStyle = .round + path.move(to: CGPoint(x: center.x - armLength, y: center.y - armLength)) + path.line(to: CGPoint(x: center.x + armLength, y: center.y + armLength)) + path.move(to: CGPoint(x: center.x - armLength, y: center.y + armLength)) + path.line(to: CGPoint(x: center.x + armLength, y: center.y - armLength)) + (iconTintColor ?? .labelColor).setStroke() + path.stroke() + } + + override func updateTrackingAreas() { + if let hoverTrackingArea { + removeTrackingArea(hoverTrackingArea) + } + let next = NSTrackingArea( + rect: .zero, + options: [.inVisibleRect, .activeAlways, .mouseEnteredAndExited], + owner: self, + userInfo: nil + ) + addTrackingArea(next) + hoverTrackingArea = next + super.updateTrackingAreas() + } + + override func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) + onHoverChanged?(true) + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + onHoverChanged?(false) + } + + override func mouseDown(with event: NSEvent) { + onPressedChanged?(true) + window?.trackEvents( + matching: [.leftMouseDragged, .leftMouseUp], + timeout: NSEvent.foreverDuration, + mode: .eventTracking + ) { [weak self] nextEvent, stop in + guard let self, let nextEvent else { + stop.pointee = true + return + } + let location = self.convert(nextEvent.locationInWindow, from: nil) + let isInside = self.bounds.contains(location) + switch nextEvent.type { + case .leftMouseDragged: + self.onPressedChanged?(isInside) + case .leftMouseUp: + self.onPressedChanged?(false) + if isInside, let action = self.action { + _ = NSApp.sendAction(action, to: self.target, from: self) + } + stop.pointee = true + default: + break + } + } + } +} + +final class WorkspaceLayoutZeroPaddingTextFieldCell: NSTextFieldCell { + override func drawingRect(forBounds rect: NSRect) -> NSRect { + rect + } + + override func titleRect(forBounds rect: NSRect) -> NSRect { + rect + } + + override func cellSize(forBounds rect: NSRect) -> NSSize { + let size = super.cellSize(forBounds: rect) + return NSSize(width: max(0, size.width - 4), height: size.height) + } +} + +@MainActor +struct WorkspaceLayoutPaneTabShortcutHintPill: View { + let text: String + let fontSize: CGFloat + let textColor: NSColor + + var body: some View { + Text(text) + .font(.system(size: fontSize, weight: .semibold, design: .rounded)) + .monospacedDigit() + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .foregroundStyle(Color(nsColor: textColor)) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(WorkspaceLayoutShortcutHintPillBackground()) + } +} + +final class WorkspaceLayoutShortcutHintPillView: NSHostingView { + + required init(rootView: WorkspaceLayoutPaneTabShortcutHintPill) { + super.init(rootView: rootView) + translatesAutoresizingMaskIntoConstraints = false + } + + convenience init(frame frameRect: NSRect) { + self.init( + rootView: WorkspaceLayoutPaneTabShortcutHintPill( + text: "", + fontSize: 9, + textColor: .labelColor + ) + ) + frame = frameRect + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(text: String, fontSize: CGFloat, textColor: NSColor) { + rootView = WorkspaceLayoutPaneTabShortcutHintPill( + text: text, + fontSize: fontSize, + textColor: textColor + ) + invalidateIntrinsicContentSize() + } + + func applyAppearance() {} +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutTabChromeDebugPreview.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutTabChromeDebugPreview.swift new file mode 100644 index 000000000000..9b82489df528 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutTabChromeDebugPreview.swift @@ -0,0 +1,384 @@ +import AppKit +import SwiftUI + +final class ClosureMenuTarget: NSObject { + let handler: () -> Void + + init(handler: @escaping () -> Void) { + self.handler = handler + } + + @objc func invoke(_ sender: Any?) { + handler() + } +} + +struct WorkspaceLayoutTabChromeDebugScenario: Identifiable { + let id: String + let title: String + let tab: WorkspaceLayout.Tab + let isSelected: Bool + let isHovered: Bool + let isCloseHovered: Bool + let isClosePressed: Bool + let showsZoomIndicator: Bool + let isZoomHovered: Bool + let isZoomPressed: Bool + let appearance: WorkspaceLayoutConfiguration.Appearance +} + +struct WorkspaceLayoutTabChromeDebugDiffMetrics: Codable, Hashable { + let width: Int + let height: Int + let differingPixelCount: Int + let totalPixelCount: Int + let maxChannelDelta: Int + let meanAbsoluteChannelDelta: Double + let matchingPixels: Bool +} + +struct WorkspaceLayoutTabChromeDebugExportScenarioResult: Codable { + let id: String + let title: String + let appKitPNG: String + let scenario: WorkspaceLayoutTabChromeDebugScenarioSpec +} + +struct WorkspaceLayoutTabChromeDebugExportManifest: Codable { + let generatedAt: String + let scenarioResults: [WorkspaceLayoutTabChromeDebugExportScenarioResult] +} + +struct WorkspaceLayoutTabChromeDebugScenarioSpec: Codable { + let tab: WorkspaceLayoutTabChromeDebugTabSpec + let isSelected: Bool + let isHovered: Bool + let isCloseHovered: Bool + let isClosePressed: Bool + let showsZoomIndicator: Bool + let isZoomHovered: Bool + let isZoomPressed: Bool + let appearance: WorkspaceLayoutTabChromeDebugAppearanceSpec +} + +struct WorkspaceLayoutTabChromeDebugTabSpec: Codable { + let id: UUID + let title: String + let hasCustomTitle: Bool + let icon: String? + let iconImageDataBase64: String? + let kind: WorkspaceLayoutTabKind? + let isDirty: Bool + let showsNotificationBadge: Bool + let isLoading: Bool + let isPinned: Bool +} + +struct WorkspaceLayoutTabChromeDebugAppearanceSpec: Codable { + let tabBarHeight: Double + let tabMinWidth: Double + let tabMaxWidth: Double + let tabTitleFontSize: Double + let tabSpacing: Double + let minimumPaneWidth: Double + let minimumPaneHeight: Double + let showSplitButtons: Bool + let splitButtonsOnHover: Bool + let tabBarLeadingInset: Double + let splitButtonTooltips: WorkspaceLayoutTabChromeDebugSplitButtonTooltipsSpec + let animationDuration: Double + let enableAnimations: Bool + let chromeColors: WorkspaceLayoutTabChromeDebugChromeColorsSpec +} + +struct WorkspaceLayoutTabChromeDebugSplitButtonTooltipsSpec: Codable { + let newTerminal: String + let newBrowser: String + let splitRight: String + let splitDown: String +} + +struct WorkspaceLayoutTabChromeDebugChromeColorsSpec: Codable { + let backgroundHex: String? + let borderHex: String? +} + +struct WorkspaceLayoutReferenceTabChromeView: View { + let tab: WorkspaceLayout.Tab + let isSelected: Bool + let isHovered: Bool + let isCloseHovered: Bool + let isClosePressed: Bool + let showsZoomIndicator: Bool + let isZoomHovered: Bool + let isZoomPressed: Bool + let appearance: WorkspaceLayoutConfiguration.Appearance + let fixedSpinnerPhase: Double? + let saturation: Double = 1.0 + + var body: some View { + HStack(spacing: 0) { + HStack(spacing: TabBarMetrics.contentSpacing) { + let iconSlotSize = TabBarMetrics.iconSize + let iconTint = isSelected + ? TabBarColors.activeText(for: appearance) + : TabBarColors.inactiveText(for: appearance) + let faviconImage = decodedFaviconImage + + Group { + if tab.isLoading { + WorkspaceLayoutReferenceTabLoadingSpinner( + size: iconSlotSize * 0.86, + color: iconTint, + fixedPhaseDegrees: fixedSpinnerPhase + ) + } else if let image = faviconImage { + WorkspaceLayoutReferenceFaviconIconView(image: image) + .frame(width: iconSlotSize, height: iconSlotSize, alignment: .center) + .clipped() + } else if let iconName = tab.icon { + Image(systemName: iconName) + .font(.system(size: glyphSize(for: iconName))) + .foregroundStyle(iconTint) + } + } + .saturation(WorkspaceLayoutReferenceTabItemStyling.iconSaturation(hasRasterIcon: faviconImage != nil, tabSaturation: saturation)) + .transaction { tx in + tx.animation = nil + } + .frame(width: iconSlotSize, height: iconSlotSize, alignment: .center) + + Text(tab.title) + .font(.system(size: appearance.tabTitleFontSize)) + .lineLimit(1) + .foregroundStyle( + isSelected + ? TabBarColors.activeText(for: appearance) + : TabBarColors.inactiveText(for: appearance) + ) + .saturation(saturation) + + if showsZoomIndicator { + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.system(size: max(8, appearance.tabTitleFontSize - 2), weight: .semibold)) + .foregroundStyle( + isZoomHovered + ? TabBarColors.activeText(for: appearance) + : TabBarColors.inactiveText(for: appearance) + ) + .frame(width: TabBarMetrics.closeButtonSize, height: TabBarMetrics.closeButtonSize) + .background( + Circle() + .fill( + isZoomHovered + ? TabBarColors.hoveredTabBackground(for: appearance) + : .clear + ) + ) + .saturation(saturation) + } + } + + Spacer(minLength: 0) + + trailingAccessory + } + .padding(.horizontal, TabBarMetrics.tabHorizontalPadding) + .offset(y: isSelected ? 0.5 : 0) + .frame( + minWidth: TabBarMetrics.tabMinWidth, + maxWidth: TabBarMetrics.tabMaxWidth, + minHeight: TabBarMetrics.tabHeight, + maxHeight: TabBarMetrics.tabHeight + ) + .padding(.bottom, isSelected ? 1 : 0) + .background(tabBackground) + .allowsHitTesting(false) + } + + var decodedFaviconImage: NSImage? { + guard let data = tab.iconImageData, + let image = NSImage(data: data) else { + return nil + } + image.isTemplate = false + return image + } + + func glyphSize(for iconName: String) -> CGFloat { + if iconName == "terminal.fill" || iconName == "terminal" || iconName == "globe" { + return max(10, TabBarMetrics.iconSize - 2.5) + } + return TabBarMetrics.iconSize + } + + @ViewBuilder + var trailingAccessory: some View { + closeOrDirtyIndicator + .frame(width: TabBarMetrics.closeButtonSize, height: TabBarMetrics.closeButtonSize) + .animation(.easeInOut(duration: TabBarMetrics.hoverDuration), value: isHovered) + .animation(.easeInOut(duration: TabBarMetrics.hoverDuration), value: isCloseHovered) + } + + @ViewBuilder + var tabBackground: some View { + ZStack(alignment: .top) { + if WorkspaceLayoutReferenceTabItemStyling.shouldShowHoverBackground(isHovered: isHovered, isSelected: isSelected) { + Rectangle() + .fill(TabBarColors.hoveredTabBackground(for: appearance)) + } else { + Color.clear + } + + if isSelected { + Rectangle() + .fill(Color.accentColor) + .frame(height: TabBarMetrics.activeIndicatorHeight) + } + + HStack { + Spacer() + Rectangle() + .fill(TabBarColors.separator(for: appearance)) + .frame(width: 1) + } + } + } + + @ViewBuilder + var closeOrDirtyIndicator: some View { + ZStack { + if (!isSelected && !isHovered && !isCloseHovered) && (tab.isDirty || tab.showsNotificationBadge) { + HStack(spacing: 2) { + if tab.showsNotificationBadge { + Circle() + .fill(TabBarColors.notificationBadge(for: appearance)) + .frame(width: TabBarMetrics.notificationBadgeSize, height: TabBarMetrics.notificationBadgeSize) + } + if tab.isDirty { + Circle() + .fill(TabBarColors.dirtyIndicator(for: appearance)) + .frame(width: TabBarMetrics.dirtyIndicatorSize, height: TabBarMetrics.dirtyIndicatorSize) + .saturation(saturation) + } + } + } + + if tab.isPinned { + if isSelected || isHovered || isCloseHovered || (!tab.isDirty && !tab.showsNotificationBadge) { + Image(systemName: "pin.fill") + .font(.system(size: TabBarMetrics.closeIconSize, weight: .semibold)) + .foregroundStyle(TabBarColors.inactiveText(for: appearance)) + .frame(width: TabBarMetrics.closeButtonSize, height: TabBarMetrics.closeButtonSize) + .saturation(saturation) + } + } else if isSelected || isHovered || isCloseHovered { + Image(systemName: "xmark") + .font(.system(size: TabBarMetrics.closeIconSize, weight: .semibold)) + .foregroundStyle( + isCloseHovered + ? TabBarColors.activeText(for: appearance) + : TabBarColors.inactiveText(for: appearance) + ) + .frame(width: TabBarMetrics.closeButtonSize, height: TabBarMetrics.closeButtonSize) + .background( + Circle() + .fill( + isCloseHovered + ? TabBarColors.hoveredTabBackground(for: appearance) + : .clear + ) + ) + .saturation(saturation) + } + } + } +} + +enum WorkspaceLayoutReferenceTabItemStyling { + static func iconSaturation(hasRasterIcon: Bool, tabSaturation: Double) -> Double { + hasRasterIcon ? 1.0 : tabSaturation + } + + static func shouldShowHoverBackground(isHovered: Bool, isSelected: Bool) -> Bool { + isHovered && !isSelected + } +} + +struct WorkspaceLayoutReferenceTabLoadingSpinner: View { + let size: CGFloat + let color: Color + let fixedPhaseDegrees: Double? + + var body: some View { + TimelineView(.animation) { context in + ZStack { + Circle() + .stroke(color.opacity(0.20), lineWidth: ringWidth) + Circle() + .trim(from: 0.0, to: 0.28) + .stroke(color, style: StrokeStyle(lineWidth: ringWidth, lineCap: .round)) + .rotationEffect(.degrees(spinnerAngle(for: context.date))) + } + .frame(width: size, height: size) + } + } + + var ringWidth: CGFloat { + max(1.6, size * 0.14) + } + + func spinnerAngle(for date: Date) -> Double { + if let fixedPhaseDegrees { + return fixedPhaseDegrees + } + let t = date.timeIntervalSinceReferenceDate + return (t.truncatingRemainder(dividingBy: 0.9) / 0.9) * 360.0 + } +} + +struct WorkspaceLayoutReferenceFaviconIconView: NSViewRepresentable { + let image: NSImage + + final class ContainerView: NSView { + let imageView = NSImageView(frame: .zero) + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.masksToBounds = true + imageView.imageScaling = .scaleProportionallyDown + imageView.imageAlignment = .alignCenter + imageView.animates = false + imageView.contentTintColor = nil + imageView.autoresizingMask = [.width, .height] + addSubview(imageView) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: NSSize { + .zero + } + + override func layout() { + super.layout() + imageView.frame = bounds.integral + } + } + + func makeNSView(context: Context) -> ContainerView { + ContainerView(frame: .zero) + } + + func updateNSView(_ nsView: ContainerView, context: Context) { + image.isTemplate = false + if nsView.imageView.image !== image { + nsView.imageView.image = image + } + nsView.imageView.contentTintColor = nil + } +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutTabChromeDebugRendering.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutTabChromeDebugRendering.swift new file mode 100644 index 000000000000..f03d1489ec16 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutTabChromeDebugRendering.swift @@ -0,0 +1,474 @@ +import AppKit +import SwiftUI + +@MainActor +func workspaceLayoutRenderAppKitTabChromeImage( + scenario: WorkspaceLayoutTabChromeDebugScenario, + scale: CGFloat = 2 +) -> NSImage? { + let view = WorkspaceLayoutNativeTabButtonView(frame: .zero) + let renderTab = scenario.tab + view.update( + tab: renderTab, + paneId: PaneID(), + isSelected: scenario.isSelected, + isPaneFocused: true, + showsZoomIndicator: scenario.showsZoomIndicator, + controlShortcutDigit: nil, + showsControlShortcutHint: false, + shortcutModifierSymbol: "⌃", + appearance: scenario.appearance, + contextMenuState: TabContextMenuState( + isPinned: scenario.tab.isPinned, + isUnread: scenario.tab.showsNotificationBadge, + isBrowser: scenario.tab.kind == .browser, + isTerminal: scenario.tab.kind == .terminal, + hasCustomTitle: scenario.tab.hasCustomTitle, + canCloseToLeft: false, + canCloseToRight: false, + canCloseOthers: false, + canMoveToLeftPane: false, + canMoveToRightPane: false, + isZoomed: scenario.showsZoomIndicator, + hasSplits: scenario.showsZoomIndicator, + shortcuts: [:] + ), + onSelect: {}, + onClose: {}, + onZoomToggle: {}, + onContextAction: { _ in }, + onBeginTabDrag: {}, + onCancelTabDrag: {} + ) + view.configureDebugInteractionState( + isHovered: scenario.isHovered, + isCloseHovered: scenario.isCloseHovered, + isClosePressed: scenario.isClosePressed, + isZoomHovered: scenario.isZoomHovered, + isZoomPressed: scenario.isZoomPressed + ) + let width = view.preferredWidth( + minWidth: scenario.appearance.tabMinWidth, + maxWidth: scenario.appearance.tabMaxWidth + ) + view.frame = CGRect(x: 0, y: 0, width: width, height: TabBarMetrics.tabHeight) + view.layoutSubtreeIfNeeded() + return workspaceSplitSnapshotImage( + for: view, + scale: scale, + backgroundColor: TabBarColors.nsColorPaneBackground(for: scenario.appearance) + ) +} + +@MainActor +func workspaceLayoutRenderReferenceTabChromeImage( + scenario: WorkspaceLayoutTabChromeDebugScenario, + scale: CGFloat = 2 +) -> NSImage? { + let rootView = WorkspaceLayoutReferenceTabChromeView( + tab: scenario.tab, + isSelected: scenario.isSelected, + isHovered: scenario.isHovered, + isCloseHovered: scenario.isCloseHovered, + isClosePressed: scenario.isClosePressed, + showsZoomIndicator: scenario.showsZoomIndicator, + isZoomHovered: scenario.isZoomHovered, + isZoomPressed: scenario.isZoomPressed, + appearance: scenario.appearance, + fixedSpinnerPhase: scenario.tab.isLoading ? 0 : nil + ) + let host = NSHostingView(rootView: rootView) + let fittingWidth = ceil(host.fittingSize.width) + host.frame = CGRect(x: 0, y: 0, width: fittingWidth, height: TabBarMetrics.tabHeight) + host.layoutSubtreeIfNeeded() + return workspaceSplitSnapshotImage( + for: host, + scale: scale, + backgroundColor: TabBarColors.nsColorPaneBackground(for: scenario.appearance) + ) +} + +func workspaceSplitAccessoryButtonTuning() -> ( + accessoryDX: CGFloat, + accessoryDY: CGFloat, + accessoryPointSizeDelta: CGFloat, + closeGlyphDX: CGFloat, + closeGlyphDY: CGFloat, + closeCircleDX: CGFloat, + closeCircleDY: CGFloat, + closeCircleSizeDelta: CGFloat +) { +#if DEBUG + let tuning = WorkspaceLayoutTabChromeDebugTuning.current + return ( + accessoryDX: tuning.accessoryDX, + accessoryDY: tuning.accessoryDY, + accessoryPointSizeDelta: tuning.accessoryPointSizeDelta, + closeGlyphDX: tuning.closeGlyphDX, + closeGlyphDY: tuning.closeGlyphDY, + closeCircleDX: tuning.closeCircleDX, + closeCircleDY: tuning.closeCircleDY, + closeCircleSizeDelta: tuning.closeCircleSizeDelta + ) +#else + ( + accessoryDX: CGFloat(0), + accessoryDY: WorkspaceLayoutTabChromeAccessoryMetrics.baseDY, + accessoryPointSizeDelta: WorkspaceLayoutTabChromeAccessoryMetrics.basePointSizeDelta, + closeGlyphDX: CGFloat(0), + closeGlyphDY: CGFloat(0), + closeCircleDX: CGFloat(0), + closeCircleDY: WorkspaceLayoutTabChromeAccessoryMetrics.baseCloseCircleDY, + closeCircleSizeDelta: CGFloat(0) + ) +#endif +} + +func workspaceSplitAccessoryBackgroundColor( + isHovered: Bool, + isPressed: Bool, + appearance: WorkspaceLayoutConfiguration.Appearance +) -> NSColor { + if isPressed { + return workspaceSplitPressedTabBackground(for: appearance) + } + if isHovered { + return workspaceSplitHoveredTabBackground(for: appearance) + } + return .clear +} + +func workspaceSplitDrawAccessoryButton( + in rect: CGRect, + symbolName: String, + pointSize: CGFloat, + isHovered: Bool, + isPressed: Bool, + appearance: WorkspaceLayoutConfiguration.Appearance +) { + let tuning = workspaceSplitAccessoryButtonTuning() + let backgroundColor = workspaceSplitAccessoryBackgroundColor( + isHovered: isHovered, + isPressed: isPressed, + appearance: appearance + ) + if backgroundColor.alphaComponent > 0 { + let backgroundRect = CGRect( + x: rect.minX + tuning.closeCircleDX - (tuning.closeCircleSizeDelta / 2), + y: rect.minY + tuning.closeCircleDY - (tuning.closeCircleSizeDelta / 2), + width: max(1, rect.width + tuning.closeCircleSizeDelta), + height: max(1, rect.height + tuning.closeCircleSizeDelta) + ) + backgroundColor.setFill() + NSBezierPath(ovalIn: backgroundRect).fill() + } + let tint = (isHovered || isPressed) + ? TabBarColors.nsColorActiveText(for: appearance) + : TabBarColors.nsColorInactiveText(for: appearance) + let glyphDX = tuning.accessoryDX + (symbolName == "xmark" ? tuning.closeGlyphDX : 0) + let glyphDY = tuning.accessoryDY + (symbolName == "xmark" ? tuning.closeGlyphDY : 0) + workspaceSplitDrawSymbol( + named: symbolName, + pointSize: pointSize + tuning.accessoryPointSizeDelta, + weight: .semibold, + color: tint, + in: rect.offsetBy(dx: glyphDX, dy: glyphDY) + ) +} + +final class WorkspaceLayoutAccessoryDebugPreviewView: NSView { + var symbolName: String + var pointSize: CGFloat + var isHovered: Bool + var isPressed: Bool + var splitAppearance: WorkspaceLayoutConfiguration.Appearance + + init( + frame frameRect: NSRect, + symbolName: String, + pointSize: CGFloat, + isHovered: Bool, + isPressed: Bool, + appearance: WorkspaceLayoutConfiguration.Appearance + ) { + self.symbolName = symbolName + self.pointSize = pointSize + self.isHovered = isHovered + self.isPressed = isPressed + self.splitAppearance = appearance + super.init(frame: frameRect) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update( + symbolName: String, + pointSize: CGFloat, + isHovered: Bool, + isPressed: Bool, + appearance: WorkspaceLayoutConfiguration.Appearance + ) { + self.symbolName = symbolName + self.pointSize = pointSize + self.isHovered = isHovered + self.isPressed = isPressed + self.splitAppearance = appearance + needsDisplay = true + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + let buttonRect = CGRect( + x: floor((bounds.width - TabBarMetrics.closeButtonSize) / 2), + y: floor((bounds.height - TabBarMetrics.closeButtonSize) / 2), + width: TabBarMetrics.closeButtonSize, + height: TabBarMetrics.closeButtonSize + ) + workspaceSplitDrawAccessoryButton( + in: buttonRect, + symbolName: symbolName, + pointSize: pointSize, + isHovered: isHovered, + isPressed: isPressed, + appearance: splitAppearance + ) + } +} + +final class WorkspaceLayoutNativeTabButtonDebugPreviewHost: NSView { + let button = WorkspaceLayoutNativeTabButtonView(frame: .zero) + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + addSubview(button) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update( + title: String, + icon: String?, + kind: WorkspaceLayoutTabKind, + appearance: WorkspaceLayoutConfiguration.Appearance, + isSelected: Bool, + isHovered: Bool, + isCloseHovered: Bool, + isClosePressed: Bool + ) { + let tab = WorkspaceLayout.Tab.rendered( + title: title, + icon: icon, + kind: kind + ) + let contextMenuState = TabContextMenuState( + isPinned: false, + isUnread: false, + isBrowser: kind == .browser, + isTerminal: kind == .terminal, + hasCustomTitle: false, + canCloseToLeft: false, + canCloseToRight: false, + canCloseOthers: false, + canMoveToLeftPane: false, + canMoveToRightPane: false, + isZoomed: false, + hasSplits: false, + shortcuts: [:] + ) + button.update( + tab: tab, + paneId: PaneID(), + isSelected: isSelected, + isPaneFocused: true, + showsZoomIndicator: false, + controlShortcutDigit: nil, + showsControlShortcutHint: false, + shortcutModifierSymbol: "⌃", + appearance: appearance, + contextMenuState: contextMenuState, + onSelect: {}, + onClose: {}, + onZoomToggle: {}, + onContextAction: { _ in }, + onBeginTabDrag: {}, + onCancelTabDrag: {} + ) + button.configureDebugInteractionState( + isHovered: isHovered, + isCloseHovered: isCloseHovered, + isClosePressed: isClosePressed, + isZoomHovered: false, + isZoomPressed: false + ) + let width = button.preferredWidth( + minWidth: appearance.tabMinWidth, + maxWidth: appearance.tabMaxWidth + ) + frame.size = CGSize(width: width, height: TabBarMetrics.tabHeight) + button.frame = CGRect(x: 0, y: 0, width: width, height: TabBarMetrics.tabHeight) + invalidateIntrinsicContentSize() + needsLayout = true + needsDisplay = true + } + + override var intrinsicContentSize: NSSize { + CGSize(width: max(1, button.frame.width), height: TabBarMetrics.tabHeight) + } +} + +@MainActor +func workspaceLayoutTabChromeDebugDiff( + appKitImage: NSImage, + referenceImage: NSImage +) -> (image: NSImage, metrics: WorkspaceLayoutTabChromeDebugDiffMetrics)? { + guard let appKitBuffer = workspaceLayoutRGBAImageBuffer(from: appKitImage), + let referenceBuffer = workspaceLayoutRGBAImageBuffer(from: referenceImage) else { + return nil + } + + let width = max(appKitBuffer.width, referenceBuffer.width) + let height = max(appKitBuffer.height, referenceBuffer.height) + guard let resizedAppKit = workspaceLayoutResizeImageBuffer(appKitBuffer, width: width, height: height), + let resizedReference = workspaceLayoutResizeImageBuffer(referenceBuffer, width: width, height: height) else { + return nil + } + + var diffBytes = [UInt8](repeating: 0, count: width * height * 4) + var differingPixelCount = 0 + var maxChannelDelta = 0 + var totalChannelDelta = 0 + + for pixelIndex in 0..<(width * height) { + let base = pixelIndex * 4 + let rDelta = abs(Int(resizedAppKit.bytes[base]) - Int(resizedReference.bytes[base])) + let gDelta = abs(Int(resizedAppKit.bytes[base + 1]) - Int(resizedReference.bytes[base + 1])) + let bDelta = abs(Int(resizedAppKit.bytes[base + 2]) - Int(resizedReference.bytes[base + 2])) + let aDelta = abs(Int(resizedAppKit.bytes[base + 3]) - Int(resizedReference.bytes[base + 3])) + let pixelMax = max(rDelta, gDelta, bDelta, aDelta) + if pixelMax > 0 { + differingPixelCount += 1 + } + maxChannelDelta = max(maxChannelDelta, pixelMax) + totalChannelDelta += rDelta + gDelta + bDelta + aDelta + + diffBytes[base] = UInt8(clamping: rDelta) + diffBytes[base + 1] = UInt8(clamping: gDelta) + diffBytes[base + 2] = UInt8(clamping: bDelta) + diffBytes[base + 3] = pixelMax == 0 ? 0 : 255 + } + + guard let diffImage = workspaceLayoutImageFromRGBABytes( + diffBytes, + width: width, + height: height + ) else { + return nil + } + + let totalPixels = width * height + let meanAbsoluteChannelDelta = totalPixels == 0 + ? 0 + : Double(totalChannelDelta) / Double(totalPixels * 4) + let metrics = WorkspaceLayoutTabChromeDebugDiffMetrics( + width: width, + height: height, + differingPixelCount: differingPixelCount, + totalPixelCount: totalPixels, + maxChannelDelta: maxChannelDelta, + meanAbsoluteChannelDelta: meanAbsoluteChannelDelta, + matchingPixels: differingPixelCount == 0 + ) + return (diffImage, metrics) +} + +@MainActor +func workspaceLayoutExportTabChromeDebugArtifacts( + to directory: URL, + scale: CGFloat = 2 +) throws -> WorkspaceLayoutTabChromeDebugExportManifest { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + + var scenarioResults: [WorkspaceLayoutTabChromeDebugExportScenarioResult] = [] + for scenario in workspaceLayoutTabChromeDebugScenarios() { + guard let appKitImage = workspaceLayoutRenderAppKitTabChromeImage(scenario: scenario, scale: scale) else { + continue + } + + let appKitName = "\(scenario.id)-appkit.png" + try workspaceLayoutWritePNG(image: appKitImage, to: directory.appendingPathComponent(appKitName)) + scenarioResults.append( + WorkspaceLayoutTabChromeDebugExportScenarioResult( + id: scenario.id, + title: scenario.title, + appKitPNG: appKitName, + scenario: workspaceLayoutTabChromeDebugScenarioSpec(from: scenario) + ) + ) + } + + let formatter = ISO8601DateFormatter() + let manifest = WorkspaceLayoutTabChromeDebugExportManifest( + generatedAt: formatter.string(from: Date()), + scenarioResults: scenarioResults + ) + let manifestURL = directory.appendingPathComponent("manifest.json") + let data = try JSONEncoder().encode(manifest) + try data.write(to: manifestURL, options: .atomic) + return manifest +} + +func workspaceLayoutTabChromeDebugScenarioSpec( + from scenario: WorkspaceLayoutTabChromeDebugScenario +) -> WorkspaceLayoutTabChromeDebugScenarioSpec { + WorkspaceLayoutTabChromeDebugScenarioSpec( + tab: WorkspaceLayoutTabChromeDebugTabSpec( + id: scenario.tab.id.id, + title: scenario.tab.title, + hasCustomTitle: scenario.tab.hasCustomTitle, + icon: scenario.tab.icon, + iconImageDataBase64: scenario.tab.iconImageData?.base64EncodedString(), + kind: scenario.tab.kind, + isDirty: scenario.tab.isDirty, + showsNotificationBadge: scenario.tab.showsNotificationBadge, + isLoading: scenario.tab.isLoading, + isPinned: scenario.tab.isPinned + ), + isSelected: scenario.isSelected, + isHovered: scenario.isHovered, + isCloseHovered: scenario.isCloseHovered, + isClosePressed: scenario.isClosePressed, + showsZoomIndicator: scenario.showsZoomIndicator, + isZoomHovered: scenario.isZoomHovered, + isZoomPressed: scenario.isZoomPressed, + appearance: WorkspaceLayoutTabChromeDebugAppearanceSpec( + tabBarHeight: Double(scenario.appearance.tabBarHeight), + tabMinWidth: Double(scenario.appearance.tabMinWidth), + tabMaxWidth: Double(scenario.appearance.tabMaxWidth), + tabTitleFontSize: Double(scenario.appearance.tabTitleFontSize), + tabSpacing: Double(scenario.appearance.tabSpacing), + minimumPaneWidth: Double(scenario.appearance.minimumPaneWidth), + minimumPaneHeight: Double(scenario.appearance.minimumPaneHeight), + showSplitButtons: scenario.appearance.showSplitButtons, + splitButtonsOnHover: scenario.appearance.splitButtonsOnHover, + tabBarLeadingInset: Double(scenario.appearance.tabBarLeadingInset), + splitButtonTooltips: WorkspaceLayoutTabChromeDebugSplitButtonTooltipsSpec( + newTerminal: scenario.appearance.splitButtonTooltips.newTerminal, + newBrowser: scenario.appearance.splitButtonTooltips.newBrowser, + splitRight: scenario.appearance.splitButtonTooltips.splitRight, + splitDown: scenario.appearance.splitButtonTooltips.splitDown + ), + animationDuration: scenario.appearance.animationDuration, + enableAnimations: scenario.appearance.enableAnimations, + chromeColors: WorkspaceLayoutTabChromeDebugChromeColorsSpec( + backgroundHex: scenario.appearance.chromeColors.backgroundHex, + borderHex: scenario.appearance.chromeColors.borderHex + ) + ) + ) +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutTabChromeDebugScenarios.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutTabChromeDebugScenarios.swift new file mode 100644 index 000000000000..3cc6fe4fa1f3 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutTabChromeDebugScenarios.swift @@ -0,0 +1,201 @@ +import AppKit +import SwiftUI + +@MainActor +func workspaceLayoutTabChromeDebugScenarios() -> [WorkspaceLayoutTabChromeDebugScenario] { + let baseAppearance = workspaceLayoutTabChromeDebugAppearance() + let terminalTab = WorkspaceLayout.Tab.rendered( + title: "~/fun/cmuxterm-hq", + icon: "terminal.fill", + kind: .terminal + ) + + let scenarios = [ + WorkspaceLayoutTabChromeDebugScenario( + id: "selected-idle", + title: "Selected Idle", + tab: terminalTab, + isSelected: true, + isHovered: false, + isCloseHovered: false, + isClosePressed: false, + showsZoomIndicator: false, + isZoomHovered: false, + isZoomPressed: false, + appearance: baseAppearance + ), + WorkspaceLayoutTabChromeDebugScenario( + id: "selected-close-hover", + title: "Selected Close Hover", + tab: terminalTab, + isSelected: true, + isHovered: false, + isCloseHovered: true, + isClosePressed: false, + showsZoomIndicator: false, + isZoomHovered: false, + isZoomPressed: false, + appearance: baseAppearance + ), + WorkspaceLayoutTabChromeDebugScenario( + id: "selected-close-pressed", + title: "Selected Close Pressed", + tab: terminalTab, + isSelected: true, + isHovered: false, + isCloseHovered: true, + isClosePressed: true, + showsZoomIndicator: false, + isZoomHovered: false, + isZoomPressed: false, + appearance: baseAppearance + ), + WorkspaceLayoutTabChromeDebugScenario( + id: "unselected-idle", + title: "Unselected Idle", + tab: terminalTab, + isSelected: false, + isHovered: false, + isCloseHovered: false, + isClosePressed: false, + showsZoomIndicator: false, + isZoomHovered: false, + isZoomPressed: false, + appearance: baseAppearance + ), + WorkspaceLayoutTabChromeDebugScenario( + id: "unselected-hover", + title: "Unselected Hover", + tab: terminalTab, + isSelected: false, + isHovered: true, + isCloseHovered: false, + isClosePressed: false, + showsZoomIndicator: false, + isZoomHovered: false, + isZoomPressed: false, + appearance: baseAppearance + ), + WorkspaceLayoutTabChromeDebugScenario( + id: "unselected-close-hover", + title: "Unselected Close Hover", + tab: terminalTab, + isSelected: false, + isHovered: true, + isCloseHovered: true, + isClosePressed: false, + showsZoomIndicator: false, + isZoomHovered: false, + isZoomPressed: false, + appearance: baseAppearance + ), + WorkspaceLayoutTabChromeDebugScenario( + id: "dirty-unread", + title: "Dirty + Unread", + tab: WorkspaceLayout.Tab.rendered( + title: "~/fun/cmuxterm-hq", + icon: "terminal.fill", + kind: .terminal, + isDirty: true, + showsNotificationBadge: true + ), + isSelected: false, + isHovered: false, + isCloseHovered: false, + isClosePressed: false, + showsZoomIndicator: false, + isZoomHovered: false, + isZoomPressed: false, + appearance: baseAppearance + ), + WorkspaceLayoutTabChromeDebugScenario( + id: "pinned", + title: "Pinned", + tab: WorkspaceLayout.Tab.rendered( + title: "Pinned shell", + icon: "terminal.fill", + kind: .terminal, + isPinned: true + ), + isSelected: false, + isHovered: false, + isCloseHovered: false, + isClosePressed: false, + showsZoomIndicator: false, + isZoomHovered: false, + isZoomPressed: false, + appearance: baseAppearance + ), + WorkspaceLayoutTabChromeDebugScenario( + id: "zoomed", + title: "Zoomed", + tab: terminalTab, + isSelected: true, + isHovered: false, + isCloseHovered: false, + isClosePressed: false, + showsZoomIndicator: true, + isZoomHovered: false, + isZoomPressed: false, + appearance: baseAppearance + ), + WorkspaceLayoutTabChromeDebugScenario( + id: "loading", + title: "Loading", + tab: WorkspaceLayout.Tab.rendered( + title: "Loading shell", + icon: "terminal.fill", + kind: .terminal, + isLoading: true + ), + isSelected: true, + isHovered: false, + isCloseHovered: false, + isClosePressed: false, + showsZoomIndicator: false, + isZoomHovered: false, + isZoomPressed: false, + appearance: baseAppearance + ), + WorkspaceLayoutTabChromeDebugScenario( + id: "long-title", + title: "Long Title", + tab: WorkspaceLayout.Tab.rendered( + title: "lawrence@Mac:~/fun/cmuxterm-hq", + icon: "terminal.fill", + kind: .terminal + ), + isSelected: true, + isHovered: false, + isCloseHovered: false, + isClosePressed: false, + showsZoomIndicator: false, + isZoomHovered: false, + isZoomPressed: false, + appearance: baseAppearance + ), + ] + +#if DEBUG + let environment = ProcessInfo.processInfo.environment + if let rawFilter = environment["CMUX_WORKSPACE_TAB_CHROME_SCENARIO_IDS"]? + .split(separator: ",") + .map({ $0.trimmingCharacters(in: .whitespacesAndNewlines) }) + .filter({ !$0.isEmpty }), + !rawFilter.isEmpty { + let allowed = Set(rawFilter) + return scenarios.filter { allowed.contains($0.id) } + } +#endif + + return scenarios +} + +@MainActor +func workspaceLayoutTabChromeDebugAppearance() -> WorkspaceLayoutConfiguration.Appearance { + WorkspaceLayoutConfiguration.Appearance( + chromeColors: .init( + backgroundHex: "#111111" + ) + ) +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutTabChromeDebugSettings.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutTabChromeDebugSettings.swift new file mode 100644 index 000000000000..13a72c4c17be --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutTabChromeDebugSettings.swift @@ -0,0 +1,270 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +enum WorkspaceLayoutTabChromeDebugSettings { + static let closeGlyphDXKey = "workspaceTabChrome.closeGlyphDX" + static let closeGlyphDYKey = "workspaceTabChrome.closeGlyphDY" + static let closeCircleDXKey = "workspaceTabChrome.closeCircleDX" + static let closeCircleDYKey = "workspaceTabChrome.closeCircleDY" + static let closeCircleSizeDeltaKey = "workspaceTabChrome.closeCircleSizeDelta" + static let defaultCloseGlyphDX = 0.0 + static let defaultCloseGlyphDY = 0.0 + static let defaultCloseCircleDX = 0.0 + static let defaultCloseCircleDY = 0.0 + static let defaultCloseCircleSizeDelta = 0.0 + static let closeGlyphOffsetRange: ClosedRange = -4...4 + static let closeCircleSizeDeltaRange: ClosedRange = -6...8 + + static func clamped(_ value: Double) -> Double { + min(max(value, closeGlyphOffsetRange.lowerBound), closeGlyphOffsetRange.upperBound) + } + + static func clampedCircleSizeDelta(_ value: Double) -> Double { + min(max(value, closeCircleSizeDeltaRange.lowerBound), closeCircleSizeDeltaRange.upperBound) + } + + static func closeGlyphDX(userDefaults: UserDefaults = .standard) -> CGFloat { + CGFloat( + clamped( + userDefaults.object(forKey: closeGlyphDXKey) as? Double + ?? defaultCloseGlyphDX + ) + ) + } + + static func closeGlyphDY(userDefaults: UserDefaults = .standard) -> CGFloat { + CGFloat( + clamped( + userDefaults.object(forKey: closeGlyphDYKey) as? Double + ?? defaultCloseGlyphDY + ) + ) + } + + static func closeCircleDX(userDefaults: UserDefaults = .standard) -> CGFloat { + CGFloat( + clamped( + userDefaults.object(forKey: closeCircleDXKey) as? Double + ?? defaultCloseCircleDX + ) + ) + } + + static func closeCircleDY(userDefaults: UserDefaults = .standard) -> CGFloat { + CGFloat( + clamped( + userDefaults.object(forKey: closeCircleDYKey) as? Double + ?? defaultCloseCircleDY + ) + ) + } + + static func closeCircleSizeDelta(userDefaults: UserDefaults = .standard) -> CGFloat { + CGFloat( + clampedCircleSizeDelta( + userDefaults.object(forKey: closeCircleSizeDeltaKey) as? Double + ?? defaultCloseCircleSizeDelta + ) + ) + } +} + +enum WorkspaceLayoutTabChromeAccessoryMetrics { + static let baseDY = CGFloat(-0.359375) + static let basePointSizeDelta = CGFloat(0.09375) + static let baseCloseCircleDY = CGFloat(-1) +} + +func workspaceLayoutDebugPreview(_ text: String, limit: Int = 120) -> String { + let escaped = text + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + .replacingOccurrences(of: "\t", with: "\\t") + if escaped.count <= limit { + return escaped + } + return "\(escaped.prefix(limit))..." +} + +struct WorkspaceLayoutTabChromeDebugTuning { + let titleDX: CGFloat + let titleDY: CGFloat + let titlePointSizeDelta: CGFloat + let titleKern: CGFloat + let iconDX: CGFloat + let iconDY: CGFloat + let iconPointSizeDelta: CGFloat + let accessoryDX: CGFloat + let accessoryDY: CGFloat + let accessoryPointSizeDelta: CGFloat + let closeGlyphDX: CGFloat + let closeGlyphDY: CGFloat + let closeCircleDX: CGFloat + let closeCircleDY: CGFloat + let closeCircleSizeDelta: CGFloat + + static var current: WorkspaceLayoutTabChromeDebugTuning { + WorkspaceLayoutTabChromeDebugTuning() + } + + init(environment: [String: String] = ProcessInfo.processInfo.environment) { + titleDX = Self.cgFloat("CMUX_TAB_CHROME_TITLE_DX", environment: environment, defaultValue: 1) + titleDY = Self.cgFloat("CMUX_TAB_CHROME_TITLE_DY", environment: environment, defaultValue: 0.375) + titlePointSizeDelta = Self.cgFloat("CMUX_TAB_CHROME_TITLE_POINT_SIZE_DELTA", environment: environment) + titleKern = Self.cgFloat("CMUX_TAB_CHROME_TITLE_KERN", environment: environment) + iconDX = Self.cgFloat("CMUX_TAB_CHROME_ICON_DX", environment: environment, defaultValue: -1) + iconDY = Self.cgFloat("CMUX_TAB_CHROME_ICON_DY", environment: environment, defaultValue: -0.875) + iconPointSizeDelta = Self.cgFloat("CMUX_TAB_CHROME_ICON_POINT_SIZE_DELTA", environment: environment, defaultValue: -0.5) + accessoryDX = Self.cgFloat("CMUX_TAB_CHROME_ACCESSORY_DX", environment: environment) + accessoryDY = Self.cgFloat( + "CMUX_TAB_CHROME_ACCESSORY_DY", + environment: environment, + defaultValue: WorkspaceLayoutTabChromeAccessoryMetrics.baseDY + ) + accessoryPointSizeDelta = Self.cgFloat( + "CMUX_TAB_CHROME_ACCESSORY_POINT_SIZE_DELTA", + environment: environment, + defaultValue: WorkspaceLayoutTabChromeAccessoryMetrics.basePointSizeDelta + ) + closeGlyphDX = Self.cgFloat( + "CMUX_TAB_CHROME_CLOSE_GLYPH_DX", + environment: environment, + defaultValue: WorkspaceLayoutTabChromeDebugSettings.closeGlyphDX() + ) + closeGlyphDY = Self.cgFloat( + "CMUX_TAB_CHROME_CLOSE_GLYPH_DY", + environment: environment, + defaultValue: WorkspaceLayoutTabChromeDebugSettings.closeGlyphDY() + ) + closeCircleDX = Self.cgFloat( + "CMUX_TAB_CHROME_CLOSE_CIRCLE_DX", + environment: environment, + defaultValue: WorkspaceLayoutTabChromeDebugSettings.closeCircleDX() + ) + closeCircleDY = Self.cgFloat( + "CMUX_TAB_CHROME_CLOSE_CIRCLE_DY", + environment: environment, + defaultValue: WorkspaceLayoutTabChromeAccessoryMetrics.baseCloseCircleDY + + WorkspaceLayoutTabChromeDebugSettings.closeCircleDY() + ) + closeCircleSizeDelta = Self.cgFloat( + "CMUX_TAB_CHROME_CLOSE_CIRCLE_SIZE_DELTA", + environment: environment, + defaultValue: WorkspaceLayoutTabChromeDebugSettings.closeCircleSizeDelta() + ) + } + + static func cgFloat( + _ key: String, + environment: [String: String], + defaultValue: CGFloat = 0 + ) -> CGFloat { + guard let raw = environment[key], let value = Double(raw) else { + return defaultValue + } + return CGFloat(value) + } +} + +enum WorkspaceLayoutTabChromeTitleRenderer: String { + case stringDraw + case textKit + + static let current: WorkspaceLayoutTabChromeTitleRenderer = { +#if DEBUG + let environment = ProcessInfo.processInfo.environment + if let raw = environment["CMUX_TAB_CHROME_TITLE_RENDERER"], + let renderer = WorkspaceLayoutTabChromeTitleRenderer(rawValue: raw) { + return renderer + } +#endif + return .stringDraw + }() +} + +enum WorkspaceLayoutTabChromeTitleDrawMode: String { + case current + case noLeading + case deviceMetrics + case noLeadingDeviceMetrics + case disableScreenFontSubstitution + + private static let disableScreenFontSubstitutionOption = NSString.DrawingOptions(rawValue: 1 << 2) + + static let selected: WorkspaceLayoutTabChromeTitleDrawMode = { +#if DEBUG + let environment = ProcessInfo.processInfo.environment + if let raw = environment["CMUX_TAB_CHROME_TITLE_DRAW_MODE"], + let mode = WorkspaceLayoutTabChromeTitleDrawMode(rawValue: raw) { + return mode + } +#endif + return .current + }() + + var options: NSString.DrawingOptions { + switch self { + case .current: + return [.usesLineFragmentOrigin, .usesFontLeading, .truncatesLastVisibleLine] + case .noLeading: + return [.usesLineFragmentOrigin, .truncatesLastVisibleLine] + case .deviceMetrics: + return [.usesLineFragmentOrigin, .usesFontLeading, .truncatesLastVisibleLine, .usesDeviceMetrics] + case .noLeadingDeviceMetrics: + return [.usesLineFragmentOrigin, .truncatesLastVisibleLine, .usesDeviceMetrics] + case .disableScreenFontSubstitution: + return [.usesLineFragmentOrigin, .usesFontLeading, .truncatesLastVisibleLine, Self.disableScreenFontSubstitutionOption] + } + } +} + +enum WorkspaceLayoutTabChromeContentRenderer: String { + case customDraw + case appKitSubviews + + static let current: WorkspaceLayoutTabChromeContentRenderer = { +#if DEBUG + let environment = ProcessInfo.processInfo.environment + if let raw = environment["CMUX_TAB_CHROME_CONTENT_RENDERER"], + let renderer = WorkspaceLayoutTabChromeContentRenderer(rawValue: raw) { + return renderer + } +#endif + return .appKitSubviews + }() +} + +enum WorkspaceLayoutTabChromeSubviewTitleRenderer: String { + case label + case draw + + static let current: WorkspaceLayoutTabChromeSubviewTitleRenderer = { +#if DEBUG + let environment = ProcessInfo.processInfo.environment + if let raw = environment["CMUX_TAB_CHROME_SUBVIEW_TITLE_RENDERER"], + let renderer = WorkspaceLayoutTabChromeSubviewTitleRenderer(rawValue: raw) { + return renderer + } +#endif + return .draw + }() +} + +enum WorkspaceLayoutTabChromeTitleSource: String { + case auto + case draw + case label + + static let current: WorkspaceLayoutTabChromeTitleSource = { +#if DEBUG + let environment = ProcessInfo.processInfo.environment + if let raw = environment["CMUX_TAB_CHROME_TITLE_SOURCE"], + let source = WorkspaceLayoutTabChromeTitleSource(rawValue: raw) { + return source + } +#endif + return .draw + }() +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutView.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutView.swift new file mode 100644 index 000000000000..704045a98895 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutView.swift @@ -0,0 +1,30 @@ +import SwiftUI + +struct WorkspaceLayoutView: View { + let host: WorkspaceLayoutInteractionHandlers + let renderSnapshot: WorkspaceLayoutRenderSnapshot + let surfaceRegistry: any WorkspaceSurfaceRegistryProtocol + + /// Initialize with a workspace-owned host boundary and the canonical render snapshot. + /// - Parameters: + /// - host: Workspace-owned AppKit host boundary + /// - renderSnapshot: The canonical snapshot resolved by the workspace runtime owner + /// - surfaceRegistry: Workspace-owned retained surface registry + init( + host: WorkspaceLayoutInteractionHandlers, + renderSnapshot: WorkspaceLayoutRenderSnapshot, + surfaceRegistry: any WorkspaceSurfaceRegistryProtocol + ) { + self.host = host + self.renderSnapshot = renderSnapshot + self.surfaceRegistry = surfaceRegistry + } + + var body: some View { + WorkspaceLayoutNativeHost( + hostBridge: host, + renderSnapshot: renderSnapshot, + surfaceRegistry: surfaceRegistry + ) + } +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutViewportHostView.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutViewportHostView.swift new file mode 100644 index 000000000000..c1e17a6e54a5 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceLayoutViewportHostView.swift @@ -0,0 +1,254 @@ +import AppKit + +final class WorkspaceLayoutViewportCanvasView: NSView { + let surfaceRegistry: any WorkspaceSurfaceRegistryProtocol + let debugName: String + var viewportHosts: [WorkspacePaneMountIdentity: WorkspaceLayoutSurfaceViewportHostView] = [:] + + init(surfaceRegistry: any WorkspaceSurfaceRegistryProtocol, debugName: String) { + self.surfaceRegistry = surfaceRegistry + self.debugName = debugName + super.init(frame: .zero) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update( + viewports: [WorkspaceLayoutViewportSnapshot], + presentation _: WorkspaceLayoutPresentationSnapshot, + paneDropZones: [UUID: DropZone?] + ) { + let liveIdentities = Set(viewports.map(\.mountIdentity)) + + for viewport in viewports { + let host = viewportHost(for: viewport.mountIdentity) + host.apply( + snapshot: viewport, + surfaceRegistry: surfaceRegistry, + activeDropZone: paneDropZones[viewport.paneId.id] ?? nil + ) + if host.superview !== self { + addSubview(host) + } + } + + for (identity, host) in viewportHosts where !liveIdentities.contains(identity) { + host.prepareForRemoval(surfaceRegistry: surfaceRegistry) + viewportHosts.removeValue(forKey: identity) + } + } + + func clear(surfaceRegistry: any WorkspaceSurfaceRegistryProtocol) { + for (_, host) in viewportHosts { + host.prepareForRemoval(surfaceRegistry: surfaceRegistry) + } + viewportHosts.removeAll() + } + + func viewportHost( + for identity: WorkspacePaneMountIdentity + ) -> WorkspaceLayoutSurfaceViewportHostView { + if let existing = viewportHosts[identity] { + return existing + } + let host = WorkspaceLayoutSurfaceViewportHostView( + mountIdentity: identity, + surfaceRegistry: surfaceRegistry, + debugCanvasName: debugName + ) + viewportHosts[identity] = host + return host + } + +#if DEBUG + var debugMountedIdentities: Set { + Set(viewportHosts.keys) + } +#endif +} + +@MainActor +final class WorkspaceLayoutViewportRevealCoverView: NSView { + let spinner = NSProgressIndicator(frame: .zero) + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + spinner.style = .spinning + spinner.controlSize = .small + spinner.sizeToFit() + addSubview(spinner) + isHidden = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layout() { + super.layout() + spinner.sizeToFit() + spinner.frame = CGRect( + x: round((bounds.width - spinner.frame.width) / 2), + y: round((bounds.height - spinner.frame.height) / 2), + width: spinner.frame.width, + height: spinner.frame.height + ) + } + + func setVisible(_ visible: Bool) { + isHidden = !visible + if visible { + spinner.startAnimation(nil) + } else { + spinner.stopAnimation(nil) + } + } +} + +@MainActor +final class WorkspaceLayoutSurfaceViewportHostView: NSView { + let mountIdentity: WorkspacePaneMountIdentity + let surfaceRegistry: any WorkspaceSurfaceRegistryProtocol + let debugCanvasName: String + let slotView = WorkspaceLayoutPaneContentSlotView(frame: .zero) + let revealCoverView = WorkspaceLayoutViewportRevealCoverView(frame: .zero) + var mountedContent: WorkspaceLayoutMountedTabEntry? + var currentSnapshot: WorkspaceLayoutViewportSnapshot? + var lastPresentationFacts: WorkspaceSurfacePresentationFacts? + + init( + mountIdentity: WorkspacePaneMountIdentity, + surfaceRegistry: any WorkspaceSurfaceRegistryProtocol, + debugCanvasName: String + ) { + self.mountIdentity = mountIdentity + self.surfaceRegistry = surfaceRegistry + self.debugCanvasName = debugCanvasName + super.init(frame: .zero) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + layer?.masksToBounds = true + addSubview(slotView) + addSubview(revealCoverView) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layout() { + super.layout() + synchronizeSlotFrameToBounds() + revealCoverView.frame = bounds + refreshSurfacePresentation(reason: "layout", force: false) + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + refreshSurfacePresentation(reason: "windowMove", force: true) + } + + func apply( + snapshot: WorkspaceLayoutViewportSnapshot, + surfaceRegistry: any WorkspaceSurfaceRegistryProtocol, + activeDropZone: DropZone? + ) { + frame = snapshot.frame + synchronizeSlotFrameToBounds() + currentSnapshot = snapshot + let nextMountedContent = WorkspaceLayoutMountedTabEntry( + contentId: snapshot.contentId, + content: snapshot.content, + mountIdentity: mountIdentity + ) + + if let mountedContent, + (mountedContent.contentId != nextMountedContent.contentId + || mountedContent.mountIdentity != nextMountedContent.mountIdentity) { + surfaceRegistry.unmountContent( + mountedContent.content, + contentId: mountedContent.contentId, + from: slotView + ) + self.mountedContent = nil + } + + surfaceRegistry.mountContent( + snapshot.content, + contentId: snapshot.contentId, + in: slotView, + activeDropZone: activeDropZone, + onPresentationChange: { [weak self] in + self?.updateRevealState(reason: "presentationChange", force: true) + } + ) + mountedContent = nextMountedContent + refreshSurfacePresentation(reason: "apply", force: true) + } + + #if DEBUG + var debugSlotBounds: CGRect { + slotView.bounds + } + + var debugRevealPhase: WorkspaceSurfaceRevealPhase? { + lastPresentationFacts?.revealPhase + } + + var debugShowsRevealCover: Bool { + !revealCoverView.isHidden + } + #endif + + func prepareForRemoval(surfaceRegistry: any WorkspaceSurfaceRegistryProtocol) { + if let mountedContent { + surfaceRegistry.unmountContent( + mountedContent.content, + contentId: mountedContent.contentId, + from: slotView + ) + } + currentSnapshot = nil + lastPresentationFacts = nil + mountedContent = nil + revealCoverView.setVisible(false) + removeFromSuperview() + } + + func refreshSurfacePresentation(reason: String, force: Bool) { + guard currentSnapshot != nil else { return } + guard let mountedContent else { return } + surfaceRegistry.reconcileViewportLifecycle( + mountedContent.content, + contentId: mountedContent.contentId, + in: slotView, + reason: "viewport.\(reason)" + ) + updateRevealState(reason: reason, force: force) + } + + func updateRevealState(reason: String, force: Bool) { + guard let currentSnapshot else { return } + let nextFacts = surfaceRegistry.presentationFacts( + currentSnapshot.content, + contentId: currentSnapshot.contentId + ) + guard force || nextFacts != lastPresentationFacts else { return } + lastPresentationFacts = nextFacts + revealCoverView.setVisible(nextFacts.showsLoadingCover) + } + + func synchronizeSlotFrameToBounds() { + guard slotView.frame != bounds else { return } + slotView.frame = bounds + } +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspacePaneDropRouting.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspacePaneDropRouting.swift new file mode 100644 index 000000000000..2cce8b22d298 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspacePaneDropRouting.swift @@ -0,0 +1,89 @@ +import CoreGraphics + +struct WorkspacePaneDropZoneDecision: Equatable { + let defaultZone: DropZone + let finalZone: DropZone + let targetPaneId: PaneID + let sourcePaneId: PaneID? + let remapReason: String? +} + +enum WorkspacePaneDropRouting { + static let padding: CGFloat = 4 + + static func overlayFrame(for zone: DropZone, in size: CGSize) -> CGRect { + switch zone { + case .center: + return CGRect( + x: padding, + y: padding, + width: size.width - (padding * 2), + height: size.height - (padding * 2) + ) + case .left: + return CGRect( + x: padding, + y: padding, + width: (size.width / 2) - padding, + height: size.height - (padding * 2) + ) + case .right: + return CGRect( + x: size.width / 2, + y: padding, + width: (size.width / 2) - padding, + height: size.height - (padding * 2) + ) + case .top: + return CGRect( + x: padding, + y: size.height / 2, + width: size.width - (padding * 2), + height: (size.height / 2) - padding + ) + case .bottom: + return CGRect( + x: padding, + y: padding, + width: size.width - (padding * 2), + height: (size.height / 2) - padding + ) + } + } + + static func zone(for location: CGPoint, in size: CGSize) -> DropZone { + let edgeRatio: CGFloat = 0.25 + let horizontalEdge = max(80, size.width * edgeRatio) + let verticalEdge = max(80, size.height * edgeRatio) + + if location.x < horizontalEdge { + return .left + } + if location.x > size.width - horizontalEdge { + return .right + } + if location.y > size.height - verticalEdge { + return .top + } + if location.y < verticalEdge { + return .bottom + } + return .center + } + + static func decision( + for location: CGPoint, + in size: CGSize, + targetPaneId: PaneID, + sourcePaneId: PaneID? + ) -> WorkspacePaneDropZoneDecision { + let defaultZone = zone(for: location, in: size) + return WorkspacePaneDropZoneDecision( + defaultZone: defaultZone, + finalZone: defaultZone, + targetPaneId: targetPaneId, + sourcePaneId: sourcePaneId, + remapReason: nil + ) + } +} diff --git a/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceSurfaceRegistryProtocol.swift b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceSurfaceRegistryProtocol.swift new file mode 100644 index 000000000000..966c608a3af7 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Sources/CMUXWorkspaceSplit/WorkspaceSurfaceRegistryProtocol.swift @@ -0,0 +1,31 @@ +import AppKit +import Foundation + +@MainActor +protocol WorkspaceSurfaceRegistryProtocol: AnyObject { + func mountContent( + _ content: WorkspacePaneContent, + contentId: UUID, + in slotView: WorkspaceLayoutPaneContentSlotView, + activeDropZone: DropZone?, + onPresentationChange: (() -> Void)? + ) + + func unmountContent( + _ content: WorkspacePaneContent, + contentId: UUID, + from slotView: WorkspaceLayoutPaneContentSlotView + ) + + func reconcileViewportLifecycle( + _ content: WorkspacePaneContent, + contentId: UUID, + in slotView: WorkspaceLayoutPaneContentSlotView, + reason: String + ) + + func presentationFacts( + _ content: WorkspacePaneContent, + contentId: UUID + ) -> WorkspaceSurfacePresentationFacts +} diff --git a/Packages/CMUXWorkspaceSplit/Tests/CMUXWorkspaceSplitTests/WorkspacePaneDropRoutingTests.swift b/Packages/CMUXWorkspaceSplit/Tests/CMUXWorkspaceSplitTests/WorkspacePaneDropRoutingTests.swift new file mode 100644 index 000000000000..e4c5ceaa7c60 --- /dev/null +++ b/Packages/CMUXWorkspaceSplit/Tests/CMUXWorkspaceSplitTests/WorkspacePaneDropRoutingTests.swift @@ -0,0 +1,43 @@ +import CoreGraphics +@testable import CMUXWorkspaceSplit +import XCTest + +final class WorkspacePaneDropRoutingTests: XCTestCase { + func testZoneChoosesCenterAwayFromEdges() { + XCTAssertEqual( + WorkspacePaneDropRouting.zone( + for: CGPoint(x: 200, y: 160), + in: CGSize(width: 400, height: 320) + ), + .center + ) + } + + func testDecisionPreservesTargetAndSourcePaneIds() { + let targetPane = PaneID() + let sourcePane = PaneID() + + let decision = WorkspacePaneDropRouting.decision( + for: CGPoint(x: 12, y: 160), + in: CGSize(width: 400, height: 320), + targetPaneId: targetPane, + sourcePaneId: sourcePane + ) + + XCTAssertEqual(decision.defaultZone, .left) + XCTAssertEqual(decision.finalZone, .left) + XCTAssertEqual(decision.targetPaneId, targetPane) + XCTAssertEqual(decision.sourcePaneId, sourcePane) + XCTAssertNil(decision.remapReason) + } + + func testOverlayFrameUsesStableEdgePadding() { + XCTAssertEqual( + WorkspacePaneDropRouting.overlayFrame( + for: .right, + in: CGSize(width: 400, height: 320) + ), + CGRect(x: 200, y: 4, width: 196, height: 312) + ) + } +} diff --git a/Resources/Info.plist b/Resources/Info.plist index 5198ca6191e4..a1cfa1da89cf 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -132,7 +132,7 @@ UTTypeIdentifier com.splittabbar.tabtransfer UTTypeDescription - Bonsplit Tab Transfer + Workspace Tab Transfer UTTypeConformsTo public.data diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index aa5041ed9493..63f2786612ad 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -94203,1016 +94203,2466 @@ "feed.context.you": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "You:" } }, - "ja": { "stringUnit": { "state": "translated", "value": "あなた:" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "You:" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "あなた:" + } + } } }, "feed.context.agent": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Agent:" } }, - "ja": { "stringUnit": { "state": "translated", "value": "エージェント:" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Agent:" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェント:" + } + } } }, "feed.context.plan": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Plan:" } }, - "ja": { "stringUnit": { "state": "translated", "value": "計画:" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Plan:" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "計画:" + } + } } }, "feed.promptEcho": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "You: %@" } }, - "ja": { "stringUnit": { "state": "translated", "value": "あなた: %@" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "You: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "あなた: %@" + } + } } }, "feed.promptEcho.permission": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "You: %@ request from %@" } }, - "ja": { "stringUnit": { "state": "translated", "value": "%2$@からの%1$@リクエスト" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "You: %@ request from %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%2$@からの%1$@リクエスト" + } + } } }, "feed.kind.permission": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "PERMISSION" } }, - "ja": { "stringUnit": { "state": "translated", "value": "権限" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "PERMISSION" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "権限" + } + } } }, "feed.kind.plan": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "PLAN" } }, - "ja": { "stringUnit": { "state": "translated", "value": "プラン" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "PLAN" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プラン" + } + } } }, "feed.kind.exitPlan": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Exit plan" } }, - "ja": { "stringUnit": { "state": "translated", "value": "終了プラン" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Exit plan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "終了プラン" + } + } } }, "feed.kind.question": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Question" } }, - "ja": { "stringUnit": { "state": "translated", "value": "質問" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Question" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "質問" + } + } } }, "feed.kind.question.upper": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "QUESTION" } }, - "ja": { "stringUnit": { "state": "translated", "value": "質問" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "QUESTION" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "質問" + } + } } }, "feed.kind.toolUse": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "TOOL USE" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ツール使用" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "TOOL USE" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ツール使用" + } + } } }, "feed.kind.toolResult": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "TOOL RESULT" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ツール結果" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "TOOL RESULT" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ツール結果" + } + } } }, "feed.kind.prompt": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "PROMPT" } }, - "ja": { "stringUnit": { "state": "translated", "value": "プロンプト" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "PROMPT" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロンプト" + } + } } }, "feed.kind.message": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "MESSAGE" } }, - "ja": { "stringUnit": { "state": "translated", "value": "メッセージ" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "MESSAGE" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メッセージ" + } + } } }, "feed.kind.sessionStart.upper": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "SESSION START" } }, - "ja": { "stringUnit": { "state": "translated", "value": "セッション開始" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "SESSION START" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッション開始" + } + } } }, "feed.kind.sessionEnd.upper": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "SESSION END" } }, - "ja": { "stringUnit": { "state": "translated", "value": "セッション終了" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "SESSION END" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッション終了" + } + } } }, "feed.kind.stop": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "STOP" } }, - "ja": { "stringUnit": { "state": "translated", "value": "停止" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "STOP" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "停止" + } + } } }, "feed.kind.todos": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "TODOS" } }, - "ja": { "stringUnit": { "state": "translated", "value": "TODO" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "TODOS" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "TODO" + } + } } }, "feed.permission.mode.once": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "once" } }, - "ja": { "stringUnit": { "state": "translated", "value": "1回" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "once" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1回" + } + } } }, "feed.permission.mode.always": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "always" } }, - "ja": { "stringUnit": { "state": "translated", "value": "常に" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "always" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "常に" + } + } } }, "feed.permission.mode.all": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "all tools" } }, - "ja": { "stringUnit": { "state": "translated", "value": "全ツール" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "all tools" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "全ツール" + } + } } }, "feed.permission.mode.bypass": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "bypass" } }, - "ja": { "stringUnit": { "state": "translated", "value": "バイパス" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "bypass" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バイパス" + } + } } }, "feed.permission.mode.deny": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "denied" } }, - "ja": { "stringUnit": { "state": "translated", "value": "拒否" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "denied" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "拒否" + } + } } }, "feed.exitplan.mode.bypass": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "bypass" } }, - "ja": { "stringUnit": { "state": "translated", "value": "バイパス" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "bypass" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バイパス" + } + } } }, "feed.exitplan.mode.ultraplan": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "ultraplan" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ウルトラプラン" } } - } - }, - "feed.exitplan.mode.autoAccept": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "auto" } }, - "ja": { "stringUnit": { "state": "translated", "value": "自動" } } - } - }, - "feed.exitplan.mode.manual": { + "en": { + "stringUnit": { + "state": "translated", + "value": "ultraplan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウルトラプラン" + } + } + } + }, + "feed.exitplan.mode.autoAccept": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "auto" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + } + } + }, + "feed.exitplan.mode.manual": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "manual" } }, - "ja": { "stringUnit": { "state": "translated", "value": "手動" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "manual" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "手動" + } + } } }, "feed.exitplan.mode.deny": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "denied" } }, - "ja": { "stringUnit": { "state": "translated", "value": "拒否" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "denied" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "拒否" + } + } } }, "feed.question.multiSelect": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Multi-select" } }, - "ja": { "stringUnit": { "state": "translated", "value": "複数選択" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Multi-select" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "複数選択" + } + } } }, "feed.telemetry.error": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "error" } }, - "ja": { "stringUnit": { "state": "translated", "value": "エラー" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "error" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エラー" + } + } } }, "feed.telemetry.ok": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "ok" } }, - "ja": { "stringUnit": { "state": "translated", "value": "OK" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "ok" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + } } }, "feed.telemetry.sessionStart": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "session start" } }, - "ja": { "stringUnit": { "state": "translated", "value": "セッション開始" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "session start" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッション開始" + } + } } }, "feed.telemetry.sessionEnd": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "session end" } }, - "ja": { "stringUnit": { "state": "translated", "value": "セッション終了" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "session end" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッション終了" + } + } } }, "feed.telemetry.stop": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "stop" } }, - "ja": { "stringUnit": { "state": "translated", "value": "停止" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "stop" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "停止" + } + } } }, "feed.todos.title": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Tasks" } }, - "ja": { "stringUnit": { "state": "translated", "value": "タスク" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Tasks" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タスク" + } + } } }, "feed.todos.moreCompleted": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "... +%lld completed" } }, - "ja": { "stringUnit": { "state": "translated", "value": "... 完了が%lld件" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "... +%lld completed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "... 完了が%lld件" + } + } } }, "feed.todos.collapse": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Collapse" } }, - "ja": { "stringUnit": { "state": "translated", "value": "折りたたむ" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Collapse" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "折りたたむ" + } + } } }, "feed.todos.summary.done": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%lld done" } }, - "ja": { "stringUnit": { "state": "translated", "value": "完了%lld件" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld done" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完了%lld件" + } + } } }, "feed.todos.summary.inProgress": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%lld in progress" } }, - "ja": { "stringUnit": { "state": "translated", "value": "進行中%lld件" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld in progress" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "進行中%lld件" + } + } } }, "feed.todos.summary.open": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%lld open" } }, - "ja": { "stringUnit": { "state": "translated", "value": "未完了%lld件" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld open" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未完了%lld件" + } + } } }, "feed.notification.permission.title": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%@ permission" } }, - "ja": { "stringUnit": { "state": "translated", "value": "%@の権限確認" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ permission" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@の権限確認" + } + } } }, "feed.notification.permission.body": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%@ needs approval" } }, - "ja": { "stringUnit": { "state": "translated", "value": "%@に承認が必要です" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ needs approval" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@に承認が必要です" + } + } } }, "feed.notification.decisionNeeded": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Decision needed" } }, - "ja": { "stringUnit": { "state": "translated", "value": "判断が必要です" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Decision needed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "判断が必要です" + } + } } }, "feed.notification.exitPlan.title": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%@ plan ready" } }, - "ja": { "stringUnit": { "state": "translated", "value": "%@のプラン準備完了" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ plan ready" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@のプラン準備完了" + } + } } }, "feed.notification.exitPlan.body": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Review and approve the plan" } }, - "ja": { "stringUnit": { "state": "translated", "value": "プランを確認して承認してください" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Review and approve the plan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プランを確認して承認してください" + } + } } }, "feed.notification.question.title": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%@ question" } }, - "ja": { "stringUnit": { "state": "translated", "value": "%@からの質問" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ question" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@からの質問" + } + } } }, "feed.notification.question.body": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Agent is asking a question" } }, - "ja": { "stringUnit": { "state": "translated", "value": "エージェントが質問しています" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Agent is asking a question" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントが質問しています" + } + } } }, "feed.notification.permission.allowOnce": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Allow Once" } }, - "ja": { "stringUnit": { "state": "translated", "value": "1回だけ許可" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow Once" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1回だけ許可" + } + } } }, "feed.notification.permission.always": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Always" } }, - "ja": { "stringUnit": { "state": "translated", "value": "常に許可" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Always" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "常に許可" + } + } } }, "feed.notification.permission.deny": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Deny" } }, - "ja": { "stringUnit": { "state": "translated", "value": "拒否" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Deny" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "拒否" + } + } } }, "feed.notification.exitPlan.bypass": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Bypass" } }, - "ja": { "stringUnit": { "state": "translated", "value": "バイパス" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Bypass" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バイパス" + } + } } }, "feed.notification.exitPlan.ultraplan": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Ultraplan" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ウルトラプラン" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Ultraplan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウルトラプラン" + } + } } }, "feed.notification.exitPlan.autoAccept": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Auto" } }, - "ja": { "stringUnit": { "state": "translated", "value": "自動" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + } } }, "feed.notification.exitPlan.manual": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Manual" } }, - "ja": { "stringUnit": { "state": "translated", "value": "手動" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Manual" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "手動" + } + } } }, "feed.notification.question.reply": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Reply" } }, - "ja": { "stringUnit": { "state": "translated", "value": "返信" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Reply" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "返信" + } + } } }, "debug.menu.feedButtonStyleDebug": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Feed Button Style Debug…" } }, - "ja": { "stringUnit": { "state": "translated", "value": "フィードボタンスタイルのデバッグ…" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Feed Button Style Debug…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードボタンスタイルのデバッグ…" + } + } } }, "debug.menu.feedTextEditorDebug": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Feed Text Editor Lab…" } }, - "ja": { "stringUnit": { "state": "translated", "value": "フィードテキストエディタラボ…" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Feed Text Editor Lab…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードテキストエディタラボ…" + } + } } }, "feed.textEditorDebug.windowTitle": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Feed Text Editor Lab" } }, - "ja": { "stringUnit": { "state": "translated", "value": "フィードテキストエディタラボ" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Feed Text Editor Lab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードテキストエディタラボ" + } + } } }, "feed.textEditorDebug.title": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Feed Text Editors" } }, - "ja": { "stringUnit": { "state": "translated", "value": "フィードテキストエディタ" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Feed Text Editors" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードテキストエディタ" + } + } } }, "feed.textEditorDebug.subtitle": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Compare autosizing editors with identical input." } }, - "ja": { "stringUnit": { "state": "translated", "value": "同じ入力で自動サイズ調整エディタを比較します。" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Compare autosizing editors with identical input." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "同じ入力で自動サイズ調整エディタを比較します。" + } + } } }, "feed.textEditorDebug.reset": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Reset" } }, - "ja": { "stringUnit": { "state": "translated", "value": "リセット" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リセット" + } + } } }, "feed.textEditorDebug.placeholder": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Type several lines here..." } }, - "ja": { "stringUnit": { "state": "translated", "value": "ここに複数行を入力..." } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Type several lines here..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ここに複数行を入力..." + } + } } }, "feed.textEditorDebug.metrics": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%lld lines · %lld chars" } }, - "ja": { "stringUnit": { "state": "translated", "value": "%lld 行・%lld 文字" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld lines · %lld chars" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld 行・%lld 文字" + } + } } }, "feed.textEditorDebug.section.swiftui": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "SwiftUI" } }, - "ja": { "stringUnit": { "state": "translated", "value": "SwiftUI" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "SwiftUI" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SwiftUI" + } + } } }, "feed.textEditorDebug.section.appkit": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "AppKit" } }, - "ja": { "stringUnit": { "state": "translated", "value": "AppKit" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "AppKit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AppKit" + } + } } }, "feed.textEditorDebug.variant.swiftUITextField": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "SwiftUI TextField" } }, - "ja": { "stringUnit": { "state": "translated", "value": "SwiftUI TextField" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "SwiftUI TextField" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SwiftUI TextField" + } + } } }, "feed.textEditorDebug.variant.swiftUIMirror": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "SwiftUI Mirror TextEditor" } }, - "ja": { "stringUnit": { "state": "translated", "value": "SwiftUI Mirror TextEditor" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "SwiftUI Mirror TextEditor" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SwiftUI Mirror TextEditor" + } + } } }, "feed.textEditorDebug.variant.appKitDirectSize": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "AppKit Direct, sizeThatFits" } }, - "ja": { "stringUnit": { "state": "translated", "value": "AppKit Direct, sizeThatFits" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "AppKit Direct, sizeThatFits" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AppKit Direct, sizeThatFits" + } + } } }, "feed.textEditorDebug.variant.appKitDirectIntrinsic": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "AppKit Direct, intrinsic" } }, - "ja": { "stringUnit": { "state": "translated", "value": "AppKit Direct, intrinsic" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "AppKit Direct, intrinsic" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AppKit Direct, intrinsic" + } + } } }, "feed.textEditorDebug.variant.appKitScrollSize": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "AppKit ScrollView, sizeThatFits" } }, - "ja": { "stringUnit": { "state": "translated", "value": "AppKit ScrollView, sizeThatFits" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "AppKit ScrollView, sizeThatFits" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AppKit ScrollView, sizeThatFits" + } + } } }, "feed.textEditorDebug.variant.appKitScrollMeasured": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "AppKit ScrollView, measured" } }, - "ja": { "stringUnit": { "state": "translated", "value": "AppKit ScrollView, measured" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "AppKit ScrollView, measured" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AppKit ScrollView, measured" + } + } } }, "feed.buttonDebug.windowTitle": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Feed Button Style" } }, - "ja": { "stringUnit": { "state": "translated", "value": "フィードボタンスタイル" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Feed Button Style" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードボタンスタイル" + } + } } }, "feed.buttonDebug.title": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Feed Buttons" } }, - "ja": { "stringUnit": { "state": "translated", "value": "フィードボタン" } } - } - }, - "feed.buttonDebug.subtitle": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Feed Buttons" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードボタン" + } + } + } + }, + "feed.buttonDebug.subtitle": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Tune every Feed button kind live." } }, - "ja": { "stringUnit": { "state": "translated", "value": "すべてのフィードボタン種別をライブ調整します。" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Tune every Feed button kind live." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべてのフィードボタン種別をライブ調整します。" + } + } } }, "feed.buttonDebug.reset": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Reset" } }, - "ja": { "stringUnit": { "state": "translated", "value": "リセット" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リセット" + } + } } }, "feed.buttonDebug.palette": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Palette" } }, - "ja": { "stringUnit": { "state": "translated", "value": "パレット" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Palette" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パレット" + } + } } }, "feed.buttonDebug.palette.system": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "System" } }, - "ja": { "stringUnit": { "state": "translated", "value": "システム" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "System" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システム" + } + } } }, "feed.buttonDebug.palette.glassNeutral": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Glass Neutral" } }, - "ja": { "stringUnit": { "state": "translated", "value": "Glassニュートラル" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Glass Neutral" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Glassニュートラル" + } + } } }, "feed.buttonDebug.palette.graphite": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Graphite" } }, - "ja": { "stringUnit": { "state": "translated", "value": "グラファイト" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Graphite" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グラファイト" + } + } } }, "feed.buttonDebug.palette.aqua": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Aqua" } }, - "ja": { "stringUnit": { "state": "translated", "value": "アクア" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Aqua" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクア" + } + } } }, "feed.buttonDebug.palette.orchard": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Orchard" } }, - "ja": { "stringUnit": { "state": "translated", "value": "オーチャード" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Orchard" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オーチャード" + } + } } }, "feed.buttonDebug.palette.ember": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Ember" } }, - "ja": { "stringUnit": { "state": "translated", "value": "エンバー" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Ember" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エンバー" + } + } } }, "feed.buttonDebug.palette.contrast": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Contrast" } }, - "ja": { "stringUnit": { "state": "translated", "value": "コントラスト" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Contrast" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コントラスト" + } + } } }, "feed.buttonDebug.palette.light": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Light" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ライト" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Light" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ライト" + } + } } }, "feed.buttonDebug.palette.dark": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Dark" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ダーク" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Dark" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダーク" + } + } } }, "feed.buttonDebug.style": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Style" } }, - "ja": { "stringUnit": { "state": "translated", "value": "スタイル" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Style" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スタイル" + } + } } }, "feed.buttonDebug.style.solid": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Solid" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ソリッド" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Solid" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ソリッド" + } + } } }, "feed.buttonDebug.style.glass": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Raycast Glass" } }, - "ja": { "stringUnit": { "state": "translated", "value": "Raycast Glass" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Raycast Glass" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Raycast Glass" + } + } } }, "feed.buttonDebug.style.standardGlass": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Standard Glass" } }, - "ja": { "stringUnit": { "state": "translated", "value": "標準Glass" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Standard Glass" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "標準Glass" + } + } } }, "feed.buttonDebug.style.standardTintedGlass": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Standard Tinted Glass" } }, - "ja": { "stringUnit": { "state": "translated", "value": "標準Tint Glass" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Standard Tinted Glass" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "標準Tint Glass" + } + } } }, "feed.buttonDebug.style.nativeGlass": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Native Glass" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ネイティブGlass" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Native Glass" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネイティブGlass" + } + } } }, "feed.buttonDebug.style.nativeProminentGlass": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Prominent Glass" } }, - "ja": { "stringUnit": { "state": "translated", "value": "強調Glass" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Prominent Glass" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "強調Glass" + } + } } }, "feed.buttonDebug.style.liquid": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Liquid" } }, - "ja": { "stringUnit": { "state": "translated", "value": "リキッド" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Liquid" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リキッド" + } + } } }, "feed.buttonDebug.style.halo": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Halo" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ハロー" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Halo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ハロー" + } + } } }, "feed.buttonDebug.style.command": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Command" } }, - "ja": { "stringUnit": { "state": "translated", "value": "コマンド" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Command" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コマンド" + } + } } }, "feed.buttonDebug.style.commandLight": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Command Light" } }, - "ja": { "stringUnit": { "state": "translated", "value": "コマンドライト" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Command Light" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コマンドライト" + } + } } }, "feed.buttonDebug.style.outline": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Outline" } }, - "ja": { "stringUnit": { "state": "translated", "value": "アウトライン" } } - } - }, - "feed.buttonDebug.style.flat": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Flat" } }, - "ja": { "stringUnit": { "state": "translated", "value": "フラット" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Outline" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アウトライン" + } + } + } + }, + "feed.buttonDebug.style.flat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Flat" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フラット" + } + } } }, "feed.buttonDebug.applyRaycastGlass": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Apply Raycast Glass" } }, - "ja": { "stringUnit": { "state": "translated", "value": "Raycast Glassを適用" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Apply Raycast Glass" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Raycast Glassを適用" + } + } } }, "feed.buttonDebug.variations": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Variations" } }, - "ja": { "stringUnit": { "state": "translated", "value": "バリエーション" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Variations" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バリエーション" + } + } } }, "feed.buttonDebug.section.base": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Base" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ベース" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Base" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ベース" + } + } } }, "feed.buttonDebug.section.nativeGlass": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Native Glass" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ネイティブGlass" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Native Glass" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネイティブGlass" + } + } } }, "feed.buttonDebug.section.command": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Command" } }, - "ja": { "stringUnit": { "state": "translated", "value": "コマンド" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Command" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コマンド" + } + } } }, "feed.buttonDebug.section.material": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Material" } }, - "ja": { "stringUnit": { "state": "translated", "value": "マテリアル" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Material" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マテリアル" + } + } } }, "feed.buttonDebug.preset.solidClassic": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Solid Classic" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ソリッド定番" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Solid Classic" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ソリッド定番" + } + } } }, "feed.buttonDebug.preset.raycastGlass": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Raycast Glass" } }, - "ja": { "stringUnit": { "state": "translated", "value": "Raycast Glass" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Raycast Glass" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Raycast Glass" + } + } } }, "feed.buttonDebug.preset.standardLiquidGlass": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Standard Liquid Glass" } }, - "ja": { "stringUnit": { "state": "translated", "value": "標準Liquid Glass" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Standard Liquid Glass" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "標準Liquid Glass" + } + } } }, "feed.buttonDebug.preset.tintedLiquidGlass": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Tinted Liquid Glass" } }, - "ja": { "stringUnit": { "state": "translated", "value": "Tint付きLiquid Glass" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Tinted Liquid Glass" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tint付きLiquid Glass" + } + } } }, "feed.buttonDebug.preset.nativeGlass": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Native Glass" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ネイティブGlass" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Native Glass" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネイティブGlass" + } + } } }, "feed.buttonDebug.preset.nativeProminentGlass": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Prominent Glass" } }, - "ja": { "stringUnit": { "state": "translated", "value": "強調Glass" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Prominent Glass" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "強調Glass" + } + } } }, "feed.buttonDebug.preset.liquidCapsule": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Liquid Capsule" } }, - "ja": { "stringUnit": { "state": "translated", "value": "リキッドカプセル" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Liquid Capsule" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リキッドカプセル" + } + } } }, "feed.buttonDebug.preset.frostedOutline": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Frosted Outline" } }, - "ja": { "stringUnit": { "state": "translated", "value": "フロストアウトライン" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Frosted Outline" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フロストアウトライン" + } + } } }, "feed.buttonDebug.preset.haloGlow": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Halo Glow" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ハローグロー" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Halo Glow" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ハローグロー" + } + } } }, "feed.buttonDebug.preset.commandDark": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Command Dark" } }, - "ja": { "stringUnit": { "state": "translated", "value": "コマンドダーク" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Command Dark" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コマンドダーク" + } + } } }, "feed.buttonDebug.preset.commandLight": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Command Light" } }, - "ja": { "stringUnit": { "state": "translated", "value": "コマンドライト" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Command Light" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コマンドライト" + } + } } }, "feed.buttonDebug.preset.clearGlass": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Clear Glass" } }, - "ja": { "stringUnit": { "state": "translated", "value": "クリアGlass" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear Glass" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリアGlass" + } + } } }, "feed.buttonDebug.preset.compactGlass": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Compact Glass" } }, - "ja": { "stringUnit": { "state": "translated", "value": "コンパクトGlass" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Compact Glass" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンパクトGlass" + } + } } }, "feed.buttonDebug.preset.nativeBlue": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Native Blue" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ネイティブブルー" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Native Blue" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネイティブブルー" + } + } } }, "feed.buttonDebug.preset.liquidMono": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Liquid Mono" } }, - "ja": { "stringUnit": { "state": "translated", "value": "リキッドモノ" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Liquid Mono" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リキッドモノ" + } + } } }, "feed.buttonDebug.preset.softHalo": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Soft Halo" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ソフトハロー" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Soft Halo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ソフトハロー" + } + } } }, "feed.buttonDebug.preset.hairlineGlass": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Hairline Glass" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ヘアラインGlass" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Hairline Glass" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ヘアラインGlass" + } + } } }, "feed.buttonDebug.preset.minimalFlat": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Minimal Flat" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ミニマルフラット" } } - } + "en": { + "stringUnit": { + "state": "translated", + "value": "Minimal Flat" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ミニマルフラット" + } + } + } }, "feed.buttonDebug.compactRadius": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Compact radius" } }, - "ja": { "stringUnit": { "state": "translated", "value": "コンパクト半径" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Compact radius" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンパクト半径" + } + } } }, "feed.buttonDebug.mediumRadius": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Medium radius" } }, - "ja": { "stringUnit": { "state": "translated", "value": "中サイズ半径" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Medium radius" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "中サイズ半径" + } + } } }, "feed.buttonDebug.horizontalPadding": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Medium horizontal padding" } }, - "ja": { "stringUnit": { "state": "translated", "value": "中サイズ横余白" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Medium horizontal padding" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "中サイズ横余白" + } + } } }, "feed.buttonDebug.compactHorizontalPadding": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Compact horizontal padding" } }, - "ja": { "stringUnit": { "state": "translated", "value": "コンパクト横余白" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Compact horizontal padding" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンパクト横余白" + } + } } }, "feed.buttonDebug.compactVerticalPadding": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Compact vertical padding" } }, - "ja": { "stringUnit": { "state": "translated", "value": "コンパクト縦余白" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Compact vertical padding" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンパクト縦余白" + } + } } }, "feed.buttonDebug.mediumVerticalPadding": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Medium vertical padding" } }, - "ja": { "stringUnit": { "state": "translated", "value": "中サイズ縦余白" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Medium vertical padding" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "中サイズ縦余白" + } + } } }, "feed.buttonDebug.glassTint": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Glass tint" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ガラスの色合い" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Glass tint" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ガラスの色合い" + } + } } }, "feed.buttonDebug.borderWidth": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Border" } }, - "ja": { "stringUnit": { "state": "translated", "value": "境界線" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Border" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "境界線" + } + } } }, "feed.buttonDebug.kind": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Button Kind" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ボタン種別" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Button Kind" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ボタン種別" + } + } } }, "feed.buttonDebug.colors": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Colors" } }, - "ja": { "stringUnit": { "state": "translated", "value": "色" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Colors" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "色" + } + } } }, "feed.buttonDebug.background": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Background" } }, - "ja": { "stringUnit": { "state": "translated", "value": "背景" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Background" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "背景" + } + } } }, "feed.buttonDebug.hover": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Hover" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ホバー" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Hover" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ホバー" + } + } } }, "feed.buttonDebug.foreground": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Foreground" } }, - "ja": { "stringUnit": { "state": "translated", "value": "前景" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Foreground" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "前景" + } + } } }, "feed.buttonDebug.preview": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Preview" } }, - "ja": { "stringUnit": { "state": "translated", "value": "プレビュー" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プレビュー" + } + } } }, "feed.buttonDebug.kind.ghost": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Ghost" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ゴースト" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Ghost" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ゴースト" + } + } } }, "feed.buttonDebug.kind.soft": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Soft" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ソフト" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Soft" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ソフト" + } + } } }, "feed.buttonDebug.kind.dark": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Dark" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ダーク" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Dark" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダーク" + } + } } }, "feed.buttonDebug.kind.light": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Light" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ライト" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Light" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ライト" + } + } } }, "feed.buttonDebug.kind.primary": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Primary" } }, - "ja": { "stringUnit": { "state": "translated", "value": "プライマリ" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Primary" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プライマリ" + } + } } }, "feed.buttonDebug.kind.success": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Success" } }, - "ja": { "stringUnit": { "state": "translated", "value": "成功" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Success" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "成功" + } + } } }, "feed.buttonDebug.kind.warning": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Warning" } }, - "ja": { "stringUnit": { "state": "translated", "value": "警告" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Warning" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "警告" + } + } } }, "feed.buttonDebug.kind.destructive": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Destructive" } }, - "ja": { "stringUnit": { "state": "translated", "value": "破壊的" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Destructive" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "破壊的" + } + } } }, "rightSidebar.mode.pendingHelp": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%@ · %lld pending" } }, - "ja": { "stringUnit": { "state": "translated", "value": "%@ · 保留中%lld件" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ · %lld pending" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ · 保留中%lld件" + } + } } }, "feed.filter.activity": { @@ -95651,6 +97101,641 @@ } } } + }, + "dialog.cmuxConfig.confirmCommand.message": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "هل أنت متأكد أنك تريد تشغيل هذا الأمر؟" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Jeste li sigurni da želite pokrenuti ovu naredbu?" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Er du sikker på, at du vil køre denne kommando?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Möchten Sie diesen Befehl wirklich ausführen?" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Are you sure you want to run this command?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Estás seguro de que deseas ejecutar este comando?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Êtes-vous sûr de vouloir exécuter cette commande ?" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sei sicuro di voler eseguire questo comando?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このコマンドを実行してもよろしいですか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 명령을 실행하시겠습니까?" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Er du sikker på at du vil kjøre denne kommandoen?" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Czy na pewno chcesz uruchomić to polecenie?" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tem certeza de que deseja executar este comando?" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вы уверены, что хотите выполнить эту команду?" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คุณแน่ใจหรือไม่ว่าต้องการรันคำสั่งนี้?" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu komutu çalıştırmak istediğinizden emin misiniz?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确定要运行此命令吗?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "確定要執行此命令嗎?" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Ви впевнені, що хочете виконати цю команду?" + } + } + } + }, + "command.copyIdentifiers.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy IDs" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "IDをコピー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "ID 복사" + } + } + } + }, + "debug.workspaceTabChrome.closeGlyph.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Updates live tabs and the debug previews." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ライブのタブとデバッグプレビューをその場で更新します。" + } + } + } + }, + "debug.workspaceTabChrome.closeGlyph.sectionTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Glyph" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閉じるグリフ" + } + } + } + }, + "debug.workspaceTabChrome.closeCircle.sectionTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Circle" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閉じる円" + } + } + } + }, + "debug.workspaceTabChrome.closeCircle.sizeLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Size" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイズ" + } + } + } + }, + "debug.workspaceTabChrome.closeGlyph.xLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "X" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "X" + } + } + } + }, + "debug.workspaceTabChrome.preview.closeState": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Button" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閉じるボタン" + } + } + } + }, + "debug.workspaceTabChrome.preview.closeState.hover": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hover" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ホバー" + } + } + } + }, + "debug.workspaceTabChrome.preview.closeState.idle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Idle" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通常" + } + } + } + }, + "debug.workspaceTabChrome.preview.closeState.pressed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pressed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "押下" + } + } + } + }, + "debug.workspaceTabChrome.preview.closeHover": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Hover" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閉じるホバー" + } + } + } + }, + "debug.workspaceTabChrome.preview.closePressed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Pressed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閉じる押下" + } + } + } + }, + "debug.workspaceTabChrome.preview.scenarioTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Interactive Preview" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インタラクティブプレビュー" + } + } + } + }, + "debug.workspaceTabChrome.preview.sectionTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Interactive Preview" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インタラクティブプレビュー" + } + } + } + }, + "debug.workspaceTabChrome.preview.selected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Selected" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択中" + } + } + } + }, + "debug.workspaceTabChrome.preview.tabHover": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tab Hover" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブホバー" + } + } + } + }, + "debug.workspaceTabChrome.closeGlyph.yLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Y" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Y" + } + } + } + }, + "workspace.emptyPane": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace content unavailable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースの内容を表示できません" + } + } + } + }, + "workspace.accessibility.pinnedTab": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pinned Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ピン留めされたタブ" + } + } + } + }, + "workspace.menu.closeTabsToLeft": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Tabs to Left" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "左側のタブを閉じる" + } + } + } + }, + "workspace.menu.closeTabsToRight": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Tabs to Right" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右側のタブを閉じる" + } + } + } + }, + "workspace.menu.duplicateTab": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Duplicate Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブを複製" + } + } + } + }, + "workspace.menu.exitZoomPane": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Exit Zoom" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ズームを終了" + } + } + } + }, + "workspace.menu.moveToLeftPane": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move to Left Pane" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "左ペインへ移動" + } + } + } + }, + "workspace.menu.moveToRightPane": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move to Right Pane" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右ペインへ移動" + } + } + } + }, + "workspace.menu.newBrowserTabToRight": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Browser Tab to Right" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右側に新しいブラウザタブ" + } + } + } + }, + "workspace.menu.newTerminalTabToRight": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Terminal Tab to Right" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右側に新しいターミナルタブ" + } + } + } + }, + "workspace.menu.reloadTab": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブを再読み込み" + } + } + } + }, + "workspace.menu.removeCustomTabName": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove Custom Tab Name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタムタブ名を削除" + } + } + } + }, + "workspace.menu.zoomPane": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Zoom Pane" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペインをズーム" + } + } + } } } } diff --git a/Sources/App/ShortcutRoutingSupport.swift b/Sources/App/ShortcutRoutingSupport.swift index 159fc4c93823..f813e10ca873 100644 --- a/Sources/App/ShortcutRoutingSupport.swift +++ b/Sources/App/ShortcutRoutingSupport.swift @@ -92,6 +92,55 @@ func shouldDispatchBrowserArrowViaFirstResponderKeyDown( return normalizedFlags.isEmpty } +func shouldDispatchBrowserSpaceViaFirstResponderKeyDown( + keyCode: UInt16, + firstResponderIsBrowser: Bool, + firstResponderHasMarkedText: Bool = false, + flags: NSEvent.ModifierFlags +) -> Bool { + guard firstResponderIsBrowser else { return false } + guard !firstResponderHasMarkedText else { return false } + guard keyCode == 49 else { return false } + + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + return normalizedFlags.isEmpty || normalizedFlags == [.shift] +} + +func shouldBypassBrowserPlainTextKeyEquivalent( + keyCode: UInt16, + firstResponderIsBrowser: Bool, + firstResponderHasMarkedText: Bool = false, + flags: NSEvent.ModifierFlags, + characters: String?, + charactersIgnoringModifiers: String? +) -> Bool { + guard firstResponderIsBrowser else { return false } + guard !firstResponderHasMarkedText else { return false } + + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + guard normalizedFlags.isEmpty || normalizedFlags == [.shift] else { return false } + + switch keyCode { + case 36, 49, 76, 125, 126: + return false + default: + break + } + + let text = charactersIgnoringModifiers ?? characters ?? "" + guard !text.isEmpty else { return false } + + return text.unicodeScalars.allSatisfy { scalar in + !CharacterSet.controlCharacters.contains(scalar) + && !CharacterSet.newlines.contains(scalar) + && scalar.properties.generalCategory != .privateUse + } +} + func shouldToggleMainWindowFullScreenForCommandControlFShortcut( flags: NSEvent.ModifierFlags, chars: String, @@ -493,9 +542,9 @@ private func browserFindCommandEquivalent(for event: NSEvent) -> BrowserFindComm } } -/// For browser content, let the page try browser-local Find-family commands before cmux's menu fallback. -/// Cmd+F is excluded because cmux chooses terminal, browser, or right-sidebar -/// find from the current focus owner. +/// For browser content, let the page try the Find command family before cmux's menu fallback. +/// This preserves native web-app shortcuts like VS Code's Cmd+F while still allowing cmux's +/// browser find overlay to keep owning its visible Find UI shortcuts. func shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst( _ event: NSEvent, responder: NSResponder? = nil, @@ -505,10 +554,6 @@ func shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst( return false } - if case .find = shortcut { - return false - } - if cmuxIsLikelyWebInspectorResponder(responder) { return false } @@ -543,6 +588,12 @@ func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? { let ghosttyView = cmuxOwningGhosttyView(for: ownerView) { return ghosttyView } + + if !textView.isFieldEditor, + let delegateView = textView.delegate as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: delegateView) { + return ghosttyView + } } var current = responder.nextResponder diff --git a/Sources/App/WorkspaceRuntimeSettings.swift b/Sources/App/WorkspaceRuntimeSettings.swift index b332ccd82399..f9b410750907 100644 --- a/Sources/App/WorkspaceRuntimeSettings.swift +++ b/Sources/App/WorkspaceRuntimeSettings.swift @@ -1,4 +1,5 @@ import Darwin +import CoreGraphics import Foundation enum WorkspaceTitlebarSettings { @@ -35,6 +36,20 @@ enum WorkspacePresentationModeSettings { } } +enum VerticalTabsSidebarLayoutMetrics { + static func workspaceScrollTopVisibilityInset(titlebarHeight: CGFloat, isMinimalMode: Bool) -> CGFloat { + isMinimalMode ? 0 : titlebarHeight + 8 + } + + static func sidebarTopScrimHeight(titlebarHeight: CGFloat, isMinimalMode: Bool) -> CGFloat { + isMinimalMode ? 0 : titlebarHeight + 20 + } + + static func titlebarDragHandleHeight(titlebarHeight: CGFloat, isMinimalMode: Bool) -> CGFloat { + isMinimalMode ? 0 : titlebarHeight + } +} + enum WorkspaceButtonFadeSettings { static let modeKey = "workspaceButtonsFadeMode" static let legacyTitlebarControlsVisibilityModeKey = "titlebarControlsVisibilityMode" diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 1140197d5cc3..29615c3fe5ec 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1,7 +1,5 @@ import AppKit import SwiftUI -import Bonsplit -import CMUXWorkstream import CoreServices import UserNotifications import Sentry @@ -45,114 +43,6 @@ final class MainWindowHostingView: NSHostingView { } } -/// Caches `AXWindows` responses so repeated AX polls can reuse the same -/// snapshot while the app window graph is unchanged. Only `.windows` is -/// cached; `.children` and `.visibleChildren` fall through to AppKit so the -/// menu bar stays present in the accessibility tree for VoiceOver and other -/// AX clients. `.mainWindow` / `.focusedWindow` also fall through, so AppKit -/// remains authoritative on focus transitions. -final class CmuxApplicationAccessibilityHierarchyCache { - enum Resolution { - case passthrough - case handled(Any?) - } - - struct WindowToken: Equatable { - let identity: ObjectIdentifier - let windowNumber: Int - let isVisible: Bool - let isMiniaturized: Bool - } - - struct StateToken: Equatable { - let windows: [WindowToken] - - init(windows: [NSWindow]) { - self.windows = windows.map { - WindowToken( - identity: ObjectIdentifier($0), - windowNumber: $0.windowNumber, - isVisible: $0.isVisible, - isMiniaturized: $0.isMiniaturized - ) - } - } - } - - struct Snapshot { - let windows: [NSWindow] - } - - static let shared = CmuxApplicationAccessibilityHierarchyCache() - - private let notificationCenter: NotificationCenter - private var cachedStateToken: StateToken? - private var cachedSnapshot: Snapshot? - private var windowCloseObserver: NSObjectProtocol? - - init(notificationCenter: NotificationCenter = .default) { - self.notificationCenter = notificationCenter - // Drop strong refs to any window the instant it closes so the cache - // never keeps a closed NSWindow alive between AX polls. - windowCloseObserver = notificationCenter.addObserver( - forName: NSWindow.willCloseNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.invalidate() - } - } - - deinit { - if let windowCloseObserver { - notificationCenter.removeObserver(windowCloseObserver) - } - } - - func invalidate() { - cachedStateToken = nil - cachedSnapshot = nil - } - - func resolve(attribute: NSAccessibility.Attribute, application: NSApplication) -> Resolution { - guard Self.supportsCaching(attribute) else { return .passthrough } - let windows = application.windows - let stateToken = StateToken(windows: windows) - let value = value(for: attribute, stateToken: stateToken) { - Snapshot(windows: windows) - } - return .handled(value) - } - - func value( - for attribute: NSAccessibility.Attribute, - stateToken: StateToken, - builder: () -> Snapshot - ) -> Any? { - guard Self.supportsCaching(attribute) else { return nil } - - let snapshot: Snapshot - if cachedStateToken == stateToken, let cachedSnapshot { - snapshot = cachedSnapshot - } else { - snapshot = builder() - cachedStateToken = stateToken - cachedSnapshot = snapshot - } - - switch attribute.rawValue { - case NSAccessibility.Attribute.windows.rawValue: - return snapshot.windows - default: - return nil - } - } - - private static func supportsCaching(_ attribute: NSAccessibility.Attribute) -> Bool { - attribute.rawValue == NSAccessibility.Attribute.windows.rawValue - } -} - private enum CmuxThemeNotifications { static let reloadConfig = Notification.Name("com.cmuxterm.themes.reload-config") } @@ -163,6 +53,10 @@ func isCommandPaletteFocusStealingTerminalOrBrowserResponder(_ responder: NSResp } if let textView = responder as? NSTextView, !textView.isFieldEditor { + if let delegateView = textView.delegate as? NSView, + isCommandPaletteFocusStealingTerminalOrBrowserView(delegateView) { + return true + } return isCommandPaletteFocusStealingTerminalOrBrowserView(textView) } @@ -219,7 +113,7 @@ enum CmuxTypingTiming { guard event.timestamp > 0 else { return } let delayMs = max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000.0) guard shouldLog(delayMs: delayMs, elapsedMs: nil) else { return } - cmuxDebugLog("typing.delay path=\(path) delayMs=\(format(delayMs)) \(eventFields(event))") + dlog("typing.delay path=\(path) delayMs=\(format(delayMs)) \(eventFields(event))") } @inline(__always) @@ -242,7 +136,7 @@ enum CmuxTypingTiming { if let extra, !extra.isEmpty { line += " \(extra)" } - cmuxDebugLog(line) + dlog(line) } @inline(__always) @@ -276,12 +170,19 @@ enum CmuxTypingTiming { if let extra, !extra.isEmpty { line += " \(extra)" } - cmuxDebugLog(line) + dlog(line) } @inline(__always) private static func eventFields(_ event: NSEvent) -> String { - "eventType=\(event.type.rawValue) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)" + switch event.type { + case .keyDown, .keyUp: + return "eventType=\(event.type.rawValue) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)" + case .flagsChanged: + return "eventType=\(event.type.rawValue) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue)" + default: + return "eventType=\(event.type.rawValue) mods=\(event.modifierFlags.rawValue)" + } } @inline(__always) @@ -364,7 +265,7 @@ final class CmuxMainRunLoopStallMonitor { let currentEvent = NSApp.currentEvent.map { "eventType=\($0.type.rawValue) keyCode=\($0.keyCode) mods=\($0.modifierFlags.rawValue)" } ?? "event=nil" - cmuxDebugLog( + dlog( "runloop.stall gapMs=\(String(format: "%.2f", elapsedMs)) prev=\(label(for: lastActivity)) " + "next=\(label(for: activity)) mode=\(mode) firstResponder=\(firstResponder) \(currentEvent)" ) @@ -504,7 +405,7 @@ final class CmuxMainThreadTurnProfiler { } .joined(separator: " ") - cmuxDebugLog( + dlog( "main.turn.work turnMs=\(String(format: "%.2f", turnMs)) trackedMs=\(String(format: "%.2f", trackedMs)) totalCount=\(totalCount) " + "next=\(label(for: nextActivity)) mode=\(mode) firstResponder=\(firstResponder) \(eventSummary) " + "\(bucketSummary)" @@ -585,14 +486,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self.sidebarState = sidebarState self.sidebarSelectionState = sidebarSelectionState self.fileExplorerState = fileExplorerState - self.cmuxConfigStore = cmuxConfigStore - self.window = window self.keyboardFocusCoordinator = MainWindowFocusController( windowId: windowId, window: window, tabManager: tabManager, fileExplorerState: fileExplorerState ) + self.cmuxConfigStore = cmuxConfigStore + self.window = window } } @@ -638,6 +539,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var workspaceObserver: NSObjectProtocol? private var lifecycleSnapshotObservers: [NSObjectProtocol] = [] private var windowKeyObserver: NSObjectProtocol? + private var windowResignKeyObserver: NSObjectProtocol? private var shortcutMonitor: Any? private var shortcutDefaultsObserver: NSObjectProtocol? private var menuBarVisibilityObserver: NSObjectProtocol? @@ -707,10 +609,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } method_exchangeImplementations(originalMethod, swizzledMethod) }() - private static let didInstallApplicationAccessibilitySwizzle: Void = { + private static let didInstallApplicationSendActionSwizzle: Void = { let targetClass: AnyClass = NSApplication.self - let originalSelector = #selector(NSApplication.accessibilityAttributeValue(_:)) - let swizzledSelector = #selector(NSApplication.cmux_accessibilityAttributeValue(_:)) + let originalSelector = #selector(NSApplication.sendAction(_:to:from:)) + let swizzledSelector = #selector(NSApplication.cmux_sendAction(_:to:from:)) guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { return @@ -724,9 +626,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var jumpUnreadFocusObserver: NSObjectProtocol? private var didSetupTerminalCmdClickUITest = false private var didSetupGotoSplitUITest = false - private var didSetupBonsplitTabDragUITest = false + private var didSetupSplitTabDragUITest = false + private var splitTabDragUITestRecorder: DispatchSourceTimer? private var terminalCmdClickUITestPoller: DispatchSourceTimer? - private var bonsplitTabDragUITestRecorder: DispatchSourceTimer? private var gotoSplitUITestRecorder: DispatchSourceTimer? private var gotoSplitUITestObservers: [NSObjectProtocol] = [] private var didSetupMultiWindowNotificationsUITest = false @@ -794,7 +696,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var startupSessionSnapshot: AppSessionSnapshot? private var didPrepareStartupSessionSnapshot = false private var didAttemptStartupSessionRestore = false - private var isApplyingSessionRestore = false + private var isApplyingStartupSessionRestore = false private var sessionAutosaveTimer: DispatchSourceTimer? private var sessionAutosaveTickInFlight = false private var sessionAutosaveDeferredRetryPending = false @@ -813,8 +715,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var lastSessionAutosavePersistedAt: Date = .distantPast private var lastTypingActivityAt: TimeInterval = 0 private var didHandleExplicitOpenIntentAtStartup = false - private var didScheduleInitialMainWindowBootstrap = false - private var didBootstrapInitialMainWindow = false private var isTerminatingApp = false // Set to true when the user has already confirmed quit via the warning dialog, // so applicationShouldTerminate does not show a second alert. @@ -889,17 +789,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } func application(_ application: NSApplication, open urls: [URL]) { - let authCallbacks = urls.filter(AuthCallbackRouter.isAuthCallbackURL) - for url in authCallbacks { - Task { @MainActor in - do { - try await AuthManager.shared.handleCallbackURL(url) - } catch { - NSLog("auth.callback failed: %@", "\(error)") - } - } - } - let directories = externalOpenDirectories(from: urls) guard !directories.isEmpty else { return } @@ -912,15 +801,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - if hasVisibleMainTerminalWindow() { - _ = synchronizeActiveMainWindowContext(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow) - return true - } - ensureInitialMainWindowIfNeeded() - return true - } - func applicationDidFinishLaunching(_ notification: Notification) { let env = ProcessInfo.processInfo.environment let isRunningUnderXCTest = isRunningUnderXCTest(env) @@ -928,22 +808,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent AppIconLaunchState.markDidFinishLaunching() syncActivationPolicy() - claimAuthCallbackURLSchemes() - - // Install the Feed (workstream) store. Separate from the transport - // wiring: the store is a plain singleton here, and the socket - // `feed.*` V2 verbs in `TerminalController` push into it directly - // via `FeedCoordinator`. - FeedCoordinator.shared.install( - store: WorkstreamStore( - transport: NullWorkstreamTransport(), - persistence: WorkstreamPersistence(fileURL: WorkstreamPersistence.defaultFileURL()) - ) - ) - Task { @MainActor in - await FeedCoordinator.shared.store?.start() - } - DistributedNotificationCenter.default().addObserver( self, selector: #selector(handleThemesReloadNotification(_:)), @@ -957,18 +821,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent name: .reactGrabDidCopySelection, object: nil ) - NotificationCenter.default.addObserver( - self, - selector: #selector(handleFeedRequestFocus(_:)), - name: .feedRequestFocus, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(handleFeedRequestSendText(_:)), - name: .feedRequestSendText, - object: nil - ) #if DEBUG // UI tests run on a shared VM user profile, so persisted shortcuts can drift and make @@ -986,6 +838,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in self?.writeUITestDiagnosticsIfNeeded(stage: "after1s") } + DispatchQueue.main.async { + let env = ProcessInfo.processInfo.environment + if env["CMUX_OPEN_WORKSPACE_TAB_CHROME_DEBUG"] == "1" { + WorkspaceTabChromeDebugWindowController.shared.show() + } + if let snapshotPath = env["CMUX_WORKSPACE_TAB_CHROME_DEBUG_SNAPSHOT_FILE"], + !snapshotPath.isEmpty { + do { + try workspaceTabChromeDebugWriteSnapshot(to: URL(fileURLWithPath: snapshotPath)) + } catch { + NSLog("Workspace tab chrome debug snapshot failed: %@", error.localizedDescription) + } + if env["CMUX_WORKSPACE_TAB_CHROME_SNAPSHOT_ONLY"] == "1" { + NSApp.terminate(nil) + } + } + if let exportPath = env["CMUX_WORKSPACE_TAB_CHROME_EXPORT_DIR"], + !exportPath.isEmpty { + do { + _ = try workspaceLayoutExportTabChromeDebugArtifacts(to: URL(fileURLWithPath: exportPath, isDirectory: true)) + } catch { + NSLog("Workspace tab chrome export failed: %@", error.localizedDescription) + } + if env["CMUX_WORKSPACE_TAB_CHROME_EXPORT_ONLY"] == "1" { + NSApp.terminate(nil) + } + } + } #endif if telemetryEnabled { @@ -1046,6 +926,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } NSWindow.allowsAutomaticWindowTabbing = false disableNativeTabbingShortcut() + retargetNativeSidebarMenuItem() if !isRunningUnderXCTest { ensureApplicationIcon() } @@ -1066,8 +947,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent installShortcutDefaultsObserver() SystemWideHotkeyController.shared.start() NSApp.servicesProvider = self - - scheduleInitialMainWindowBootstrap(debugSource: "didFinishLaunching") #if DEBUG UpdateTestSupport.applyIfNeeded(to: updateController.viewModel) if env["CMUX_UI_TEST_MODE"] == "1" { @@ -1454,13 +1333,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent setupJumpUnreadUITestIfNeeded() setupTerminalCmdClickUITestIfNeeded() setupGotoSplitUITestIfNeeded() - setupBonsplitTabDragUITestIfNeeded() + setupSplitTabDragUITestIfNeeded() setupMultiWindowNotificationsUITestIfNeeded() setupDisplayResolutionUITestDiagnosticsIfNeeded() + // UI tests sometimes don't run SwiftUI `.onAppear` soon enough (or at all) on the VM. + // The automation socket is a core testing primitive, so ensure it's started here when + // we detect XCTest, even if the main view lifecycle is flaky. let env = ProcessInfo.processInfo.environment - if isRunningUnderXCTest(env) || env["CMUX_UI_TEST_MODE"] == "1" { - scheduleUITestSocketSanityCheckIfNeeded() + if isRunningUnderXCTest(env) { + let raw = UserDefaults.standard.string(forKey: SocketControlSettings.appStorageKey) + ?? SocketControlSettings.defaultMode.rawValue + let userMode = SocketControlSettings.migrateMode(raw) + let mode = SocketControlSettings.effectiveMode(userMode: userMode) + if mode != .off { + TerminalController.shared.start( + tabManager: tabManager, + socketPath: SocketControlSettings.socketPath(), + accessMode: mode + ) + scheduleUITestSocketSanityCheckIfNeeded() + } } #endif } @@ -1471,20 +1364,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let env = ProcessInfo.processInfo.environment guard env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_SETUP"] == "1" else { - cmuxDebugLog("cmdclick.ui.setup skip reason=env_missing tag=\(env["CMUX_TAG"] ?? "nil")") + dlog("cmdclick.ui.setup skip reason=env_missing tag=\(env["CMUX_TAG"] ?? "nil")") return } guard let manifestPath = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_PATH"]? .trimmingCharacters(in: .whitespacesAndNewlines), !manifestPath.isEmpty else { - cmuxDebugLog("cmdclick.ui.setup skip reason=missing_manifest_path") + dlog("cmdclick.ui.setup skip reason=missing_manifest_path") return } didSetupTerminalCmdClickUITest = true guard let fixtureDirectory = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_FIXTURE_DIR"]? .trimmingCharacters(in: .whitespacesAndNewlines), !fixtureDirectory.isEmpty else { - cmuxDebugLog("cmdclick.ui.setup error reason=missing_fixture_dir manifest=\(manifestPath)") + dlog("cmdclick.ui.setup error reason=missing_fixture_dir manifest=\(manifestPath)") writeTerminalCmdClickUITestData(at: manifestPath, updates: [ "setupError": "Missing fixture directory" ]) @@ -1525,7 +1418,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent default: resolvedLineFormat = "grid" } - cmuxDebugLog( + dlog( "cmdclick.ui.setup start manifest=\(manifestPath) fixture=\(fixtureDirectory) " + "command=\(commandPath ?? "nil") display=\(resolvedDisplayMode) " + "lineFormat=\(resolvedLineFormat) " + @@ -2092,7 +1985,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent Task { @MainActor in evaluate() } } terminalCmdClickUITestPoller = poller - cmuxDebugLog("cmdclick.ui.setup poller_started manifest=\(manifestPath)") + dlog("cmdclick.ui.setup poller_started manifest=\(manifestPath)") poller.resume() } @@ -2107,7 +2000,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent payload[key] = value } guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else { - cmuxDebugLog("cmdclick.ui.write skip reason=json path=\(path)") + dlog("cmdclick.ui.write skip reason=json path=\(path)") return } do { @@ -2117,7 +2010,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) try data.write(to: url, options: .atomic) } catch { - cmuxDebugLog("cmdclick.ui.write error path=\(path) error=\(error.localizedDescription)") + dlog("cmdclick.ui.write error path=\(path) error=\(error.localizedDescription)") } } @@ -2182,10 +2075,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func prepareStartupSessionSnapshotIfNeeded() { guard !didPrepareStartupSessionSnapshot else { return } didPrepareStartupSessionSnapshot = true - Self.removeLegacyPersistedWindowGeometry() - SessionPersistenceStore.syncManualRestoreSnapshotCache() guard SessionRestorePolicy.shouldAttemptRestore() else { return } - startupSessionSnapshot = SessionPersistenceStore.load() + Self.removeLegacyPersistedWindowGeometry() + let snapshot = SessionPersistenceStore.load() +#if DEBUG + startupLog( + "startup.restore.prepare loaded=\(snapshot != nil ? 1 : 0) " + + "windowCount=\(snapshot?.windows.count ?? 0)" + ) +#endif + startupSessionSnapshot = snapshot } private func persistedWindowGeometry( @@ -2277,11 +2176,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let primaryContext = contextForMainTerminalWindow(primaryWindow) else { return } let startupSnapshot = startupSessionSnapshot +#if DEBUG + startupLog( + "startup.restore.attempt window=\(primaryWindow.windowNumber) " + + "hasSnapshot=\(startupSnapshot != nil ? 1 : 0) " + + "windowCount=\(startupSnapshot?.windows.count ?? 0)" + ) +#endif let primaryWindowSnapshot = startupSnapshot?.windows.first if let primaryWindowSnapshot { - isApplyingSessionRestore = true + isApplyingStartupSessionRestore = true #if DEBUG - cmuxDebugLog( + dlog( "session.restore.start windows=\(startupSnapshot?.windows.count ?? 0) " + "primaryFrame={\(debugSessionRectDescription(primaryWindowSnapshot.frame))} " + "primaryDisplay={\(debugSessionDisplayDescription(primaryWindowSnapshot.display))}" @@ -2313,7 +2219,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent .prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1))) #if DEBUG for (index, windowSnapshot) in additionalWindows.enumerated() { - cmuxDebugLog( + dlog( "session.restore.enqueueAdditional idx=\(index + 1) " + "frame={\(debugSessionRectDescription(windowSnapshot.frame))} " + "display={\(debugSessionDisplayDescription(windowSnapshot.display))}" @@ -2326,90 +2232,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent for windowSnapshot in additionalWindows { _ = self.createMainWindow(sessionWindowSnapshot: windowSnapshot) } - self.completeSessionRestoreOperation(isManualReopen: false) + self.completeStartupSessionRestore() } } else { - completeSessionRestoreOperation(isManualReopen: false) + completeStartupSessionRestore() } } } - private func completeSessionRestoreOperation(isManualReopen: Bool) { - startupSessionSnapshot = nil - isApplyingSessionRestore = false - if Self.shouldSaveSessionSnapshotOnRestoreCompletion(isManualReopen: isManualReopen) { - _ = saveSessionSnapshot(includeScrollback: false) - } - } - - nonisolated static func shouldSaveSessionSnapshotOnRestoreCompletion( - isManualReopen: Bool - ) -> Bool { - !isManualReopen - } - - @discardableResult - func reopenPreviousSession(shouldActivate: Bool = true) -> Bool { - guard let snapshot = SessionPersistenceStore.loadReopenSessionSnapshot() else { - return false - } - - let snapshotWindows = Array( - snapshot.windows.prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) - ) - guard !snapshotWindows.isEmpty else { return false } - - let existingContexts = sortedMainWindowContextsForSessionSnapshot() - isApplyingSessionRestore = true + private func completeStartupSessionRestore() { startupSessionSnapshot = nil - didAttemptStartupSessionRestore = true - - if existingContexts.isEmpty { - for windowSnapshot in snapshotWindows { - _ = createMainWindow( - sessionWindowSnapshot: windowSnapshot, - shouldActivate: shouldActivate - ) - } - } else { - for (context, windowSnapshot) in zip(existingContexts, snapshotWindows) { - applySessionWindowSnapshot( - windowSnapshot, - to: context, - window: context.window ?? windowForMainWindowId(context.windowId) - ) - } - - if snapshotWindows.count > existingContexts.count { - for windowSnapshot in snapshotWindows.dropFirst(existingContexts.count) { - _ = createMainWindow( - sessionWindowSnapshot: windowSnapshot, - shouldActivate: shouldActivate - ) - } - } - - if existingContexts.count > snapshotWindows.count { - for context in existingContexts.dropFirst(snapshotWindows.count) { - _ = closeMainWindow(windowId: context.windowId) - } - } - } - - completeSessionRestoreOperation(isManualReopen: true) - - if shouldActivate, - let primaryWindow = sortedMainWindowContextsForSessionSnapshot() - .compactMap({ $0.window ?? windowForMainWindowId($0.windowId) }) - .first { - primaryWindow.makeKeyAndOrderFront(nil) - setActiveMainWindow(primaryWindow) - NSRunningApplication.current.activate( - options: [.activateAllWindows, .activateIgnoringOtherApps] - ) - } - - return true + isApplyingStartupSessionRestore = false + _ = saveSessionSnapshot(includeScrollback: false) } private func applySessionWindowSnapshot( @@ -2418,7 +2252,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent window: NSWindow? ) { #if DEBUG - cmuxDebugLog( + dlog( "session.restore.apply window=\(context.windowId.uuidString.prefix(8)) " + "liveWin=\(window?.windowNumber ?? -1) " + "snapshotFrame={\(debugSessionRectDescription(snapshot.frame))} " + @@ -2435,7 +2269,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if let restoredFrame = resolvedWindowFrame(from: snapshot), let window { window.setFrame(restoredFrame, display: true) #if DEBUG - cmuxDebugLog( + dlog( "session.restore.frameApplied window=\(context.windowId.uuidString.prefix(8)) " + "applied={\(debugNSRectDescription(window.frame))}" ) @@ -2872,20 +2706,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return (mode: mode, path: SocketControlSettings.socketPath()) } - private func startSocketListenerIfEnabled(tabManager: TabManager, source: String) { - guard let config = socketListenerConfigurationIfEnabled() else { - TerminalController.shared.stop() - return - } - let path = TerminalController.shared.activeSocketPath(preferredPath: config.path) - sentryBreadcrumb("socket.listener.start", category: "socket", data: [ - "mode": config.mode.rawValue, - "path": path, - "source": source - ]) - TerminalController.shared.start(tabManager: tabManager, socketPath: path, accessMode: config.mode) - } - private func restartSocketListenerIfEnabled(source: String) { guard let tabManager, let config = socketListenerConfigurationIfEnabled() else { return } @@ -2911,10 +2731,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent didDisableSuddenTermination = false } - private func sessionAutosaveFingerprint( - includeScrollback: Bool, - restorableAgentIndex: RestorableAgentSessionIndex - ) -> Int? { + private func sessionAutosaveFingerprint(includeScrollback: Bool) -> Int? { guard !includeScrollback else { return nil } var hasher = Hasher() @@ -2925,11 +2742,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent for context in contexts.prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) { hasher.combine(context.windowId) - hasher.combine( - context.tabManager.sessionAutosaveFingerprint( - restorableAgentIndex: restorableAgentIndex - ) - ) + hasher.combine(context.tabManager.sessionAutosaveFingerprint()) hasher.combine(context.sidebarState.isVisible) hasher.combine( Int(SessionPersistencePolicy.sanitizedSidebarWidth(Double(context.sidebarState.persistedWidth)).rounded()) @@ -2953,17 +2766,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } @discardableResult - private func saveSessionSnapshot( - includeScrollback: Bool, - removeWhenEmpty: Bool = false, - restorableAgentIndex: RestorableAgentSessionIndex? = nil - ) -> Bool { - if Self.shouldSkipSessionSaveDuringRestore( - isApplyingSessionRestore: isApplyingSessionRestore, + private func saveSessionSnapshot(includeScrollback: Bool, removeWhenEmpty: Bool = false) -> Bool { + if Self.shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: isApplyingStartupSessionRestore, includeScrollback: includeScrollback ) { #if DEBUG - cmuxDebugLog("session.save.skipped reason=session_restore_in_progress includeScrollback=0") + dlog("session.save.skipped reason=startup_restore_in_progress includeScrollback=0") #endif return false } @@ -2983,10 +2792,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } #endif - guard let snapshot = buildSessionSnapshot( - includeScrollback: includeScrollback, - restorableAgentIndex: restorableAgentIndex - ) else { + guard let snapshot = buildSessionSnapshot(includeScrollback: includeScrollback) else { persistSessionSnapshot( nil, removeWhenEmpty: removeWhenEmpty, @@ -3025,11 +2831,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent !isTerminatingApp } - nonisolated static func shouldSkipSessionSaveDuringRestore( - isApplyingSessionRestore: Bool, + nonisolated static func shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: Bool, includeScrollback: Bool ) -> Bool { - isApplyingSessionRestore && !includeScrollback + isApplyingStartupSessionRestore && !includeScrollback } nonisolated static func shouldRunSessionAutosaveTick(isTerminatingApp: Bool) -> Bool { @@ -3063,7 +2869,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard !sessionAutosaveTickInFlight else { return } if let remainingQuietPeriod = remainingSessionAutosaveTypingQuietPeriod() { #if DEBUG - cmuxDebugLog( + dlog( "session.save.skipped reason=typing_recent includeScrollback=0 source=\(source) " + "retryMs=\(Int((remainingQuietPeriod * 1000).rounded()))" ) @@ -3105,11 +2911,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG let fingerprintStart = ProcessInfo.processInfo.systemUptime #endif - let restorableAgentIndex = RestorableAgentSessionIndex.load() - let autosaveFingerprint = sessionAutosaveFingerprint( - includeScrollback: false, - restorableAgentIndex: restorableAgentIndex - ) + let autosaveFingerprint = sessionAutosaveFingerprint(includeScrollback: false) #if DEBUG fingerprintMs = (ProcessInfo.processInfo.systemUptime - fingerprintStart) * 1000.0 #endif @@ -3122,7 +2924,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent now: now ) { #if DEBUG - cmuxDebugLog( + dlog( "session.save.skipped reason=unchanged_autosave_fingerprint includeScrollback=0 source=\(source)" ) #endif @@ -3132,10 +2934,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG let saveStart = ProcessInfo.processInfo.systemUptime #endif - _ = saveSessionSnapshot( - includeScrollback: false, - restorableAgentIndex: restorableAgentIndex - ) + _ = saveSessionSnapshot(includeScrollback: false) #if DEBUG saveMs = (ProcessInfo.processInfo.systemUptime - saveStart) * 1000.0 #endif @@ -3228,8 +3027,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - private func sortedMainWindowContextsForSessionSnapshot() -> [MainWindowContext] { - mainWindowContexts.values.sorted { lhs, rhs in + private func buildSessionSnapshot(includeScrollback: Bool) -> AppSessionSnapshot? { + let contexts = mainWindowContexts.values.sorted { lhs, rhs in let lhsWindow = lhs.window ?? windowForMainWindowId(lhs.windowId) let rhsWindow = rhs.window ?? windowForMainWindowId(rhs.windowId) let lhsIsKey = lhsWindow?.isKeyWindow ?? false @@ -3239,16 +3038,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } return lhs.windowId.uuidString < rhs.windowId.uuidString } - } - - private func buildSessionSnapshot( - includeScrollback: Bool, - restorableAgentIndex suppliedRestorableAgentIndex: RestorableAgentSessionIndex? = nil - ) -> AppSessionSnapshot? { - let contexts = sortedMainWindowContextsForSessionSnapshot() guard !contexts.isEmpty else { return nil } - let restorableAgentIndex = suppliedRestorableAgentIndex ?? RestorableAgentSessionIndex.load() let windows: [SessionWindowSnapshot] = contexts .prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) @@ -3257,10 +3048,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return SessionWindowSnapshot( frame: window.map { SessionRectSnapshot($0.frame) }, display: displaySnapshot(for: window), - tabManager: context.tabManager.sessionSnapshot( - includeScrollback: includeScrollback, - restorableAgentIndex: restorableAgentIndex - ), + tabManager: context.tabManager.sessionSnapshot(includeScrollback: includeScrollback), sidebar: SessionSidebarSnapshot( isVisible: context.sidebarState.isVisible, selection: SessionSidebarSelection(selection: context.sidebarSelectionState.selection), @@ -3282,14 +3070,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent _ snapshot: AppSessionSnapshot, includeScrollback: Bool ) { - cmuxDebugLog( + dlog( "session.save includeScrollback=\(includeScrollback ? 1 : 0) " + "windows=\(snapshot.windows.count)" ) for (index, windowSnapshot) in snapshot.windows.enumerated() { let workspaceCount = windowSnapshot.tabManager.workspaces.count let selectedWorkspace = windowSnapshot.tabManager.selectedWorkspaceIndex.map(String.init) ?? "nil" - cmuxDebugLog( + dlog( "session.save.window idx=\(index) " + "frame={\(debugSessionRectDescription(windowSnapshot.frame))} " + "display={\(debugSessionDisplayDescription(windowSnapshot.display))} " + @@ -3364,7 +3152,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent existingWindow !== window, existingWindow.isVisible || existingWindow.isMiniaturized { #if DEBUG - cmuxDebugLog( + dlog( "mainWindow.register.duplicateIgnored windowId=\(String(windowId.uuidString.prefix(8))) " + "existing={\(debugWindowToken(existingWindow))} duplicate={\(debugWindowToken(window))}" ) @@ -3419,7 +3207,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent commandPaletteSnapshotByWindowId[windowId] = .empty #if DEBUG - cmuxDebugLog( + dlog( "mainWindow.register windowId=\(String(windowId.uuidString.prefix(8))) window={\(debugWindowToken(window))} manager=\(debugManagerToken(tabManager)) priorActiveMgr=\(priorManagerToken) \(debugShortcutRouteSnapshot())" ) #endif @@ -3434,6 +3222,45 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + private func sortedMainWindowContextsForSessionSnapshot() -> [MainWindowContext] { + mainWindowContexts.values.sorted { lhs, rhs in + let lhsWindow = lhs.window ?? windowForMainWindowId(lhs.windowId) + let rhsWindow = rhs.window ?? windowForMainWindowId(rhs.windowId) + let lhsIsKey = lhsWindow?.isKeyWindow ?? false + let rhsIsKey = rhsWindow?.isKeyWindow ?? false + if lhsIsKey != rhsIsKey { + return lhsIsKey + } + let lhsNumber = lhsWindow?.windowNumber ?? Int.max + let rhsNumber = rhsWindow?.windowNumber ?? Int.max + return lhsNumber < rhsNumber + } + } + + @discardableResult + func ensureInitialMainWindowIfNeeded(shouldActivate: Bool = true) -> UUID { + for context in sortedMainWindowContextsForSessionSnapshot() { + guard let window = resolvedWindow(for: context) else { continue } + if shouldActivate { + if window.isMiniaturized { + window.deminiaturize(nil) + } + window.makeKeyAndOrderFront(nil) + setActiveMainWindow(window) + } + return context.windowId + } + + return createMainWindow(shouldActivate: shouldActivate) + } + + private func hasVisibleMainTerminalWindow() -> Bool { + mainWindowContexts.values.contains { context in + guard let window = resolvedWindow(for: context) else { return false } + return window.isVisible && !window.isMiniaturized + } + } + struct MainWindowSummary { let windowId: UUID let isKeyWindow: Bool @@ -3574,11 +3401,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return windowId } - func locateBonsplitSurface(tabId: UUID) -> (windowId: UUID, workspaceId: UUID, panelId: UUID, tabManager: TabManager)? { - let bonsplitTabId = TabID(uuid: tabId) + func locateSplitSurface(tabId: UUID) -> (windowId: UUID, workspaceId: UUID, panelId: UUID, tabManager: TabManager)? { + let splitTabId = TabID(uuid: tabId) for context in mainWindowContexts.values { for workspace in context.tabManager.tabs { - if let panelId = workspace.panelIdFromSurfaceId(bonsplitTabId) { + if let panelId = workspace.panel(for: splitTabId)?.id { return (context.windowId, workspace.id, panelId, context.tabManager) } } @@ -3605,38 +3432,49 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 return String(format: "%.2f", ms) } - cmuxDebugLog( + dlog( "surface.move.begin panel=\(panelId.uuidString.prefix(5)) targetWs=\(targetWorkspaceId.uuidString.prefix(5)) " + "targetPane=\(targetPane?.id.uuidString.prefix(5) ?? "auto") targetIndex=\(targetIndex.map(String.init) ?? "nil") " + "split=\(splitLabel) focus=\(focus ? 1 : 0) focusWindow=\(focusWindow ? 1 : 0)" ) #endif + let browserMoveBegin = browserPanel(for: panelId) != nil + if browserMoveBegin { + } guard let source = locateSurface(surfaceId: panelId) else { #if DEBUG - cmuxDebugLog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourcePanelNotFound elapsedMs=\(elapsedMs(since: moveStart))") + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourcePanelNotFound elapsedMs=\(elapsedMs(since: moveStart))") #endif + if browserMoveBegin { + } return false } guard let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }) else { #if DEBUG - cmuxDebugLog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourceWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))") + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourceWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))") #endif + if browserMoveBegin { + } return false } guard let destinationManager = tabManagerFor(tabId: targetWorkspaceId) else { #if DEBUG - cmuxDebugLog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationManagerMissing elapsedMs=\(elapsedMs(since: moveStart))") + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationManagerMissing elapsedMs=\(elapsedMs(since: moveStart))") #endif + if browserMoveBegin { + } return false } guard let destinationWorkspace = destinationManager.tabs.first(where: { $0.id == targetWorkspaceId }) else { #if DEBUG - cmuxDebugLog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))") + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))") #endif + if browserMoveBegin { + } return false } #if DEBUG - cmuxDebugLog( + dlog( "surface.move.route panel=\(panelId.uuidString.prefix(5)) sourceWs=\(sourceWorkspace.id.uuidString.prefix(5)) " + "sourceWin=\(source.windowId.uuidString.prefix(5)) destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) " + "sameWorkspace=\(destinationWorkspace.id == sourceWorkspace.id ? 1 : 0)" @@ -3644,13 +3482,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif let resolvedTargetPane = targetPane.flatMap { pane in - destinationWorkspace.bonsplitController.allPaneIds.first(where: { $0 == pane }) - } ?? destinationWorkspace.bonsplitController.focusedPaneId - ?? destinationWorkspace.bonsplitController.allPaneIds.first + destinationWorkspace.paneIds.first(where: { $0 == pane }) + } ?? destinationWorkspace.focusedPaneId + ?? destinationWorkspace.paneIds.first guard let resolvedTargetPane else { #if DEBUG - cmuxDebugLog( + dlog( "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=targetPaneMissing " + "destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" ) @@ -3660,31 +3498,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if destinationWorkspace.id == sourceWorkspace.id { if let splitTarget { - guard let sourceTabId = sourceWorkspace.surfaceIdFromPanelId(panelId), - sourceWorkspace.bonsplitController.splitPane( - resolvedTargetPane, - orientation: splitTarget.orientation, - movingTab: sourceTabId, - insertFirst: splitTarget.insertFirst - ) != nil else { -#if DEBUG - cmuxDebugLog( + guard sourceWorkspace.splitSurfaceByDrag( + panelId: panelId, + inPane: resolvedTargetPane, + orientation: splitTarget.orientation, + insertFirst: splitTarget.insertFirst, + focusNewPane: focus + ) != nil else { +#if DEBUG + dlog( "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sameWorkspaceSplitFailed " + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) split=\(splitLabel) " + "elapsedMs=\(elapsedMs(since: moveStart))" ) #endif + if browserMoveBegin { + } return false } if focus { source.tabManager.focusTab(sourceWorkspace.id, surfaceId: panelId, suppressFlash: true) } #if DEBUG - cmuxDebugLog( + dlog( "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=sameWorkspaceSplit moved=1 " + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" ) #endif + if browserMoveBegin { + } return true } @@ -3695,12 +3537,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent focus: focus ) #if DEBUG - cmuxDebugLog( - "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=sameWorkspaceMove moved=\(moved ? 1 : 0) " + - "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) targetIndex=\(targetIndex.map(String.init) ?? "nil") " + - "elapsedMs=\(elapsedMs(since: moveStart))" + dlog( + "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=sameWorkspaceMove moved=\(moved ? 1 : 0) " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) targetIndex=\(targetIndex.map(String.init) ?? "nil") " + + "elapsedMs=\(elapsedMs(since: moveStart))" ) #endif + if browserMoveBegin { + } return moved } @@ -3712,11 +3556,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let detached = sourceWorkspace.detachSurface(panelId: panelId) else { #if DEBUG - cmuxDebugLog( + dlog( "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=detachFailed " + "elapsedMs=\(elapsedMs(since: moveStart))" ) #endif + if browserMoveBegin { + } return false } #if DEBUG @@ -3737,11 +3583,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent focus: focus ) #if DEBUG - cmuxDebugLog( + dlog( "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=attachFailed " + "detachMs=\(detachMs) elapsedMs=\(elapsedMs(since: moveStart))" ) #endif + if browserMoveBegin { + } return false } #if DEBUG @@ -3753,13 +3601,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG let splitStart = ProcessInfo.processInfo.systemUptime #endif - guard let movedTabId = destinationWorkspace.surfaceIdFromPanelId(panelId), - destinationWorkspace.bonsplitController.splitPane( - resolvedTargetPane, - orientation: splitTarget.orientation, - movingTab: movedTabId, - insertFirst: splitTarget.insertFirst - ) != nil else { + guard destinationWorkspace.splitSurface( + panelId: panelId, + inPane: resolvedTargetPane, + orientation: splitTarget.orientation, + insertFirst: splitTarget.insertFirst, + focusNewPane: focus + ) != nil else { if let detachedFromDestination = destinationWorkspace.detachSurface(panelId: panelId) { rollbackDetachedSurface( detachedFromDestination, @@ -3770,11 +3618,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } #if DEBUG - cmuxDebugLog( + dlog( "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=postAttachSplitFailed " + "detachMs=\(detachMs) attachMs=\(attachMs) elapsedMs=\(elapsedMs(since: moveStart))" ) #endif + if browserMoveBegin { + } return false } #if DEBUG @@ -3813,7 +3663,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } #if DEBUG let focusMs = elapsedMs(since: focusStart) - cmuxDebugLog( + dlog( "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=crossWorkspace moved=1 " + "sourceWs=\(sourceWorkspace.id.uuidString.prefix(5)) destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) " + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) targetIndex=\(targetIndex.map(String.init) ?? "nil") " + @@ -3821,12 +3671,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "cleanupMs=\(cleanupMs) focusMs=\(focusMs) elapsedMs=\(elapsedMs(since: moveStart))" ) #endif + if browserMoveBegin { + } return true } @discardableResult - func moveBonsplitTab( + func moveSplitTab( tabId: UUID, toWorkspace targetWorkspaceId: UUID, targetPane: PaneID? = nil, @@ -3841,23 +3693,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 return String(format: "%.2f", ms) } - cmuxDebugLog( - "surface.moveBonsplit.begin tab=\(tabId.uuidString.prefix(5)) targetWs=\(targetWorkspaceId.uuidString.prefix(5)) " + + dlog( + "surface.movesplit.begin tab=\(tabId.uuidString.prefix(5)) targetWs=\(targetWorkspaceId.uuidString.prefix(5)) " + "targetPane=\(targetPane?.id.uuidString.prefix(5) ?? "auto") targetIndex=\(targetIndex.map(String.init) ?? "nil")" ) #endif - guard let located = locateBonsplitSurface(tabId: tabId) else { + guard let located = locateSplitSurface(tabId: tabId) else { #if DEBUG - cmuxDebugLog( - "surface.moveBonsplit.fail tab=\(tabId.uuidString.prefix(5)) reason=tabNotFound " + + dlog( + "surface.movesplit.fail tab=\(tabId.uuidString.prefix(5)) reason=tabNotFound " + "targetWs=\(targetWorkspaceId.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" ) #endif return false } #if DEBUG - cmuxDebugLog( - "surface.moveBonsplit.located tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " + + dlog( + "surface.movesplit.located tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " + "sourceWs=\(located.workspaceId.uuidString.prefix(5)) sourceWin=\(located.windowId.uuidString.prefix(5))" ) #endif @@ -3871,8 +3723,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent focusWindow: focusWindow ) #if DEBUG - cmuxDebugLog( - "surface.moveBonsplit.end tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " + + dlog( + "surface.movesplit.end tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " + "moved=\(moved ? 1 : 0) elapsedMs=\(elapsedMs(since: moveStart))" ) #endif @@ -3880,7 +3732,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } func tabManagerFor(windowId: UUID) -> TabManager? { - mainWindowContexts.values.first(where: { $0.windowId == windowId })?.tabManager + pruneOrphanedMainWindowContexts() + return mainWindowContexts.values.first(where: { $0.windowId == windowId })?.tabManager } func windowId(for tabManager: TabManager) -> UUID? { @@ -3999,7 +3852,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } NotificationCenter.default.post(name: name, object: targetWindow) #if DEBUG - cmuxDebugLog( + dlog( "shortcut.palette.request source=\(source) " + "target={\(debugWindowToken(targetWindow))} " + "pendingMarked=\(markPending ? 1 : 0)" @@ -4073,7 +3926,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let requestedAt = commandPaletteRecentRequestAtByWindowId[windowId] else { commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) #if DEBUG - cmuxDebugLog("shortcut.palette.pendingPrune windowId=\(windowId.uuidString.prefix(8)) reason=missingTimestamp") + dlog("shortcut.palette.pendingPrune windowId=\(windowId.uuidString.prefix(8)) reason=missingTimestamp") #endif continue } @@ -4082,7 +3935,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) #if DEBUG - cmuxDebugLog( + dlog( "shortcut.palette.pendingPrune windowId=\(windowId.uuidString.prefix(8)) " + "reason=stale ageMs=\(Int(age * 1000))" ) @@ -4159,7 +4012,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if let window = suppressionWindow { endCommandPaletteEscapeSuppression(for: window) #if DEBUG - cmuxDebugLog( + dlog( "shortcut.escape suppressionClear target={\(debugWindowToken(window))} " + "keyUpConsumed=\(didConsume ? 1 : 0)" ) @@ -4169,7 +4022,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent commandPaletteEscapeSuppressionByWindowId.removeAll() commandPaletteEscapeSuppressionStartedAtByWindowId.removeAll() #if DEBUG - cmuxDebugLog("shortcut.escape suppressionClear target={nil} clearedAll=1 keyUpConsumed=\(didConsume ? 1 : 0)") + dlog("shortcut.escape suppressionClear target={nil} clearedAll=1 keyUpConsumed=\(didConsume ? 1 : 0)") #endif return didConsume } @@ -4189,12 +4042,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if !visible, !wasVisible, commandPalettePendingOpenByWindowId[windowId] == true { - cmuxDebugLog( + dlog( "palette.visibility.retainPending " + "window={\(debugWindowToken(window))} visible=0 wasVisible=0 pending=1" ) } #endif + if wasVisible != visible { + NotificationCenter.default.post( + name: .commandPaletteVisibilityDidChange, + object: window, + userInfo: ["visible": visible] + ) + } } func isCommandPaletteVisible(windowId: UUID) -> Bool { @@ -4327,22 +4187,109 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } - func repairFocusedTerminalKeyboardRoutingIfNeeded( - window: NSWindow, - event: NSEvent - ) { - guard event.type == .keyDown else { return } + private func shouldRepairFocusedBrowserKeyboardInput(event: NSEvent) -> Bool { + guard event.type == .keyDown else { return false } let normalizedFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - guard isMainTerminalWindow(window) else { return } + return !normalizedFlags.contains(.command) && + !normalizedFlags.contains(.control) && + !normalizedFlags.contains(.option) + } + + private func shouldPreserveNativeTextResponderForFocusedBrowserKeyRepair( + _ responder: NSResponder?, + in window: NSWindow, + event: NSEvent + ) -> Bool { + guard let responder else { return false } + if responder is NSWindow { + return false + } + if NSWindow.cmuxOwningWebView(for: responder, in: window, event: event) != nil { + return false + } + if let textView = responder as? NSTextView { + return textView.isEditable || textView.isFieldEditor + } + if let textField = responder as? NSTextField { + return textField.isEditable + } + if let control = responder as? NSControl { + return control.currentEditor() != nil + } + return false + } + + func repairFocusedBrowserKeyboardRoutingIfNeeded( + window: NSWindow, + event: NSEvent + ) { + guard shouldRepairFocusedBrowserKeyboardInput(event: event) else { return } + guard isMainTerminalWindow(window) else { return } guard window.attachedSheet == nil else { return } guard !isCommandPaletteEffectivelyVisible(in: window) else { return } - // If the active first responder is owned by a non-terminal interaction surface, - // never re-route the keystroke to the terminal. Symmetric with - // applyFirstResponderIfNeeded's foreign focus guard. if let firstResponder = window.firstResponder, firstResponder is NSText || isRightSidebarFocusResponder(firstResponder, in: window) { return } + guard let context = contextForMainWindow(window) ?? contextForMainTerminalWindow(window), + let workspace = context.tabManager.selectedWorkspace, + let panelId = workspace.focusedPanelId, + let browserPanel = workspace.browserPanel(for: panelId) else { + return + } + guard browserPanel.preferredFocusIntentForActivation() == .browser(.webView) else { return } + + let firstResponder = window.firstResponder + guard !shouldPreserveNativeTextResponderForFocusedBrowserKeyRepair( + firstResponder, + in: window, + event: event + ) else { return } + + let actualFocus = browserPanel.actualFocus(in: window) + switch actualFocus { + case let .browserAddressBar(actualPanelId) where actualPanelId == panelId, + let .browserFindField(actualPanelId) where actualPanelId == panelId: + return + default: + break + } + +#if DEBUG + let before = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "browser.focus.keyRepair.window attempt window=\(ObjectIdentifier(window)) " + + "workspace=\(String(workspace.id.uuidString.prefix(5))) " + + "panel=\(String(panelId.uuidString.prefix(5))) " + + "fr=\(before) actual=\(actualFocus.debugDescription) " + + "keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue)" + ) +#endif + + let repaired = browserPanel.repairWebContentFocusForKeyboardInput( + in: window, + reason: "windowSendEvent" + ) + +#if DEBUG + let after = window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "browser.focus.keyRepair.window result window=\(ObjectIdentifier(window)) " + + "panel=\(String(panelId.uuidString.prefix(5))) " + + "repaired=\(repaired ? 1 : 0) fr=\(after)" + ) +#endif + } + + func repairFocusedTerminalKeyboardRoutingIfNeeded( + window: NSWindow, + event: NSEvent + ) { + guard event.type == .keyDown else { return } + let normalizedFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + guard isMainTerminalWindow(window) else { return } + guard window.attachedSheet == nil else { return } + guard !isCommandPaletteEffectivelyVisible(in: window) else { return } guard let context = contextForMainWindow(window) ?? contextForMainTerminalWindow(window), let workspace = context.tabManager.selectedWorkspace, let panelId = workspace.focusedPanelId, @@ -4370,7 +4317,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let before = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" let target = terminalPanel.hostedView.preferredPanelFocusIntentForActivation() let mode = normalizedFlags.contains(.command) ? "command" : "plain" - cmuxDebugLog( + dlog( "focus.keyRepair attempt window=\(ObjectIdentifier(window)) " + "workspace=\(String(workspace.id.uuidString.prefix(5))) " + "panel=\(String(panelId.uuidString.prefix(5))) " + @@ -4384,7 +4331,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG let after = window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - cmuxDebugLog( + dlog( "focus.keyRepair result window=\(ObjectIdentifier(window)) " + "panel=\(String(panelId.uuidString.prefix(5))) " + "isSurfaceResponder=\(terminalPanel.hostedView.isSurfaceViewFirstResponder() ? 1 : 0) " + @@ -4461,7 +4408,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent refreshedCount += 1 } #if DEBUG - cmuxDebugLog("reload.config.surfaceRefresh source=\(source) count=\(refreshedCount)") + dlog("reload.config.surfaceRefresh source=\(source) count=\(refreshedCount)") #endif } @@ -4581,9 +4528,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent focus: Bool ) { let rollbackPane = sourcePane.flatMap { pane in - workspace.bonsplitController.allPaneIds.first(where: { $0 == pane }) - } ?? workspace.bonsplitController.focusedPaneId - ?? workspace.bonsplitController.allPaneIds.first + workspace.paneIds.first(where: { $0 == pane }) + } ?? workspace.focusedPaneId + ?? workspace.paneIds.first guard let rollbackPane else { return } _ = workspace.attachDetachedSurface( detached, @@ -4650,6 +4597,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) } + private func windowForShortcutWindowNumber(_ windowNumber: Int) -> NSWindow? { + guard windowNumber > 0 else { return nil } + if let window = NSApp.window(withWindowNumber: windowNumber) { + return window + } + return mainWindowContexts.values.lazy.compactMap { context in + let window = context.window ?? self.windowForMainWindowId(context.windowId) + guard window?.windowNumber == windowNumber else { return nil } + return window + }.first + } + private func resolvedWindow(for context: MainWindowContext) -> NSWindow? { if let window = context.window { return window @@ -4780,6 +4739,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + private func pruneOrphanedMainWindowContexts() { + let orphaned = mainWindowContexts.values.filter { context in + windowForMainWindowId(context.windowId) == nil + } + for context in orphaned { + discardOrphanedMainWindowContext(context) + } + } + private func mainWindowId(for window: NSWindow) -> UUID? { if let context = mainWindowContexts[ObjectIdentifier(window)] { return context.windowId @@ -4941,6 +4909,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent isMainTerminalWindow(window) { return window } + if let window = windowForShortcutWindowNumber(event.windowNumber), + isMainTerminalWindow(window) { + return window + } if let keyWindow = NSApp.keyWindow, isMainTerminalWindow(keyWindow) { return keyWindow } @@ -4957,6 +4929,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let eventWindowNumber = event.windowNumber guard eventWindowNumber > 0 else { return nil } return NSApp.window(withWindowNumber: eventWindowNumber) + ?? NSApp.windows.first(where: { $0.windowNumber == eventWindowNumber }) } /// Re-sync app-level active window pointers from the currently focused main terminal window. @@ -4983,7 +4956,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG let beforeManagerToken = debugManagerToken(tabManager) - cmuxDebugLog( + dlog( "shortcut.sync.pre source=\(source) preferred={\(debugWindowToken(preferredWindow))} chosen={\(debugContextToken(context))} \(debugShortcutRouteSnapshot())" ) #endif @@ -4994,7 +4967,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent && sidebarSelectionState === context.sidebarSelectionState if alreadyActive { #if DEBUG - cmuxDebugLog( + dlog( "shortcut.sync.post source=\(source) beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) chosen={\(debugContextToken(context))} nochange=1 \(debugShortcutRouteSnapshot())" ) #endif @@ -5010,7 +4983,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent TerminalController.shared.setActiveTabManager(context.tabManager) } #if DEBUG - cmuxDebugLog( + dlog( "shortcut.sync.post source=\(source) beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) chosen={\(debugContextToken(context))} \(debugShortcutRouteSnapshot())" ) #endif @@ -5058,6 +5031,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func preferredMainWindowContextForShortcuts(event: NSEvent) -> MainWindowContext? { + pruneOrphanedMainWindowContexts() if let context = contextForMainWindow(event.window) { return context } @@ -5068,13 +5042,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return context } if let activeManager = tabManager, - let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { + let activeContext = mainWindowContexts.values.first(where: { + $0.tabManager === activeManager && resolvedWindow(for: $0) != nil + }) { return activeContext } - return mainWindowContexts.values.first + return mainWindowContexts.values.first(where: { resolvedWindow(for: $0) != nil }) } private func preferredRegisteredMainWindowContext(preferredWindow: NSWindow? = nil) -> MainWindowContext? { + pruneOrphanedMainWindowContexts() if let preferredWindow, let context = contextForMainWindow(preferredWindow) { return context @@ -5086,22 +5063,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return context } if let activeManager = tabManager, - let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { + let activeContext = mainWindowContexts.values.first(where: { + $0.tabManager === activeManager && resolvedWindow(for: $0) != nil + }) { return activeContext } - return mainWindowContexts.values.first + return mainWindowContexts.values.first(where: { resolvedWindow(for: $0) != nil }) } private func activateMainWindowContextForShortcutEvent(_ event: NSEvent) { let preferredWindow = mainWindowForShortcutEvent(event) #if DEBUG - cmuxDebugLog( + dlog( "shortcut.activate.pre event=\(NSWindow.keyDescription(event)) preferred={\(debugWindowToken(preferredWindow))} \(debugShortcutRouteSnapshot(event: event))" ) #endif _ = synchronizeActiveMainWindowContext(preferredWindow: preferredWindow) #if DEBUG - cmuxDebugLog( + dlog( "shortcut.activate.post event=\(NSWindow.keyDescription(event)) preferred={\(debugWindowToken(preferredWindow))} \(debugShortcutRouteSnapshot(event: event))" ) #endif @@ -5215,8 +5194,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent keyboardFocusCoordinator(for: window)?.allowsTerminalFocus(workspaceId: workspaceId, panelId: panelId) ?? true } - func syncBonsplitTabShortcutHintEligibility(in window: NSWindow?) { - keyboardFocusCoordinator(for: window)?.syncBonsplitTabShortcutHintEligibility() + func syncWorkspaceSplitTabShortcutHintEligibility(in window: NSWindow?) { + keyboardFocusCoordinator(for: window)?.syncWorkspaceSplitTabShortcutHintEligibility() } fileprivate struct TerminalKeyboardFocusRequest { @@ -5501,179 +5480,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return nil } - func scheduleInitialMainWindowBootstrap(debugSource: String) { - guard !didScheduleInitialMainWindowBootstrap else { return } - didScheduleInitialMainWindowBootstrap = true - DispatchQueue.main.async { [weak self] in - self?.bootstrapInitialMainWindowIfNeeded(debugSource: debugSource) - } - } - - @discardableResult - func bootstrapInitialMainWindowIfNeeded(debugSource: String, shouldActivate: Bool = true) -> UUID { - let windowId = ensureInitialMainWindowIfNeeded(shouldActivate: shouldActivate) - if let manager = tabManagerFor(windowId: windowId) { - startSocketListenerIfEnabled( - tabManager: manager, - source: "bootstrapInitialMainWindow.\(debugSource)" - ) - } - guard !didBootstrapInitialMainWindow else { return windowId } - - didBootstrapInitialMainWindow = true - if ProcessInfo.processInfo.environment["CMUX_UI_TEST_SHOW_SETTINGS"] == "1" { - openPreferencesWindow(debugSource: "uiTestShowSettings.\(debugSource)") - } - return windowId - } - - @discardableResult - func ensureInitialMainWindowIfNeeded(shouldActivate: Bool = true) -> UUID { - for context in sortedMainWindowContextsForSessionSnapshot() { - guard let window = resolvedWindow(for: context) else { continue } - if shouldActivate { - if window.isMiniaturized { - window.deminiaturize(nil) - } - window.makeKeyAndOrderFront(nil) - setActiveMainWindow(window) - } - return context.windowId - } - - return createMainWindow(shouldActivate: shouldActivate) - } - - private func hasVisibleMainTerminalWindow() -> Bool { - mainWindowContexts.values.contains { context in - guard let window = resolvedWindow(for: context) else { return false } - return window.isVisible && !window.isMiniaturized - } - } - - @discardableResult - func performNewWorkspaceAction( - tabManager preferredTabManager: TabManager? = nil, - event: NSEvent? = nil, - debugSource: String = "newWorkspace" - ) -> Bool { - let preferredContext = preferredTabManager.flatMap { mainWindowContext(for: $0) } - let livePreferredContext: MainWindowContext? = { - guard let preferredContext else { return nil } - guard resolvedWindow(for: preferredContext) != nil else { - discardOrphanedMainWindowContext(preferredContext) - return nil - } - return preferredContext - }() - - if mainWindowContexts.isEmpty && livePreferredContext == nil { -#if DEBUG - logWorkspaceCreationRouting( - phase: "fallback_new_window", - source: debugSource, - reason: "no_main_windows", - event: event, - chosenContext: nil - ) -#endif - let windowId = createMainWindow() - if let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { - let initialWorkspace = context.tabManager.selectedWorkspace - _ = executeConfiguredNewWorkspaceCommandIfAvailable( - in: context, - debugSource: debugSource, - replacingInitialWorkspace: initialWorkspace - ) - } - return true - } - - let context = livePreferredContext - ?? preferredMainWindowContextForWorkspaceCreation(event: event, debugSource: debugSource) - - if let context, - executeConfiguredNewWorkspaceCommandIfAvailable(in: context, debugSource: debugSource) { - return true - } - - if let preferredTabManager, - preferredContext == nil || livePreferredContext != nil { - preferredTabManager.addWorkspace() - return true - } - - if addWorkspaceInPreferredMainWindow(event: event, debugSource: debugSource) == nil { -#if DEBUG - logWorkspaceCreationRouting( - phase: "fallback_new_window", - source: debugSource, - reason: "workspace_creation_returned_nil", - event: event, - chosenContext: nil - ) -#endif - openNewMainWindow(nil) - } - return true - } - - private func mainWindowContext(for tabManager: TabManager) -> MainWindowContext? { - mainWindowContexts.values.first(where: { $0.tabManager === tabManager }) - } - - private func executeConfiguredNewWorkspaceCommandIfAvailable( - in context: MainWindowContext, - debugSource: String, - replacingInitialWorkspace initialWorkspace: Workspace? = nil - ) -> Bool { - guard let cmuxConfigStore = context.cmuxConfigStore, - let configured = cmuxConfigStore.resolvedNewWorkspaceCommand() else { - return false - } - guard resolvedWindow(for: context) != nil else { - discardOrphanedMainWindowContext(context) - return false - } - let rawCwd = context.tabManager.selectedWorkspace?.currentDirectory - let baseCwd = (rawCwd?.isEmpty == false) ? rawCwd! - : FileManager.default.homeDirectoryForCurrentUser.path -#if DEBUG - cmuxDebugLog( - "newWorkspace.configCommand source=\(debugSource) " + - "command=\(configured.command.name) windowId=\(String(context.windowId.uuidString.prefix(8)))" - ) -#endif - let initialWorkspaceId = initialWorkspace?.id - let didExecute = CmuxConfigExecutor.execute( - command: configured.command, - tabManager: context.tabManager, - baseCwd: baseCwd, - configSourcePath: configured.sourcePath, - globalConfigPath: cmuxConfigStore.globalConfigPath - ) { [weak self, weak context] in - self?.closeInitialWorkspaceIfNeeded( - initialWorkspaceId: initialWorkspaceId, - in: context - ) - } - return didExecute - } - - private func closeInitialWorkspaceIfNeeded( - initialWorkspaceId: UUID?, - in context: MainWindowContext? - ) { - guard let initialWorkspaceId, - let context, - context.tabManager.tabs.count > 1, - let initialWorkspace = context.tabManager.tabs.first(where: { $0.id == initialWorkspaceId }), - context.tabManager.selectedWorkspace?.id != initialWorkspaceId else { - return - } - context.tabManager.closeWorkspace(initialWorkspace) - } - /// Shows the "Open Folder" panel and creates a workspace for the selected directory. /// Called from both the SwiftUI menu and `handleCustomShortcut`. func showOpenFolderPanel() { @@ -5870,19 +5676,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - private func claimAuthCallbackURLSchemes() { - // Pin the current build as the default for cmux:// and cmux-dev:// - // so the auth-callback deeplink routes back to this app instead of an - // unrelated LaunchServices entry. - let bundleURL = Bundle.main.bundleURL - for scheme in ["cmux", "cmux-dev"] { - NSWorkspace.shared.setDefaultApplication( - at: bundleURL, - toOpenURLsWithScheme: scheme - ) { _ in } - } - } - private func externalOpenDirectories(from urls: [URL]) -> [String] { // LaunchServices can surface the running app bundle on relaunch; ignore self paths so // they do not get treated as explicit folder opens and suppress session restore. @@ -5979,6 +5772,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent event: NSEvent? = nil, debugSource: String = "unspecified" ) -> MainWindowContext? { + pruneOrphanedMainWindowContexts() if let context = mainWindowContext(forShortcutEvent: event, debugSource: debugSource) { return context } @@ -6263,20 +6057,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let sidebarSelectionState = SidebarSelectionState( selection: sessionWindowSnapshot?.sidebar.selection.sidebarSelection ?? .tabs ) - - // Seed the per-window Bonsplit tab-bar leading inset before ContentView first - // renders. The initial workspace is created inside TabManager.init, at which - // point there is no source workspace or prior window inset to inherit from, so - // applyCreationChromeInheritance returns early and leaves the Bonsplit inset - // at 0 — which is wrong in minimal mode with the sidebar collapsed, where the - // native traffic lights need an 80pt reserved strip on the tab bar. Without - // this seed, the first-frame layout can mispaint in the new window until - // ContentView.onAppear eventually runs syncTrafficLightInset (#2737). - let initialTabBarLeadingInset: CGFloat = - (WorkspacePresentationModeSettings.isMinimal() && !sidebarState.isVisible) - ? 80 - : 0 - tabManager.syncWorkspaceTabBarLeadingInset(initialTabBarLeadingInset) let notificationStore = TerminalNotificationStore.shared let cmuxConfigStore = CmuxConfigStore() @@ -6336,9 +6116,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent window.title = "" window.titleVisibility = .hidden window.titlebarAppearsTransparent = true - // cmux persists and restores main windows itself. Disable AppKit window - // restoration so the OS cannot resurrect stale duplicate main windows. - window.isRestorable = false window.isMovableByWindowBackground = false window.isMovable = false let restoredFrame = resolvedWindowFrame(from: sessionWindowSnapshot) @@ -6382,9 +6159,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent cmuxConfigStore: cmuxConfigStore ) installFileDropOverlay(on: window, tabManager: tabManager) - if !shouldActivate || TerminalController.shouldSuppressSocketCommandActivation() { + if TerminalController.shouldSuppressSocketCommandActivation() { window.orderFront(nil) - if shouldActivate, TerminalController.socketCommandAllowsInAppFocusMutations() { + if TerminalController.socketCommandAllowsInAppFocusMutations() { setActiveMainWindow(window) } } else { @@ -6400,7 +6177,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if let restoredFrame { window.setFrame(restoredFrame, display: true) #if DEBUG - cmuxDebugLog( + dlog( "session.restore.frameApplied window=\(windowId.uuidString.prefix(8)) " + "applied={\(debugNSRectDescription(window.frame))}" ) @@ -6608,7 +6385,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } ) { #if DEBUG - cmuxDebugLog("settings.open.present path=customWindowDirect") + dlog("settings.open.present path=customWindowDirect") #endif showFallbackSettingsWindow(navigationTarget) activateApplication() @@ -6621,14 +6398,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } #if DEBUG - cmuxDebugLog("settings.open.present activate=1") + dlog("settings.open.present activate=1") #endif } @MainActor func openPreferencesWindow(debugSource: String, navigationTarget: SettingsNavigationTarget? = nil) { #if DEBUG - cmuxDebugLog("settings.open.request source=\(debugSource)") + dlog("settings.open.request source=\(debugSource)") #endif Self.presentPreferencesWindow(navigationTarget: navigationTarget) } @@ -6756,67 +6533,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent pasteboard.setString(payload, forType: .string) } - @objc private func handleFeedRequestFocus(_ notification: Notification) { - guard let workspaceId = notification.userInfo?["workspaceId"] as? String, - let surfaceId = notification.userInfo?["surfaceId"] as? String - else { return } - - // Invoke the existing V2 commands so the Feed-layer focus request - // goes through the same code path as a socket-initiated focus. - // Serialize through JSON so we reuse the v2 command parser. - let controller = TerminalController.shared - let invoke: (String, [String: Any]) -> Void = { method, params in - let payload: [String: Any] = [ - "id": UUID().uuidString, - "method": method, - "params": params - ] - guard let data = try? JSONSerialization.data(withJSONObject: payload), - let line = String(data: data, encoding: .utf8) - else { return } - _ = controller.handleSocketLine(line) - } - invoke("workspace.select", ["workspace_id": workspaceId]) - invoke("surface.focus", ["surface_id": surfaceId]) - // Flash the terminal's own focus ring (same visual as - // cmd+shift+H / Flash Focused Panel) so the user's eye is - // pulled to the terminal content the Feed jumped to. - invoke("surface.trigger_flash", ["surface_id": surfaceId]) - } - - @objc private func handleFeedRequestSendText(_ notification: Notification) { - guard let surfaceId = notification.userInfo?["surfaceId"] as? String, - let text = notification.userInfo?["text"] as? String, - !text.isEmpty - else { return } - - let controller = TerminalController.shared - let invoke: (String, [String: Any]) -> Void = { method, params in - let payload: [String: Any] = [ - "id": UUID().uuidString, - "method": method, - "params": params, - ] - guard let data = try? JSONSerialization.data(withJSONObject: payload), - let line = String(data: data, encoding: .utf8) - else { return } - _ = controller.handleSocketLine(line) - } - // Terminal-mode Return is CR. sendNamedKey "Return" also works - // but one send_text is atomic, so append CR directly. - invoke("surface.send_text", [ - "surface_id": surfaceId, - "text": text + "\r", - ]) - } - @objc private func handleReactGrabDidCopySelection(_ notification: Notification) { let browserPanelId = notification.userInfo?[ReactGrabPastebackNotificationKey.browserPanelId] as? UUID guard let workspaceId = notification.userInfo?[ReactGrabPastebackNotificationKey.workspaceId] as? UUID, let returnPanelId = notification.userInfo?[ReactGrabPastebackNotificationKey.returnPanelId] as? UUID, let content = notification.userInfo?[ReactGrabPastebackNotificationKey.content] as? String else { #if DEBUG - cmuxDebugLog( + dlog( "reactGrab.pasteback h3.didCopy.drop " + "reason=missingNotificationFields " + "workspace=\(Self.debugShortId(notification.userInfo?[ReactGrabPastebackNotificationKey.workspaceId] as? UUID)) " + @@ -6831,7 +6554,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let manager = tabManagerFor(tabId: workspaceId), let workspace = manager.tabs.first(where: { $0.id == workspaceId }) else { #if DEBUG - cmuxDebugLog( + dlog( "reactGrab.pasteback h3.didCopy.drop " + "reason=missingWorkspace workspace=\(Self.debugShortId(workspaceId)) " + "browser=\(Self.debugShortId(browserPanelId)) return=\(Self.debugShortId(returnPanelId))" @@ -6842,7 +6565,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard workspace.terminalPanel(for: returnPanelId) != nil else { #if DEBUG - cmuxDebugLog( + dlog( "reactGrab.pasteback h3.didCopy.drop " + "reason=missingReturnTerminal workspace=\(Self.debugShortId(workspaceId)) " + "browser=\(Self.debugShortId(browserPanelId)) return=\(Self.debugShortId(returnPanelId)) " + @@ -6853,7 +6576,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } #if DEBUG - cmuxDebugLog( + dlog( "reactGrab.pasteback h3.didCopy " + "workspace=\(Self.debugShortId(workspaceId)) " + "browser=\(Self.debugShortId(browserPanelId)) " + @@ -6863,7 +6586,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif manager.focusTab(workspaceId, surfaceId: returnPanelId, suppressFlash: true) #if DEBUG - cmuxDebugLog( + dlog( "reactGrab.pasteback h1.focusRequested " + "workspace=\(Self.debugShortId(workspaceId)) " + "return=\(Self.debugShortId(returnPanelId)) " + @@ -6897,7 +6620,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent preferredPanelId: preferredPanelId ) if isReactGrabPasteback { - cmuxDebugLog( + dlog( "reactGrab.pasteback h2.send.start " + "workspace=\(Self.debugShortId(tab.id)) " + "preferred=\(Self.debugShortId(preferredPanelId)) " + @@ -6915,7 +6638,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent terminalPanel.surface.surface != nil { #if DEBUG if isReactGrabPasteback { - cmuxDebugLog( + dlog( "reactGrab.pasteback h2.send.immediate " + "workspace=\(Self.debugShortId(tab.id)) " + "target=\(Self.debugShortId(terminalPanel.id)) len=\(text.count)" @@ -6926,7 +6649,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent terminalPanel.sendText(text) #if DEBUG if isReactGrabPasteback { - cmuxDebugLog( + dlog( "reactGrab.pasteback h2.send.sent " + "workspace=\(Self.debugShortId(tab.id)) " + "target=\(Self.debugShortId(terminalPanel.id)) mode=immediate len=\(text.count)" @@ -6937,15 +6660,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } var resolved = false - var readyObserver: NSObjectProtocol? + weak var observedReadySurface: TerminalSurface? + var readyToken: UUID? var focusObserver: NSObjectProtocol? var firstResponderObserver: NSObjectProtocol? var panelsCancellable: AnyCancellable? func cleanupObservers() { - if let readyObserver { - NotificationCenter.default.removeObserver(readyObserver) - } + observedReadySurface?.cancelRuntimeReadyCallback(readyToken) + readyToken = nil if let focusObserver { NotificationCenter.default.removeObserver(focusObserver) } @@ -6955,14 +6678,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent panelsCancellable?.cancel() } + func installReadyCallbackIfNeeded(for terminalPanel: TerminalPanel?) { + guard let terminalPanel else { + observedReadySurface?.cancelRuntimeReadyCallback(readyToken) + observedReadySurface = nil + readyToken = nil + return + } + guard observedReadySurface !== terminalPanel.surface else { return } + observedReadySurface?.cancelRuntimeReadyCallback(readyToken) + observedReadySurface = terminalPanel.surface + readyToken = terminalPanel.surface.onRuntimeReady { +#if DEBUG + if isReactGrabPasteback { + dlog( + "reactGrab.pasteback h2.surfaceReadyCallback " + + "workspace=\(Self.debugShortId(tab.id)) " + + "surface=\(Self.debugShortId(terminalPanel.id)) " + + "target=\(Self.debugShortId(preferredPanelId))" + ) + } +#endif + finishIfReady() + } + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + } + func finishIfReady() { let terminalPanel = Self.resolveTerminalPanelForTextSend( in: tab, preferredPanelId: preferredPanelId ) + installReadyCallbackIfNeeded(for: terminalPanel) #if DEBUG if isReactGrabPasteback { - cmuxDebugLog( + dlog( "reactGrab.pasteback h2.finishIfReady " + "workspace=\(Self.debugShortId(tab.id)) " + "preferred=\(Self.debugShortId(preferredPanelId)) " + @@ -6981,7 +6731,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent terminalPanel.sendText(text) #if DEBUG if isReactGrabPasteback { - cmuxDebugLog( + dlog( "reactGrab.pasteback h2.send.sent " + "workspace=\(Self.debugShortId(tab.id)) " + "target=\(Self.debugShortId(terminalPanel.id)) mode=delayed len=\(text.count)" @@ -6995,7 +6745,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent .sink { _ in #if DEBUG if isReactGrabPasteback { - cmuxDebugLog( + dlog( "reactGrab.pasteback h2.panelsChanged " + "workspace=\(Self.debugShortId(tab.id)) " + "focused=\(Self.debugShortId(tab.focusedPanelId))" @@ -7016,7 +6766,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return } #if DEBUG - cmuxDebugLog( + dlog( "reactGrab.pasteback h1.focusEvent " + "workspace=\(Self.debugShortId(candidateTabId)) " + "surface=\(Self.debugShortId(candidateSurfaceId)) " + @@ -7036,7 +6786,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return } #if DEBUG - cmuxDebugLog( + dlog( "reactGrab.pasteback h1.firstResponderEvent " + "workspace=\(Self.debugShortId(candidateTabId)) " + "surface=\(Self.debugShortId(candidateSurfaceId)) " + @@ -7046,37 +6796,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif } } - readyObserver = NotificationCenter.default.addObserver( - forName: .terminalSurfaceDidBecomeReady, - object: nil, - queue: .main - ) { note in - guard let workspaceId = note.userInfo?["workspaceId"] as? UUID, - workspaceId == tab.id else { return } - let surfaceId = note.userInfo?["surfaceId"] as? UUID -#if DEBUG - if isReactGrabPasteback { - cmuxDebugLog( - "reactGrab.pasteback h2.surfaceReadyEvent " + - "workspace=\(Self.debugShortId(workspaceId)) " + - "surface=\(Self.debugShortId(surfaceId)) " + - "target=\(Self.debugShortId(preferredPanelId)) " + - "match=\(surfaceId == preferredPanelId ? 1 : 0)" - ) - } -#endif - if let preferredPanelId, - let surfaceId, - surfaceId != preferredPanelId { - return - } - finishIfReady() - } DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { if !resolved { #if DEBUG if isReactGrabPasteback { - cmuxDebugLog( + dlog( "reactGrab.pasteback h2.send.timeout " + "workspace=\(Self.debugShortId(tab.id)) " + "preferred=\(Self.debugShortId(preferredPanelId)) " + @@ -7106,19 +6830,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let tabManager else { return } let tab = tabManager.addTab() let config = GhosttyConfig.load() - let minimumTargetBytes = 2_000_000 - let maximumTargetBytes = 200_000_000 - let minimumLineCount = 2000 - let effectiveLimit = max(config.scrollbackLimit, 0) - let doubledLimit = min(effectiveLimit, maximumTargetBytes / 2) * 2 - let targetBytes = min(max(doubledLimit, minimumTargetBytes), maximumTargetBytes) - // `%06d` guarantees at least a 6-digit field width. Any lines beyond - // 999,999 only get wider, so this conservative floor always emits at - // least `targetBytes` without oscillating at digit-count boundaries. - let baseBytesPerLine = "scrollback 000000\n".utf8.count - let lineCount = max((targetBytes + baseBytesPerLine - 1) / baseBytesPerLine, minimumLineCount) - - let command = #"awk 'BEGIN { for (i = 1; i <= \#(lineCount); ++i) printf "scrollback %06d\n", i }'"# + "\n" + let lineCount = min(max(config.scrollbackLimit * 2, 2000), 60000) + let command = "for i in {1..\(lineCount)}; do printf \"scrollback %06d\\n\" $i; done\n" sendTextWhenReady(command, to: tab) } @@ -7181,7 +6894,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent var slowWorkspaceCount = 0 var worstWorkspaceMs: Double = 0 - cmuxDebugLog( + dlog( "stress.setup.start workspaces=\(self.debugStressWorkspaceCount) panes=\(self.debugStressPaneCount) " + "tabsPerPane=\(self.debugStressTabsPerPane) lagProbe=1" ) @@ -7212,7 +6925,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if workspaceMs >= 35 || ((index + 1) % 5 == 0) { let pending = self.pendingDebugTerminalSurfaceCount(in: created) - cmuxDebugLog( + dlog( "stress.setup.workspace idx=\(index + 1)/\(self.debugStressWorkspaceCount) " + "ms=\(String(format: "%.2f", workspaceMs)) failures=\(layoutFailures) pending=\(pending)" ) @@ -7238,7 +6951,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent tabManager.selectedTabId = originalSelectedWorkspaceId } - cmuxDebugLog( + dlog( "stress.setup.done createMs=\(String(format: "%.2f", creationElapsedMs)) " + "loadMs=\(String(format: "%.2f", loadStats.elapsedMs)) loadedPanels=\(loadStats.loadedPanels) " + "loadFailures=\(loadStats.failedPanels) totalMs=\(String(format: "%.2f", totalElapsedMs)) " + @@ -7275,24 +6988,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let topLeftPanelId = workspace.focusedTerminalPanel?.id ?? workspace.focusedPanelId else { return false } - guard let topRight = workspace.newTerminalSplit( - from: topLeftPanelId, + guard let topRight = workspace.splitTerminalPanel( + fromPanelId: topLeftPanelId, orientation: .horizontal, focus: false ) else { return false } await Task.yield() - guard workspace.newTerminalSplit( - from: topLeftPanelId, + guard workspace.splitTerminalPanel( + fromPanelId: topLeftPanelId, orientation: .vertical, focus: false ) != nil else { return false } await Task.yield() - guard workspace.newTerminalSplit( - from: topRight.id, + guard workspace.splitTerminalPanel( + fromPanelId: topRight.id, orientation: .vertical, focus: false ) != nil else { @@ -7300,14 +7013,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } await Task.yield() - let paneIds = workspace.bonsplitController.allPaneIds + let paneIds = workspace.paneIds guard paneIds.count == paneCount else { return false } let additionalTabsPerPane = max(0, tabsPerPane - 1) if additionalTabsPerPane > 0 { for (paneIndex, paneId) in paneIds.enumerated() { for tabOffset in 0.. 0 || eventCount == 1 { - cmuxDebugLog( + dlog( "stress.setup.await event=\(eventCount) pending=\(nextPending.count) " + "started=\(startedThisPass)" ) @@ -7685,7 +7398,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard elapsedMs >= thresholdMs else { return } let snapshot = debugStressLagSnapshot() - cmuxDebugLog( + dlog( "stress.inputLag path=appMonitor ms=\(String(format: "%.2f", elapsedMs)) " + "threshold=\(String(format: "%.2f", thresholdMs)) handled=\(handledByShortcut ? 1 : 0) " + "plain=\(isPlainTyping ? 1 : 0) repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) " + @@ -7840,9 +7553,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent UserDefaults.standard.removeObject(forKey: KeyboardShortcutSettings.focusRightKey) UserDefaults.standard.removeObject(forKey: KeyboardShortcutSettings.focusUpKey) UserDefaults.standard.removeObject(forKey: KeyboardShortcutSettings.focusDownKey) +#if DEBUG + KeyboardShortcutSettings.clearShortcutOverrideForTesting(for: .focusLeft) + KeyboardShortcutSettings.clearShortcutOverrideForTesting(for: .focusRight) + KeyboardShortcutSettings.clearShortcutOverrideForTesting(for: .focusUp) + KeyboardShortcutSettings.clearShortcutOverrideForTesting(for: .focusDown) +#endif } else { // For this UI test we want a letter-based shortcut (Cmd+Ctrl+H) to drive pane navigation, // since arrow keys can't be recorded by the shortcut recorder. +#if DEBUG + KeyboardShortcutSettings.setShortcutOverrideForTesting( + StoredShortcut(key: "h", command: true, shift: false, option: false, control: true), + for: .focusLeft + ) + KeyboardShortcutSettings.setShortcutOverrideForTesting( + StoredShortcut(key: "l", command: true, shift: false, option: false, control: true), + for: .focusRight + ) + KeyboardShortcutSettings.setShortcutOverrideForTesting( + StoredShortcut(key: "k", command: true, shift: false, option: false, control: true), + for: .focusUp + ) + KeyboardShortcutSettings.setShortcutOverrideForTesting( + StoredShortcut(key: "j", command: true, shift: false, option: false, control: true), + for: .focusDown + ) +#else KeyboardShortcutSettings.setShortcut( StoredShortcut(key: "h", command: true, shift: false, option: false, control: true), for: .focusLeft @@ -7859,6 +7596,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent StoredShortcut(key: "j", command: true, shift: false, option: false, control: true), for: .focusDown ) +#endif } installGotoSplitUITestFocusObserversIfNeeded() @@ -7902,17 +7640,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self.writeGotoSplitTestData(["setupError": "Invalid browser URL"]) return } - guard let browserPanelId = tabManager.newBrowserSplit( - tabId: tab.id, + guard let browserPanelId = tab.splitBrowserPanel( fromPanelId: initialPanelId, orientation: .horizontal, url: url - ) else { + )?.id else { self.writeGotoSplitTestData(["setupError": "Failed to create browser split"]) return } - self.focusWebViewForGotoSplitUITest(tab: tab, browserPanelId: browserPanelId) + self.focusWebViewForGotoSplitUITest( + tab: tab, + browserPanelId: browserPanelId, + expectedBrowserURL: url + ) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in @@ -7921,13 +7662,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - private func setupBonsplitTabDragUITestIfNeeded() { - guard !didSetupBonsplitTabDragUITest else { return } - didSetupBonsplitTabDragUITest = true + private func setupSplitTabDragUITestIfNeeded() { + guard !didSetupSplitTabDragUITest else { return } + didSetupSplitTabDragUITest = true let env = ProcessInfo.processInfo.environment - guard env["CMUX_UI_TEST_BONSPLIT_TAB_DRAG_SETUP"] == "1" else { return } + guard env["CMUX_UI_TEST_SPLIT_TAB_DRAG_SETUP"] == "1" else { return } guard tabManager != nil else { return } - let startWithHiddenSidebar = env["CMUX_UI_TEST_BONSPLIT_START_WITH_HIDDEN_SIDEBAR"] == "1" + let startWithHiddenSidebar = env["CMUX_UI_TEST_SPLIT_START_WITH_HIDDEN_SIDEBAR"] == "1" let deadline = Date().addingTimeInterval(20.0) func hasMainTerminalWindow() -> Bool { @@ -7939,7 +7680,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func runSetupWhenWindowReady() { guard Date() < deadline else { - writeBonsplitTabDragUITestData(["setupError": "Timed out waiting for main window"]) + writeSplitTabDragUITestData(["setupError": "Timed out waiting for main window"]) return } guard hasMainTerminalWindow() else { @@ -7968,7 +7709,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let tabManager = self.tabManager, let workspace = tabManager.selectedWorkspace ?? tabManager.tabs.first, let alphaPanelId = workspace.focusedPanelId else { - self.writeBonsplitTabDragUITestData(["setupError": "Missing initial workspace or panel"]) + self.writeSplitTabDragUITestData(["setupError": "Missing initial workspace or panel"]) return } @@ -7980,7 +7721,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent tabManager.newSurface() guard let betaPanelId = workspace.focusedPanelId, betaPanelId != alphaPanelId else { - self.writeBonsplitTabDragUITestData(["setupError": "Failed to create second surface"]) + self.writeSplitTabDragUITestData(["setupError": "Failed to create second surface"]) return } @@ -7988,7 +7729,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if startWithHiddenSidebar { self.sidebarState?.isVisible = false } - self.writeBonsplitTabDragUITestData([ + self.writeSplitTabDragUITestData([ "ready": "1", "sidebarVisible": startWithHiddenSidebar ? "0" : "1", "workspaceId": workspace.id.uuidString, @@ -7998,7 +7739,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "alphaPanelId": alphaPanelId.uuidString, "betaPanelId": betaPanelId.uuidString, ]) - self.startBonsplitTabDragUITestRecorder( + self.startSplitTabDragUITestRecorder( workspaceId: workspace.id, alphaPanelId: alphaPanelId, betaPanelId: betaPanelId @@ -8011,38 +7752,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - private func bonsplitTabDragUITestDataPath() -> String? { + private func splitTabDragUITestDataPath() -> String? { let env = ProcessInfo.processInfo.environment - guard env["CMUX_UI_TEST_BONSPLIT_TAB_DRAG_SETUP"] == "1", - let path = env["CMUX_UI_TEST_BONSPLIT_TAB_DRAG_PATH"], + guard env["CMUX_UI_TEST_SPLIT_TAB_DRAG_SETUP"] == "1", + let path = env["CMUX_UI_TEST_SPLIT_TAB_DRAG_PATH"], !path.isEmpty else { return nil } return path } - private func startBonsplitTabDragUITestRecorder( + private func startSplitTabDragUITestRecorder( workspaceId: UUID, alphaPanelId: UUID, betaPanelId: UUID ) { - bonsplitTabDragUITestRecorder?.cancel() - bonsplitTabDragUITestRecorder = nil + splitTabDragUITestRecorder?.cancel() + splitTabDragUITestRecorder = nil let timer = DispatchSource.makeTimerSource(queue: .main) timer.schedule(deadline: .now(), repeating: .milliseconds(100)) timer.setEventHandler { [weak self] in - self?.recordBonsplitTabDragUITestState( + self?.recordSplitTabDragUITestState( workspaceId: workspaceId, alphaPanelId: alphaPanelId, betaPanelId: betaPanelId ) } - bonsplitTabDragUITestRecorder = timer + splitTabDragUITestRecorder = timer timer.resume() } - private func recordBonsplitTabDragUITestState( + private func recordSplitTabDragUITestState( workspaceId: UUID, alphaPanelId: UUID, betaPanelId: UUID @@ -8054,19 +7795,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let trackedPaneId = workspace.paneId(forPanelId: alphaPanelId) ?? workspace.paneId(forPanelId: betaPanelId) - ?? workspace.bonsplitController.focusedPaneId - ?? workspace.bonsplitController.allPaneIds.first + ?? workspace.focusedPaneId + ?? workspace.paneIds.first guard let trackedPaneId else { return } - let titles: [String] = workspace.bonsplitController.tabs(inPane: trackedPaneId).compactMap { tab in - guard let panelId = workspace.panelIdFromSurfaceId(tab.id) else { return nil } + let titles: [String] = workspace.surfaceIds(inPane: trackedPaneId).compactMap { panelId in return workspace.panelTitle(panelId: panelId) } - let selectedTitle = workspace.bonsplitController.selectedTab(inPane: trackedPaneId) - .flatMap { workspace.panelIdFromSurfaceId($0.id) } + let selectedTitle = workspace.selectedSurfaceId(inPane: trackedPaneId) .flatMap { workspace.panelTitle(panelId: $0) } ?? "" - writeBonsplitTabDragUITestData([ + writeSplitTabDragUITestData([ "trackedPaneId": trackedPaneId.description, "trackedPaneTabTitles": titles.joined(separator: "|"), "trackedPaneTabCount": String(titles.count), @@ -8074,9 +7813,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ]) } - private func writeBonsplitTabDragUITestData(_ updates: [String: String]) { - guard let path = bonsplitTabDragUITestDataPath() else { return } - var payload = loadBonsplitTabDragUITestData(at: path) + private func writeSplitTabDragUITestData(_ updates: [String: String]) { + guard let path = splitTabDragUITestDataPath() else { return } + var payload = loadSplitTabDragUITestData(at: path) for (key, value) in updates { payload[key] = value } @@ -8084,7 +7823,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent try? data.write(to: URL(fileURLWithPath: path), options: .atomic) } - private func loadBonsplitTabDragUITestData(at path: String) -> [String: String] { + private func loadSplitTabDragUITestData(at path: String) -> [String: String] { guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { return [:] @@ -8105,7 +7844,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func gotoSplitFindStateSnapshot(for workspace: Workspace) -> [String: String] { var updates: [String: String] = [ - "focusedPaneId": workspace.bonsplitController.focusedPaneId?.description ?? "" + "focusedPaneId": workspace.focusedPaneId?.description ?? "" ] if let focusedPanelId = workspace.focusedPanelId { @@ -8151,7 +7890,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return updates } - private func focusWebViewForGotoSplitUITest(tab: Workspace, browserPanelId: UUID) { + private func focusWebViewForGotoSplitUITest( + tab: Workspace, + browserPanelId: UUID, + expectedBrowserURL: URL? = nil + ) { guard let browserPanel = tab.browserPanel(for: browserPanelId) else { writeGotoSplitTestData([ "webViewFocused": "false", @@ -8199,16 +7942,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "browserPanelId": browserPanelId.uuidString, "browserPaneId": browserPaneId.description, "terminalPaneId": terminalPaneId.description, - "initialPaneCount": String(tab.bonsplitController.allPaneIds.count), - "focusedPaneId": tab.bonsplitController.focusedPaneId?.description ?? "", + "initialPaneCount": String(tab.paneCount), + "focusedPaneId": tab.focusedPaneId?.description ?? "", "ghosttyGotoSplitLeftShortcut": ghosttyGotoSplitLeftShortcut?.displayString ?? "", "ghosttyGotoSplitRightShortcut": ghosttyGotoSplitRightShortcut?.displayString ?? "", "ghosttyGotoSplitUpShortcut": ghosttyGotoSplitUpShortcut?.displayString ?? "", "ghosttyGotoSplitDownShortcut": ghosttyGotoSplitDownShortcut?.displayString ?? "", + "configuredFocusLeftShortcut": KeyboardShortcutSettings.focusLeftShortcut().displayString, + "configuredFocusRightShortcut": KeyboardShortcutSettings.focusRightShortcut().displayString, + "configuredFocusUpShortcut": KeyboardShortcutSettings.focusUpShortcut().displayString, + "configuredFocusDownShortcut": KeyboardShortcutSettings.focusDownShortcut().displayString, "webViewFocused": "true" ]) if ProcessInfo.processInfo.environment["CMUX_UI_TEST_GOTO_SPLIT_INPUT_SETUP"] == "1" { - setupFocusedInputForGotoSplitUITest(panel: panel) + recordFocusedInputFixtureSetupWhenNavigationReady( + panel: panel, + expectedBrowserURL: expectedBrowserURL + ) } } @@ -8270,6 +8020,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent updates["browserPageTitle"] = browserPanel.webView.title? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" updates["browserPageURL"] = browserPanel.preferredURLStringForOmnibar() ?? "" + if let firstResponder = browserPanel.webView.window?.firstResponder { + updates["webViewFirstResponderType"] = String(describing: type(of: firstResponder)) + if let firstResponderView = firstResponder as? NSView { + updates["webViewFirstResponderDescendant"] = firstResponderView.isDescendant(of: browserPanel.webView) ? "true" : "false" + updates["webViewFirstResponderIsWebView"] = firstResponderView === browserPanel.webView ? "true" : "false" + } else { + updates["webViewFirstResponderDescendant"] = "false" + updates["webViewFirstResponderIsWebView"] = "false" + } + } else { + updates["webViewFirstResponderType"] = "" + updates["webViewFirstResponderDescendant"] = "false" + updates["webViewFirstResponderIsWebView"] = "false" + } writeGotoSplitTestData(updates) } @@ -8280,14 +8044,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func paneIdsForGotoSplitUITest(tab: Workspace, browserPanelId: UUID) -> (browser: PaneID, terminal: PaneID)? { - let paneIds = tab.bonsplitController.allPaneIds + let paneIds = tab.paneIds guard paneIds.count >= 2 else { return nil } var browserPane: PaneID? var terminalPane: PaneID? for paneId in paneIds { - guard let selected = tab.bonsplitController.selectedTab(inPane: paneId), - let panelId = tab.panelIdFromSurfaceId(selected.id) else { continue } + guard let panelId = tab.selectedSurfaceId(inPane: paneId) else { continue } if panelId == browserPanelId { browserPane = paneId } else if terminalPane == nil { @@ -8303,14 +8066,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard gotoSplitUITestObservers.isEmpty else { return } gotoSplitUITestObservers.append(NotificationCenter.default.addObserver( - forName: .browserFocusAddressBar, + forName: .browserDidFocusAddressBar, object: nil, queue: .main ) { [weak self] notification in guard let self else { return } guard let panelId = notification.object as? UUID else { return } self.recordGotoSplitUITestWebViewFocus(panelId: panelId, key: "webViewFocusedAfterAddressBarFocus") - self.recordGotoSplitUITestActiveElement(panelId: panelId, keyPrefix: "addressBarFocus") }) gotoSplitUITestObservers.append(NotificationCenter.default.addObserver( @@ -8321,7 +8083,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let self else { return } guard let panelId = notification.object as? UUID else { return } self.recordGotoSplitUITestWebViewFocus(panelId: panelId, key: "webViewFocusedAfterAddressBarExit") - self.recordGotoSplitUITestActiveElement(panelId: panelId, keyPrefix: "addressBarExit") }) } @@ -8412,419 +8173,128 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent Task { @MainActor in evaluate() } } - private func setupFocusedInputForGotoSplitUITest(panel: BrowserPanel) { - let script = """ - (() => { - const snapshot = () => { - const active = document.activeElement; - return { - focused: false, - id: "", - secondaryId: "", - secondaryCenterX: -1, - secondaryCenterY: -1, - activeId: active && typeof active.id === "string" ? active.id : "", - activeTag: active && active.tagName ? active.tagName.toLowerCase() : "", - trackerInstalled: window.__cmuxAddressBarFocusTrackerInstalled === true, - trackedStateId: - window.__cmuxAddressBarFocusState && - typeof window.__cmuxAddressBarFocusState.id === "string" - ? window.__cmuxAddressBarFocusState.id - : "", - readyState: String(document.readyState || "") - }; - }; - const seed = () => { - const ensureInput = (id, value) => { - const existing = document.getElementById(id); - const input = (existing && existing.tagName && existing.tagName.toLowerCase() === "input") - ? existing - : (() => { - const created = document.createElement("input"); - created.id = id; - created.type = "text"; - created.value = value; - return created; - })(); - input.autocapitalize = "off"; - input.autocomplete = "off"; - input.spellcheck = false; - input.style.display = "block"; - input.style.width = "100%"; - input.style.margin = "0"; - input.style.padding = "8px 10px"; - input.style.border = "1px solid #5f6368"; - input.style.borderRadius = "6px"; - input.style.boxSizing = "border-box"; - input.style.fontSize = "14px"; - input.style.fontFamily = "system-ui, -apple-system, sans-serif"; - input.style.background = "white"; - input.style.color = "black"; - return input; - }; - - let container = document.getElementById("cmux-ui-test-focus-container"); - if (!container || !container.tagName || container.tagName.toLowerCase() !== "div") { - container = document.createElement("div"); - container.id = "cmux-ui-test-focus-container"; - document.body.appendChild(container); - } - container.style.position = "fixed"; - container.style.left = "24px"; - container.style.top = "24px"; - container.style.width = "min(520px, calc(100vw - 48px))"; - container.style.display = "grid"; - container.style.rowGap = "12px"; - container.style.padding = "12px"; - container.style.background = "rgba(255,255,255,0.92)"; - container.style.border = "1px solid rgba(95,99,104,0.55)"; - container.style.borderRadius = "8px"; - container.style.boxShadow = "0 2px 10px rgba(0,0,0,0.2)"; - container.style.zIndex = "2147483647"; - - const input = ensureInput("cmux-ui-test-focus-input", "cmux-ui-focus-primary"); - const secondaryInput = ensureInput("cmux-ui-test-focus-input-secondary", "cmux-ui-focus-secondary"); - if (input.parentElement !== container) { - container.appendChild(input); - } - if (secondaryInput.parentElement !== container) { - container.appendChild(secondaryInput); - } - - input.focus({ preventScroll: true }); - if (typeof input.setSelectionRange === "function") { - const end = input.value.length; - input.setSelectionRange(end, end); - } - - let trackedFocusId = input.getAttribute("data-cmux-addressbar-focus-id"); - if (!trackedFocusId) { - trackedFocusId = "cmux-ui-test-focus-input-tracked"; - input.setAttribute("data-cmux-addressbar-focus-id", trackedFocusId); - } - const selectionStart = typeof input.selectionStart === "number" ? input.selectionStart : null; - const selectionEnd = typeof input.selectionEnd === "number" ? input.selectionEnd : null; - if ( - !window.__cmuxAddressBarFocusState || - typeof window.__cmuxAddressBarFocusState.id !== "string" || - window.__cmuxAddressBarFocusState.id !== trackedFocusId - ) { - window.__cmuxAddressBarFocusState = { id: trackedFocusId, selectionStart, selectionEnd }; - } - - const secondaryRect = secondaryInput.getBoundingClientRect(); - const viewportWidth = Math.max(Number(window.innerWidth) || 0, 1); - const viewportHeight = Math.max(Number(window.innerHeight) || 0, 1); - const secondaryCenterX = Math.min( - 0.98, - Math.max(0.02, (secondaryRect.left + (secondaryRect.width / 2)) / viewportWidth) - ); - const secondaryCenterY = Math.min( - 0.98, - Math.max(0.02, (secondaryRect.top + (secondaryRect.height / 2)) / viewportHeight) - ); - const active = document.activeElement; - return { - focused: active === input, - id: input.id || "", - secondaryId: secondaryInput.id || "", - secondaryCenterX, - secondaryCenterY, - activeId: active && typeof active.id === "string" ? active.id : "", - activeTag: active && active.tagName ? active.tagName.toLowerCase() : "", - trackerInstalled: window.__cmuxAddressBarFocusTrackerInstalled === true, - trackedStateId: - window.__cmuxAddressBarFocusState && - typeof window.__cmuxAddressBarFocusState.id === "string" - ? window.__cmuxAddressBarFocusState.id - : "", - readyState: String(document.readyState || "") - }; - }; - const ready = () => - window.__cmuxAddressBarFocusTrackerInstalled === true && - String(document.readyState || "") === "complete"; - - if (ready()) { - try { - return seed(); - } catch (_) { - return snapshot(); - } - } - - return new Promise((resolve) => { - let finished = false; - let observer = null; - const cleanups = []; - const finish = (value) => { - if (finished) return; - finished = true; - if (observer) observer.disconnect(); - for (const cleanup of cleanups) { - try { cleanup(); } catch (_) {} - } - resolve(value); - }; - const maybeFinish = () => { - if (!ready()) return; - try { - finish(seed()); - } catch (_) { - finish(snapshot()); - } - }; - const addListener = (target, eventName, options) => { - if (!target || typeof target.addEventListener !== "function") return; - const handler = () => maybeFinish(); - target.addEventListener(eventName, handler, options); - cleanups.push(() => target.removeEventListener(eventName, handler, options)); - }; - try { - observer = new MutationObserver(() => maybeFinish()); - observer.observe(document.documentElement || document, { - childList: true, - subtree: true, - attributes: true, - characterData: true - }); - } catch (_) {} - addListener(document, "readystatechange", true); - addListener(window, "load", true); - const timeoutId = window.setTimeout(() => finish(snapshot()), 4000); - cleanups.push(() => window.clearTimeout(timeoutId)); - maybeFinish(); - }); - })(); - """ - - panel.webView.evaluateJavaScript(script) { [weak self] result, _ in - guard let self else { return } - let payload = result as? [String: Any] - let focused = (payload?["focused"] as? Bool) ?? false - let inputId = (payload?["id"] as? String) ?? "" - let secondaryInputId = (payload?["secondaryId"] as? String) ?? "" - let secondaryCenterX = (payload?["secondaryCenterX"] as? NSNumber)?.doubleValue ?? -1 - let secondaryCenterY = (payload?["secondaryCenterY"] as? NSNumber)?.doubleValue ?? -1 - let activeId = (payload?["activeId"] as? String) ?? "" - let trackerInstalled = (payload?["trackerInstalled"] as? Bool) ?? false - let trackedStateId = (payload?["trackedStateId"] as? String) ?? "" - let readyState = (payload?["readyState"] as? String) ?? "" - var secondaryClickOffsetX = -1.0 - var secondaryClickOffsetY = -1.0 - if let window = panel.webView.window { - let webFrame = panel.webView.convert(panel.webView.bounds, to: nil) - let contentHeight = Double(window.contentView?.bounds.height ?? 0) - if webFrame.width > 1, - webFrame.height > 1, - contentHeight > 1, - secondaryCenterX > 0, - secondaryCenterX < 1, - secondaryCenterY > 0, - secondaryCenterY < 1 { - let xInContent = Double(webFrame.minX) + (secondaryCenterX * Double(webFrame.width)) - let yFromTopInWeb = secondaryCenterY * Double(webFrame.height) - let yInContent = Double(webFrame.maxY) - yFromTopInWeb - let yFromTopInContent = contentHeight - yInContent - let titlebarHeight = max(0, Double(window.frame.height) - contentHeight) - secondaryClickOffsetX = xInContent - secondaryClickOffsetY = titlebarHeight + yFromTopInContent - } + private func recordFocusedInputFixtureSetupWhenNavigationReady( + panel: BrowserPanel, + expectedBrowserURL: URL? + ) { + let expectedURLString = expectedBrowserURL?.absoluteString + var resolved = false + var urlObservation: NSKeyValueObservation? + var loadingObservation: NSKeyValueObservation? + var titleObservation: NSKeyValueObservation? + + func cleanup() { + urlObservation?.invalidate() + urlObservation = nil + loadingObservation?.invalidate() + loadingObservation = nil + titleObservation?.invalidate() + titleObservation = nil + } + + func attemptRecord(reason: String) { + guard !resolved else { return } + if let expectedURLString, + panel.webView.url?.absoluteString != expectedURLString { + return } - if focused, - !inputId.isEmpty, - !secondaryInputId.isEmpty, - inputId == activeId, - trackerInstalled, - !trackedStateId.isEmpty, - secondaryCenterX > 0, - secondaryCenterX < 1, - secondaryCenterY > 0, - secondaryCenterY < 1, - secondaryClickOffsetX > 0, - secondaryClickOffsetY > 0 { + guard !panel.webView.isLoading else { return } + guard panel.webView.title == "cmux-ui-focus-primary:cmux-ui-focus-primary" else { return } + resolved = true + cleanup() +#if DEBUG + dlog( + "gotoSplit.inputSetup.seed panel=\(panel.id.uuidString.prefix(5)) " + + "reason=\(reason)" + ) +#endif + let primaryInputId = "cmux-ui-test-focus-input" + let secondaryInputId = "cmux-ui-test-focus-input-secondary" + let secondaryInputTop = 76.0 + let inputHeight = 40.0 + let horizontalInset = 24.0 + let maxInputWidth = 520.0 + guard let window = panel.webView.window else { self.writeGotoSplitTestData([ - "webInputFocusSeeded": "true", - "webInputFocusElementId": inputId, - "webInputFocusSecondaryElementId": secondaryInputId, - "webInputFocusSecondaryCenterX": "\(secondaryCenterX)", - "webInputFocusSecondaryCenterY": "\(secondaryCenterY)", - "webInputFocusSecondaryClickOffsetX": "\(secondaryClickOffsetX)", - "webInputFocusSecondaryClickOffsetY": "\(secondaryClickOffsetY)", - "webInputFocusActiveElementId": activeId, - "webInputFocusTrackerInstalled": trackerInstalled ? "true" : "false", - "webInputFocusTrackedStateId": trackedStateId, - "webInputFocusReadyState": readyState + "webInputFocusSeeded": "false", + "setupError": "Focused input fixture missing window" ]) return } - self.writeGotoSplitTestData([ - "webInputFocusSeeded": "false", - "setupError": "Timed out focusing page input for omnibar restore test" - ]) - } - } - private func recordGotoSplitUITestActiveElement(panelId: UUID, keyPrefix: String) { - guard let tabManager, - let tab = tabManager.selectedWorkspace, - let panel = tab.browserPanel(for: panelId) else { - return - } + let webFrame = panel.webView.convert(panel.webView.bounds, to: nil) + let viewportWidth = Double(panel.webView.bounds.width) + let fittedWidth = max(0, min(maxInputWidth, viewportWidth - (horizontalInset * 2))) + let secondaryCenterX = horizontalInset + (fittedWidth / 2) + let secondaryCenterY = secondaryInputTop + (inputHeight / 2) + let contentHeight = Double(window.contentView?.bounds.height ?? 0) + guard webFrame.width > 1, + webFrame.height > 1, + contentHeight > 1, + fittedWidth > 1, + secondaryCenterX > 0, + secondaryCenterY > 0, + secondaryCenterY < Double(webFrame.height) else { + self.writeGotoSplitTestData([ + "webInputFocusSeeded": "false", + "setupError": "Focused input fixture geometry invalid" + ]) + return + } - let expectedInputId = keyPrefix == "addressBarExit" ? gotoSplitUITestExpectedInputId() : nil - let capture: @MainActor @Sendable () -> Void = { [weak self] in - guard let self else { return } - self.evaluateGotoSplitUITestActiveElement( - panel: panel, - awaitingInputId: expectedInputId - ) { snapshot in + let xInContent = Double(webFrame.minX) + secondaryCenterX + let yInContent = Double(webFrame.maxY) - secondaryCenterY + let yFromTopInContent = contentHeight - yInContent + let titlebarHeight = max(0, Double(window.frame.height) - contentHeight) + let secondaryClickOffsetX = xInContent + let secondaryClickOffsetY = titlebarHeight + yFromTopInContent + + guard secondaryClickOffsetX > 0, + secondaryClickOffsetY > 0 else { self.writeGotoSplitTestData([ - "\(keyPrefix)PanelId": panelId.uuidString, - "\(keyPrefix)ActiveElementId": snapshot["id"] ?? "", - "\(keyPrefix)ActiveElementTag": snapshot["tag"] ?? "", - "\(keyPrefix)ActiveElementType": snapshot["type"] ?? "", - "\(keyPrefix)ActiveElementEditable": snapshot["editable"] ?? "false", - "\(keyPrefix)TrackedFocusStateId": snapshot["trackedFocusStateId"] ?? "", - "\(keyPrefix)FocusTrackerInstalled": snapshot["focusTrackerInstalled"] ?? "false" + "webInputFocusSeeded": "false", + "setupError": "Focused input fixture click offsets invalid" ]) + return } + + self.writeGotoSplitTestData([ + "webInputFocusSeeded": "true", + "webInputFocusElementId": primaryInputId, + "webInputFocusSecondaryElementId": secondaryInputId, + "webInputFocusSecondaryClickOffsetX": "\(secondaryClickOffsetX)", + "webInputFocusSecondaryClickOffsetY": "\(secondaryClickOffsetY)" + ]) } - if expectedInputId == nil { + urlObservation = panel.webView.observe(\.url, options: [.initial, .new]) { _, _ in DispatchQueue.main.async { - Task { @MainActor in capture() } + attemptRecord(reason: "url") + } + } + loadingObservation = panel.webView.observe(\.isLoading, options: [.new]) { _, _ in + DispatchQueue.main.async { + attemptRecord(reason: "loading") + } + } + titleObservation = panel.webView.observe(\.title, options: [.new]) { _, _ in + DispatchQueue.main.async { + attemptRecord(reason: "title") } - } else { - Task { @MainActor in capture() } } - } - private func evaluateGotoSplitUITestActiveElement( - panel: BrowserPanel, - awaitingInputId: String? = nil, - completion: @escaping ([String: String]) -> Void - ) { - let expectedInputIdLiteral = cmuxJavaScriptStringLiteral(awaitingInputId) ?? "null" - let script = """ - (() => { - const expectedInputId = \(expectedInputIdLiteral); - const snapshot = () => { - try { - const active = document.activeElement; - if (!active) { - return { - id: "", - tag: "", - type: "", - editable: "false", - trackedFocusStateId: "", - focusTrackerInstalled: window.__cmuxAddressBarFocusTrackerInstalled === true ? "true" : "false" - }; - } - const tag = (active.tagName || "").toLowerCase(); - const type = (active.type || "").toLowerCase(); - const editable = - !!active.isContentEditable || - tag === "textarea" || - (tag === "input" && type !== "hidden"); - return { - id: typeof active.id === "string" ? active.id : "", - tag, - type, - editable: editable ? "true" : "false", - trackedFocusStateId: - window.__cmuxAddressBarFocusState && - typeof window.__cmuxAddressBarFocusState.id === "string" - ? window.__cmuxAddressBarFocusState.id - : "", - focusTrackerInstalled: - window.__cmuxAddressBarFocusTrackerInstalled === true ? "true" : "false" - }; - } catch (_) { - return { - id: "", - tag: "", - type: "", - editable: "false", - trackedFocusStateId: "", - focusTrackerInstalled: "false" - }; - } - }; - const matchesExpectation = (state) => - !expectedInputId || (typeof expectedInputId === "string" && state.id === expectedInputId); - - const initial = snapshot(); - if (matchesExpectation(initial)) { - return initial; - } - - return new Promise((resolve) => { - let finished = false; - let observer = null; - const cleanups = []; - const finish = (value) => { - if (finished) return; - finished = true; - if (observer) observer.disconnect(); - for (const cleanup of cleanups) { - try { cleanup(); } catch (_) {} - } - resolve(value); - }; - const maybeFinish = () => { - const state = snapshot(); - if (matchesExpectation(state)) { - finish(state); - } - }; - const addListener = (target, eventName, options) => { - if (!target || typeof target.addEventListener !== "function") return; - const handler = () => maybeFinish(); - target.addEventListener(eventName, handler, options); - cleanups.push(() => target.removeEventListener(eventName, handler, options)); - }; - try { - observer = new MutationObserver(() => maybeFinish()); - observer.observe(document.documentElement || document, { - childList: true, - subtree: true, - attributes: true, - characterData: true - }); - } catch (_) {} - addListener(document, "focusin", true); - addListener(document, "focusout", true); - addListener(document, "selectionchange", true); - addListener(document, "readystatechange", true); - addListener(window, "load", true); - const timeoutId = window.setTimeout(() => finish(snapshot()), 1500); - cleanups.push(() => window.clearTimeout(timeoutId)); - maybeFinish(); - }); - })(); - """ - - panel.webView.evaluateJavaScript(script) { result, _ in - let payload = result as? [String: Any] - completion([ - "id": (payload?["id"] as? String) ?? "", - "tag": (payload?["tag"] as? String) ?? "", - "type": (payload?["type"] as? String) ?? "", - "editable": (payload?["editable"] as? String) ?? "false", - "trackedFocusStateId": (payload?["trackedFocusStateId"] as? String) ?? "", - "focusTrackerInstalled": (payload?["focusTrackerInstalled"] as? String) ?? "false" + DispatchQueue.main.asyncAfter(deadline: .now() + 8.0) { [weak self, weak panel] in + guard !resolved else { return } + cleanup() + self?.writeGotoSplitTestData([ + "webInputFocusSeeded": "false", + "setupError": "Timed out waiting for focused input fixture setup", + "webInputFocusExpectedURL": expectedURLString ?? "", + "webInputFocusActualURL": panel?.webView.url?.absoluteString ?? "" ]) } } - private func gotoSplitUITestExpectedInputId() -> String? { - let env = ProcessInfo.processInfo.environment - guard let path = env["CMUX_UI_TEST_GOTO_SPLIT_PATH"], !path.isEmpty else { return nil } - return loadGotoSplitTestData(at: path)["webInputFocusElementId"] - } - private func recordGotoSplitMoveIfNeeded(direction: NavigationDirection) { guard isGotoSplitUITestRecordingEnabled() else { return } guard let tabManager, let workspace = tabManager.selectedWorkspace else { return } @@ -8864,7 +8334,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent var updates = gotoSplitFindStateSnapshot(for: workspace) updates["lastSplitDirection"] = directionValue - updates["paneCountAfterSplit"] = String(workspace.bonsplitController.allPaneIds.count) + updates["paneCountAfterSplit"] = String(workspace.paneCount) writeGotoSplitTestData(updates) } @@ -8880,8 +8350,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } var updates = self.gotoSplitFindStateSnapshot(for: workspace) - updates["splitZoomedAfterToggle"] = workspace.bonsplitController.isSplitZoomed ? "true" : "false" - updates["zoomedPaneIdAfterToggle"] = workspace.bonsplitController.zoomedPaneId?.description ?? "" + updates["splitZoomedAfterToggle"] = workspace.isSplitZoomed ? "true" : "false" + updates["zoomedPaneIdAfterToggle"] = workspace.zoomedPaneId?.description ?? "" updates["browserPanelIdAfterToggle"] = browserPanel?.id.uuidString ?? "" updates["browserContainerHiddenAfterToggle"] = browserSnapshot.map { $0.containerHidden ? "true" : "false" } ?? "" updates["browserVisibleFlagAfterToggle"] = browserSnapshot.map { $0.visibleInUI ? "true" : "false" } ?? "" @@ -8909,7 +8379,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } ?? "" let settled: Bool = { - if workspace.bonsplitController.isSplitZoomed { + if workspace.isSplitZoomed { if let focusedPanelId = workspace.focusedPanelId, workspace.terminalPanel(for: focusedPanelId) != nil { guard let browserSnapshot else { return false } @@ -9524,7 +8994,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } static func installWindowResponderSwizzlesForTesting() { - _ = didInstallApplicationAccessibilitySwizzle + _ = didInstallApplicationSendActionSwizzle _ = didInstallWindowKeyEquivalentSwizzle _ = didInstallWindowFirstResponderSwizzle _ = didInstallWindowSendEventSwizzle @@ -9543,7 +9013,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif private func installWindowResponderSwizzles() { - _ = Self.didInstallApplicationAccessibilitySwizzle + _ = Self.didInstallApplicationSendActionSwizzle _ = Self.didInstallApplicationSendEventSwizzle _ = Self.didInstallWindowKeyEquivalentSwizzle _ = Self.didInstallWindowFirstResponderSwizzle @@ -9552,11 +9022,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func installShortcutMonitor() { // Local monitor only receives events when app is active (not global) - shortcutMonitor = NSEvent.addLocalMonitorForEvents( - matching: [.keyDown, .keyUp, .flagsChanged, .systemDefined] - ) { [weak self] event in + shortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp, .flagsChanged]) { [weak self] event in guard let self else { return event } - if event.type == .keyDown || event.type == .systemDefined { + if event.type == .keyDown { #if DEBUG let phaseTotalStart = ProcessInfo.processInfo.systemUptime let preludeStart = ProcessInfo.processInfo.systemUptime @@ -9568,7 +9036,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent || UserDefaults.standard.bool(forKey: "cmuxShortcutMonitorTrace") if shortcutMonitorTraceEnabled { let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - cmuxDebugLog( + dlog( "monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil") \(self.debugShortcutRouteSnapshot(event: event))" ) } @@ -9578,6 +9046,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent preludeMs = (ProcessInfo.processInfo.systemUptime - preludeStart) * 1000.0 let shortcutTimingStart = CmuxTypingTiming.start() #endif + if self.handlePaneFocusNavigationCommandEvent( + event, + preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, + source: "monitorEarly" + ) { +#if DEBUG + dlog(" -> consumed by early pane focus routing") +#endif + return nil + } + if self.handleBrowserSearchOverlayCommandEvent( + event, + preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) { +#if DEBUG + dlog(" -> consumed by browser find overlay command routing") +#endif + return nil + } let shortcutStart = ProcessInfo.processInfo.systemUptime let handledByShortcut = self.handleCustomShortcut(event: event) #if DEBUG @@ -9609,7 +9096,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif if handledByShortcut { #if DEBUG - cmuxDebugLog(" → consumed by handleCustomShortcut") + dlog(" → consumed by handleCustomShortcut") #endif return nil // Consume the event } @@ -9656,7 +9143,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } /// Coalesce shortcut-default changes and refresh on the next runloop turn to - /// avoid mutating Bonsplit/SwiftUI-observed state during an active update pass. + /// avoid mutating WorkspaceSplit/SwiftUI-observed state during an active update pass. private func scheduleSplitButtonTooltipRefreshAcrossWorkspaces() { guard !splitButtonTooltipRefreshScheduled else { return } splitButtonTooltipRefreshScheduled = true @@ -9867,11 +9354,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func handleCustomShortcut(event: NSEvent) -> Bool { - guard !KeyboardShortcutRecorderActivity.isAnyRecorderActive else { - clearConfiguredShortcutChordState() - return false - } - // `charactersIgnoringModifiers` can be nil for some synthetic NSEvents and certain special keys. // Treat nil as "" and rely on keyCode/layout-aware fallback logic where needed. // When a non-Latin input source is active (Korean, Chinese, Japanese, etc.), @@ -9977,7 +9459,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG if event.keyCode == 36 || event.keyCode == 76 { - cmuxDebugLog( + dlog( "shortcut.return.raw " + "interactive=\(commandPaletteInteractiveInTargetWindow ? 1 : 0) " + "effective=\(commandPaletteEffectiveInTargetWindow ? 1 : 0) " + @@ -10003,7 +9485,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return activePaletteWindow }() #if DEBUG - cmuxDebugLog( + dlog( "shortcut.escape route target={\(debugWindowToken(commandPaletteTargetWindow))} " + "active={\(debugWindowToken(activePaletteWindow))} " + "visibleTarget=\(commandPaletteVisibleInTargetWindow ? 1 : 0) " + @@ -10017,7 +9499,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent !commandPaletteVisibleInTargetWindow, !commandPalettePendingOpenInTargetWindow, (commandPaletteOverlayVisibleInTargetWindow || commandPaletteResponderActiveInTargetWindow) { - cmuxDebugLog( + dlog( "shortcut.escape stateMismatch target={\(debugWindowToken(commandPaletteTargetWindow))} " + "overlayTarget=\(commandPaletteOverlayVisibleInTargetWindow ? 1 : 0) " + "responderTarget=\(commandPaletteResponderActiveInTargetWindow ? 1 : 0)" @@ -10028,7 +9510,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent isCommandPaletteEffectivelyVisible(in: paletteWindow) { if commandPaletteMarkedTextInput(in: paletteWindow) != nil { #if DEBUG - cmuxDebugLog( + dlog( "shortcut.escape imeMarkedTextBypass consumed=0 target={\(debugWindowToken(paletteWindow))}" ) #endif @@ -10038,7 +9520,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent beginCommandPaletteEscapeSuppression(for: paletteWindow) NotificationCenter.default.post(name: .commandPaletteToggleRequested, object: paletteWindow) #if DEBUG - cmuxDebugLog("shortcut.escape paletteDismiss consumed=1 target={\(debugWindowToken(paletteWindow))}") + dlog("shortcut.escape paletteDismiss consumed=1 target={\(debugWindowToken(paletteWindow))}") #endif return true } @@ -10048,7 +9530,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ?? NSApp.mainWindow if shouldConsumeSuppressedEscape(event: event, window: suppressionWindow) { #if DEBUG - cmuxDebugLog( + dlog( "shortcut.escape suppressionConsume consumed=1 target={\(debugWindowToken(suppressionWindow))} " + "repeat=\(event.isARepeat ? 1 : 0)" ) @@ -10058,7 +9540,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if let requestAge = recentCommandPaletteRequestAge(for: suppressionWindow) { beginCommandPaletteEscapeSuppression(for: suppressionWindow) #if DEBUG - cmuxDebugLog( + dlog( "shortcut.escape requestGraceConsume consumed=1 target={\(debugWindowToken(suppressionWindow))} " + "ageMs=\(Int(requestAge * 1000)) repeat=\(event.isARepeat ? 1 : 0)" ) @@ -10066,7 +9548,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } #if DEBUG - cmuxDebugLog( + dlog( "shortcut.escape paletteDismiss consumed=0 target={\(debugWindowToken(commandPaletteTargetWindow))} " + "active={\(debugWindowToken(activePaletteWindow))}" ) @@ -10118,7 +9600,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) #if DEBUG if event.keyCode == 36 || event.keyCode == 76 { - cmuxDebugLog( + dlog( "shortcut.palette.return target={\(debugWindowToken(paletteWindow))} " + "mode=\(paletteSnapshot.mode) " + "inline=\(paletteUsesInlineReturnHandling ? 1 : 0) " + @@ -10149,7 +9631,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG let stalePanelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - cmuxDebugLog( + dlog( "browser.focus.addressBar.staleClear panel=\(stalePanelToken) " + "reason=terminal_first_responder fr=\(firstResponderType)" ) @@ -10226,7 +9708,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let didSynchronizeShortcutContext = synchronizeShortcutRoutingContext(event: event) if hasEventWindowContext && !didSynchronizeShortcutContext { #if DEBUG - cmuxDebugLog("handleCustomShortcut: unresolved event window context; bypassing app shortcut handling") + dlog("handleCustomShortcut: unresolved event window context; bypassing app shortcut handling") #endif return false } @@ -10238,12 +9720,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let selected = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil" let focused = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil" let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - cmuxDebugLog("shortcut.ctrlD stage=preReconcile selected=\(selected) focused=\(focused) fr=\(frType)") + dlog("shortcut.ctrlD stage=preReconcile selected=\(selected) focused=\(focused) fr=\(frType)") #endif tabManager?.reconcileFocusedPanelFromFirstResponderForKeyboard() #if DEBUG let frAfterType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - cmuxDebugLog("shortcut.ctrlD stage=postReconcile fr=\(frAfterType)") + dlog("shortcut.ctrlD stage=postReconcile fr=\(frAfterType)") writeChildExitKeyboardProbe([:], increments: ["probeAppShortcutCtrlDPassedCount": 1]) #endif // Ctrl+D belongs to the focused terminal surface; never treat it as an app shortcut. @@ -10284,25 +9766,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } - let configuredCmuxShortcutContext = preferredMainWindowContextForShortcutRouting(event: event) - let configuredCmuxShortcutActions = configuredCmuxShortcutActions(for: configuredCmuxShortcutContext) - if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, - armConfiguredShortcutChordIfNeeded( - event: event, - shortcuts: configuredCmuxShortcutActions.compactMap(\.shortcut) - ) { - return true - } - - if let rightSidebarWindow = mainWindowForShortcutEvent(event) ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, - let mode = RightSidebarMode.modeShortcut(for: event), - rightSidebarWindow.firstResponder.map({ isRightSidebarFocusResponder($0, in: rightSidebarWindow) }) == true { - _ = focusRightSidebarInActiveMainWindow( - mode: mode, - focusFirstItem: true, - preferredWindow: rightSidebarWindow - ) + armConfiguredShortcutChordIfNeeded(event: event) { return true } @@ -10339,14 +9804,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if handleConfiguredCmuxShortcut( - event: event, - actions: configuredCmuxShortcutActions, - context: configuredCmuxShortcutContext - ) { - return true - } - // Primary UI shortcuts if matchConfiguredShortcut(event: event, action: .toggleSidebar) { _ = toggleSidebarInActiveMainWindow() @@ -10355,9 +9812,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if matchConfiguredShortcut(event: event, action: .newTab) { #if DEBUG - cmuxDebugLog("shortcut.action name=newWorkspace \(debugShortcutRouteSnapshot(event: event))") + dlog("shortcut.action name=newWorkspace \(debugShortcutRouteSnapshot(event: event))") #endif - performNewWorkspaceAction(event: event, debugSource: "shortcut.cmdN") + // Cmd+N semantics: + // - If there are no main windows, create a new window. + // - Otherwise, create a new workspace in the active window. + if mainWindowContexts.isEmpty { + #if DEBUG + logWorkspaceCreationRouting( + phase: "fallback_new_window", + source: "shortcut.cmdN", + reason: "no_main_windows", + event: event, + chosenContext: nil + ) + #endif + openNewMainWindow(nil) + } else if addWorkspaceInPreferredMainWindow(event: event, debugSource: "shortcut.cmdN") == nil { + #if DEBUG + logWorkspaceCreationRouting( + phase: "fallback_new_window", + source: "shortcut.cmdN", + reason: "workspace_creation_returned_nil", + event: event, + chosenContext: nil + ) + #endif + openNewMainWindow(nil) + } return true } @@ -10462,7 +9944,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if matchConfiguredShortcut(event: event, action: .toggleTerminalCopyMode) { let handled = tabManager?.toggleFocusedTerminalCopyMode() ?? false #if DEBUG - cmuxDebugLog( + dlog( "shortcut.action name=toggleTerminalCopyMode handled=\(handled ? 1 : 0) " + "\(debugShortcutRouteSnapshot(event: event))" ) @@ -10476,7 +9958,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if matchConfiguredShortcut(event: event, action: .nextSidebarTab) { #if DEBUG let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" - cmuxDebugLog( + dlog( "ws.shortcut dir=next repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)" ) #endif @@ -10487,7 +9969,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if matchConfiguredShortcut(event: event, action: .prevSidebarTab) { #if DEBUG let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" - cmuxDebugLog( + dlog( "ws.shortcut dir=prev repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)" ) #endif @@ -10503,7 +9985,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if matchConfiguredShortcut(event: event, action: .editWorkspaceDescription) { #if DEBUG - cmuxDebugLog( + dlog( "shortcut.editWorkspaceDescription matched target={\(debugWindowToken(commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow))} " + "\(debugShortcutRouteSnapshot(event: event))" ) @@ -10540,7 +10022,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent .compactMap({ $0 }) .first(where: { $0.identifier?.rawValue == "cmux.browser-popup" }) { #if DEBUG - cmuxDebugLog("shortcut.cmdW route=browserPopup") + dlog("shortcut.cmdW route=browserPopup") #endif targetWindow.performClose(nil) return true @@ -10551,7 +10033,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if let routedManager { #if DEBUG let selectedWorkspace = routedManager.selectedWorkspace - cmuxDebugLog( + dlog( "shortcut.cmdW route=workspaceModel workspace=\(selectedWorkspace?.id.uuidString.prefix(5) ?? "nil") " + "panel=\(selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil") " + "selected=\(routedManager.selectedTabId?.uuidString.prefix(5) ?? "nil")" @@ -10560,7 +10042,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent routedManager.closeCurrentPanelWithConfirmation() } else { #if DEBUG - cmuxDebugLog("shortcut.cmdW route=noManager") + dlog("shortcut.cmdW route=noManager") #endif return false } @@ -10574,7 +10056,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } if matchConfiguredShortcut(event: event, action: .closeWindow) { - guard let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow else { + guard let targetWindow = mainWindowForShortcutEvent(event) ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow else { NSSound.beep() return true } @@ -10584,7 +10066,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if matchConfiguredShortcut(event: event, action: .renameTab) { // Keep Cmd+R browser reload behavior when a browser panel is focused. - if tabManager?.focusedBrowserPanel != nil { + if focusedBrowserPanelForShortcutEvent(event) != nil { return false } let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow @@ -10599,7 +10081,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if let manager = tabManager, let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forDigit: digit, workspaceCount: manager.tabs.count) { #if DEBUG - cmuxDebugLog( + dlog( "shortcut.action name=workspaceDigit digit=\(digit) targetIndex=\(targetIndex) manager=\(debugManagerToken(manager)) \(debugShortcutRouteSnapshot(event: event))" ) #endif @@ -10618,53 +10100,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - // Pane focus navigation (defaults to Cmd+Option+Arrow, but can be customized to letter/number keys). - if matchConfiguredDirectionalShortcut( - event: event, - action: .focusLeft, - arrowGlyph: "←", - arrowKeyCode: 123 - ) || (ghosttyGotoSplitLeftShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "←", arrowKeyCode: 123) } ?? false) { - tabManager?.movePaneFocus(direction: .left) -#if DEBUG - recordGotoSplitMoveIfNeeded(direction: .left) -#endif - return true - } - if matchConfiguredDirectionalShortcut( - event: event, - action: .focusRight, - arrowGlyph: "→", - arrowKeyCode: 124 - ) || (ghosttyGotoSplitRightShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "→", arrowKeyCode: 124) } ?? false) { - tabManager?.movePaneFocus(direction: .right) -#if DEBUG - recordGotoSplitMoveIfNeeded(direction: .right) -#endif - return true - } - if matchConfiguredDirectionalShortcut( - event: event, - action: .focusUp, - arrowGlyph: "↑", - arrowKeyCode: 126 - ) || (ghosttyGotoSplitUpShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↑", arrowKeyCode: 126) } ?? false) { - tabManager?.movePaneFocus(direction: .up) -#if DEBUG - recordGotoSplitMoveIfNeeded(direction: .up) -#endif - return true - } - if matchConfiguredDirectionalShortcut( + if handlePaneFocusNavigationShortcut( event: event, - action: .focusDown, - arrowGlyph: "↓", - arrowKeyCode: 125 - ) || (ghosttyGotoSplitDownShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↓", arrowKeyCode: 125) } ?? false) { - tabManager?.movePaneFocus(direction: .down) -#if DEBUG - recordGotoSplitMoveIfNeeded(direction: .down) -#endif + preferredWindow: mainWindowForShortcutEvent(event), + source: "custom" + ) { return true } @@ -10679,7 +10119,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Split actions: Cmd+D / Cmd+Shift+D if matchConfiguredShortcut(event: event, action: .splitRight) { #if DEBUG - cmuxDebugLog("shortcut.action name=splitRight \(debugShortcutRouteSnapshot(event: event))") + dlog("shortcut.action name=splitRight \(debugShortcutRouteSnapshot(event: event))") #endif if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .right) { return true @@ -10693,7 +10133,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if matchConfiguredShortcut(event: event, action: .splitDown) { #if DEBUG - cmuxDebugLog("shortcut.action name=splitDown \(debugShortcutRouteSnapshot(event: event))") + dlog("shortcut.action name=splitDown \(debugShortcutRouteSnapshot(event: event))") #endif if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .down) { return true @@ -10707,7 +10147,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if matchConfiguredShortcut(event: event, action: .splitBrowserRight) { #if DEBUG - cmuxDebugLog("shortcut.action name=splitBrowserRight \(debugShortcutRouteSnapshot(event: event))") + dlog("shortcut.action name=splitBrowserRight \(debugShortcutRouteSnapshot(event: event))") #endif _ = performBrowserSplitShortcut(direction: .right) return true @@ -10715,7 +10155,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if matchConfiguredShortcut(event: event, action: .splitBrowserDown) { #if DEBUG - cmuxDebugLog("shortcut.action name=splitBrowserDown \(debugShortcutRouteSnapshot(event: event))") + dlog("shortcut.action name=splitBrowserDown \(debugShortcutRouteSnapshot(event: event))") #endif _ = performBrowserSplitShortcut(direction: .down) return true @@ -10744,7 +10184,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } if matchConfiguredShortcut(event: event, action: .focusBrowserAddressBar) { - if let focusedPanel = tabManager?.focusedBrowserPanel { + if let focusedPanel = focusedBrowserPanelForShortcutEvent(event) { focusBrowserAddressBar(in: focusedPanel) return true } @@ -10760,7 +10200,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } if matchConfiguredShortcut(event: event, action: .browserBack) { - guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { + guard let focusedBrowserPanel = focusedBrowserPanelForShortcutEvent(event) else { return false } focusedBrowserPanel.goBack() @@ -10768,7 +10208,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } if matchConfiguredShortcut(event: event, action: .browserForward) { - guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { + guard let focusedBrowserPanel = focusedBrowserPanelForShortcutEvent(event) else { return false } focusedBrowserPanel.goForward() @@ -10776,7 +10216,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } if matchConfiguredShortcut(event: event, action: .browserReload) { - guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { + guard let focusedBrowserPanel = focusedBrowserPanelForShortcutEvent(event) else { return false } focusedBrowserPanel.reload() @@ -10790,7 +10230,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG logDeveloperToolsShortcutSnapshot(phase: "toggle.pre", event: event) #endif - let didHandle = tabManager?.toggleDeveloperToolsFocusedBrowser() ?? false + let didHandle = focusedBrowserPanelForShortcutEvent(event)?.toggleDeveloperTools() ?? false #if DEBUG logDeveloperToolsShortcutSnapshot(phase: "toggle.post", event: event, didHandle: didHandle) DispatchQueue.main.async { [weak self] in @@ -10805,7 +10245,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG logDeveloperToolsShortcutSnapshot(phase: "console.pre", event: event) #endif - let didHandle = tabManager?.showJavaScriptConsoleFocusedBrowser() ?? false + let didHandle = focusedBrowserPanelForShortcutEvent(event)?.showDeveloperToolsConsole() ?? false #if DEBUG logDeveloperToolsShortcutSnapshot(phase: "console.post", event: event, didHandle: didHandle) DispatchQueue.main.async { [weak self] in @@ -10823,18 +10263,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } if matchConfiguredShortcut(event: event, action: .browserZoomIn) { - return tabManager?.zoomInFocusedBrowser() ?? false + return focusedBrowserPanelForShortcutEvent(event)?.zoomIn() ?? false } if matchConfiguredShortcut(event: event, action: .browserZoomOut) { - return tabManager?.zoomOutFocusedBrowser() ?? false + return focusedBrowserPanelForShortcutEvent(event)?.zoomOut() ?? false } if matchConfiguredShortcut(event: event, action: .browserZoomReset) { - return tabManager?.resetZoomFocusedBrowser() ?? false + return focusedBrowserPanelForShortcutEvent(event)?.resetZoom() ?? false } if matchConfiguredShortcut(event: event, action: .find) { + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false + } return performFindShortcutInActiveMainWindow(preferredWindow: resolvedShortcutEventWindow(event)) } @@ -10866,15 +10309,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } if matchConfiguredShortcut(event: event, action: .useSelectionForFind) { - restoreFocusedMainPanelFocusForShortcut(event: event) - tabManager?.searchSelection() - return true - } - - if matchConfiguredShortcut(event: event, action: .reopenPreviousSession) { - if !reopenPreviousSession() { - NSSound.beep() + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false } + tabManager?.searchSelection() return true } @@ -10919,7 +10357,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent case .down: directionLabel = "down" } let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - cmuxDebugLog( + dlog( "split.shortcut suppressed dir=\(directionLabel) reason=transient_focus_state " + "fr=\(firstResponderType) hidden=\(hostedHiddenInHierarchy ? 1 : 0) " + "attached=\(hostedAttachedToWindow ? 1 : 0) " + @@ -10961,7 +10399,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if let handled { line += " handled=\(handled ? 1 : 0)" } - cmuxDebugLog(line) + dlog(line) } private func browserFocusStateSnapshot() -> String { @@ -10992,7 +10430,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let workspace = tabManager.selectedWorkspace, let panel = workspace.browserPanel(for: panelId) else { #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.addressBar.route panel=\(panelId.uuidString.prefix(5)) " + "result=miss \(browserFocusStateSnapshot())" ) @@ -11000,7 +10438,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.addressBar.route panel=\(panel.id.uuidString.prefix(5)) " + "workspace=\(workspace.id.uuidString.prefix(5)) result=hit \(browserFocusStateSnapshot())" ) @@ -11008,7 +10446,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent workspace.focusPanel(panel.id) #if DEBUG let focusedAfter = workspace.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - cmuxDebugLog( + dlog( "browser.focus.addressBar.route panel=\(panel.id.uuidString.prefix(5)) " + "workspace=\(workspace.id.uuidString.prefix(5)) focusedAfter=\(focusedAfter)" ) @@ -11028,7 +10466,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent insertAtEnd: insertAtEnd ) else { #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.openAndFocus result=open_failed insertAtEnd=\(insertAtEnd ? 1 : 0) " + "url=\(redactedDebugURL(url)) \(browserFocusStateSnapshot())" ) @@ -11036,14 +10474,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return nil } #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.openAndFocus result=open_ok panel=\(panelId.uuidString.prefix(5)) " + "insertAtEnd=\(insertAtEnd ? 1 : 0) url=\(redactedDebugURL(url))" ) #endif #if DEBUG let didFocus = focusBrowserAddressBar(panelId: panelId) - cmuxDebugLog( + dlog( "browser.focus.openAndFocus result=focus_request panel=\(panelId.uuidString.prefix(5)) " + "focused=\(didFocus ? 1 : 0) \(browserFocusStateSnapshot())" ) @@ -11056,7 +10494,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func focusBrowserAddressBar(in panel: BrowserPanel) { #if DEBUG let requestId = panel.requestAddressBarFocus() - cmuxDebugLog( + dlog( "browser.focus.addressBar.request panel=\(panel.id.uuidString.prefix(5)) " + "request=\(requestId.uuidString.prefix(8)) \(browserFocusStateSnapshot())" ) @@ -11065,14 +10503,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif browserAddressBarFocusedPanelId = panel.id #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.addressBar.sticky panel=\(panel.id.uuidString.prefix(5)) " + "request=\(requestId.uuidString.prefix(8)) \(browserFocusStateSnapshot())" ) #endif NotificationCenter.default.post(name: .browserFocusAddressBar, object: panel.id) #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.addressBar.notify panel=\(panel.id.uuidString.prefix(5)) " + "request=\(requestId.uuidString.prefix(8))" ) @@ -11088,7 +10526,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let context = preferredMainWindowContextForShortcutRouting(event: event) else { #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + "accepted=0 reason=no_context event=\(NSWindow.keyDescription(event))" ) @@ -11098,7 +10536,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let workspace = context.tabManager.selectedWorkspace else { #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + "accepted=0 reason=no_workspace event=\(NSWindow.keyDescription(event))" ) @@ -11108,7 +10546,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let panel = workspace.browserPanel(for: panelId) else { #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + "accepted=0 reason=panel_not_in_workspace workspace=\(workspace.id.uuidString.prefix(5)) " + "event=\(NSWindow.keyDescription(event))" @@ -11123,7 +10561,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard isBrowserOmnibarResponder(shortcutResponder) else { #if DEBUG let focusedPanel = workspace.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - cmuxDebugLog( + dlog( "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + "accepted=0 reason=responder_not_omnibar responder=\(shortcutResponder.map { String(describing: type(of: $0)) } ?? "nil") " + "pending=\(panel.pendingAddressBarFocusRequestId != nil ? 1 : 0) focusedPanel=\(focusedPanel) " + @@ -11134,7 +10572,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + "accepted=1 reason=omnibar_responder workspace=\(workspace.id.uuidString.prefix(5)) " + "event=\(NSWindow.keyDescription(event))" @@ -11194,7 +10632,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG if shouldBypass { let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - cmuxDebugLog( + dlog( "browser.focus.addressBar.shortcutBypass panel=\(panelToken) " + "chars=\(chars) flags=\(normalizedFlags.rawValue)" ) @@ -11219,7 +10657,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard delta != 0 else { return } guard let panelId = browserAddressBarFocusedPanelId else { return } #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.omnibar.selectionMove panel=\(panelId.uuidString.prefix(5)) " + "delta=\(delta) repeatKey=\(browserOmnibarRepeatKeyCode.map(String.init) ?? "nil")" ) @@ -11235,7 +10673,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard delta != 0 else { return } guard browserAddressBarFocusedPanelId != nil else { #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.omnibar.repeat.start key=\(keyCode) delta=\(delta) " + "result=skip_no_focused_address_bar" ) @@ -11246,7 +10684,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if browserOmnibarRepeatKeyCode == keyCode, browserOmnibarRepeatDelta == delta { #if DEBUG let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - cmuxDebugLog( + dlog( "browser.focus.omnibar.repeat.start panel=\(panelToken) " + "key=\(keyCode) delta=\(delta) result=reuse" ) @@ -11259,7 +10697,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent browserOmnibarRepeatDelta = delta #if DEBUG let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - cmuxDebugLog( + dlog( "browser.focus.omnibar.repeat.start panel=\(panelToken) " + "key=\(keyCode) delta=\(delta) result=armed" ) @@ -11276,7 +10714,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent browserOmnibarRepeatStartWorkItem = nil guard browserAddressBarFocusedPanelId != nil else { #if DEBUG - cmuxDebugLog("browser.focus.omnibar.repeat.tick result=stop_no_focused_address_bar") + dlog("browser.focus.omnibar.repeat.tick result=stop_no_focused_address_bar") #endif stopBrowserOmnibarSelectionRepeat() return @@ -11285,7 +10723,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - cmuxDebugLog( + dlog( "browser.focus.omnibar.repeat.tick panel=\(panelToken) " + "delta=\(browserOmnibarRepeatDelta)" ) @@ -11312,7 +10750,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent browserOmnibarRepeatDelta = 0 #if DEBUG if previousKeyCode != nil || previousDelta != 0 { - cmuxDebugLog( + dlog( "browser.focus.omnibar.repeat.stop key=\(previousKeyCode.map(String.init) ?? "nil") " + "delta=\(previousDelta)" ) @@ -11327,7 +10765,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent case .keyUp: if event.keyCode == browserOmnibarRepeatKeyCode { #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.omnibar.repeat.lifecycle event=keyUp key=\(event.keyCode) " + "action=stop" ) @@ -11338,7 +10776,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) if !flags.contains(.command) { #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.omnibar.repeat.lifecycle event=flagsChanged " + "flags=\(flags.rawValue) action=stop" ) @@ -11394,7 +10832,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if let didHandle { line += " handled=\(didHandle ? 1 : 0)" } - cmuxDebugLog(line) + dlog(line) return } var line = @@ -11403,7 +10841,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if let didHandle { line += " handled=\(didHandle ? 1 : 0)" } - cmuxDebugLog(line) + dlog(line) } #endif @@ -11414,7 +10852,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard isLikelyWebInspectorResponder(keyWindow.firstResponder) else { return } let beforeResponder = keyWindow.firstResponder - let movedToWebView = keyWindow.makeFirstResponder(browser.webView) + let movedToWebView = browser.requestExplicitWebViewFocus() let movedToNil = movedToWebView ? false : keyWindow.makeFirstResponder(nil) #if DEBUG @@ -11423,7 +10861,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let afterResponder = keyWindow.firstResponder let afterType = afterResponder.map { String(describing: type(of: $0)) } ?? "nil" let afterPtr = afterResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" - cmuxDebugLog( + dlog( "split.shortcut inspector.preflight dir=\(directionLabel) panel=\(browser.id.uuidString.prefix(5)) " + "before=\(beforeType)@\(beforePtr) after=\(afterType)@\(afterPtr) " + "moveWeb=\(movedToWebView ? 1 : 0) moveNil=\(movedToNil ? 1 : 0) \(browser.debugDeveloperToolsStateSummary())" @@ -11463,13 +10901,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if let browser = tabManager?.focusedBrowserPanel { let webWindow = browser.webView.window?.windowNumber ?? -1 let webSuperview = browser.webView.superview.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" - cmuxDebugLog("split.shortcut dir=\(directionLabel) pre panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)") + dlog("split.shortcut dir=\(directionLabel) pre panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)") } else { - cmuxDebugLog("split.shortcut dir=\(directionLabel) pre panel=nil \(splitContext)") + dlog("split.shortcut dir=\(directionLabel) pre panel=nil \(splitContext)") } #endif prepareFocusedBrowserDevToolsForSplit(directionLabel: directionLabel) +#if DEBUG + if direction == .right { + latencyLog( + "cmd_d.performSplitShortcut.begin", + data: [ + "selectedTab": tabManager?.selectedTabId?.uuidString.prefix(5).description ?? "nil", + "focusedPanel": tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5).description ?? "nil", + "terminalContext": terminalContext != nil ? "1" : "0", + ] + ) + } +#endif let didCreateSplit: Bool = { if let terminalContext { return terminalContext.tabManager.createSplit( @@ -11480,6 +10930,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } return tabManager?.createSplit(direction: direction) != nil }() +#if DEBUG + if direction == .right { + latencyLog( + "cmd_d.performSplitShortcut.end", + data: [ + "created": didCreateSplit ? "1" : "0", + "selectedTab": tabManager?.selectedTabId?.uuidString.prefix(5).description ?? "nil", + "focusedPanel": tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5).description ?? "nil", + ] + ) + } +#endif #if DEBUG DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in let keyWindow = NSApp.keyWindow @@ -11499,9 +10961,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if let browser = self?.tabManager?.focusedBrowserPanel { let webWindow = browser.webView.window?.windowNumber ?? -1 let webSuperview = browser.webView.superview.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" - cmuxDebugLog("split.shortcut dir=\(directionLabel) post panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)") + dlog("split.shortcut dir=\(directionLabel) post panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)") } else { - cmuxDebugLog("split.shortcut dir=\(directionLabel) post panel=nil \(splitContext)") + dlog("split.shortcut dir=\(directionLabel) post panel=nil \(splitContext)") } } recordGotoSplitSplitIfNeeded(direction: direction) @@ -11523,7 +10985,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } let selectedTabBefore = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil" let focusedPanelBefore = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil" - cmuxDebugLog( + dlog( "split.browser.shortcut pre dir=\(directionLabel) " + "tab=\(selectedTabBefore) focusedPanel=\(focusedPanelBefore)" ) @@ -11531,7 +10993,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let panelId = tabManager?.createBrowserSplit(direction: direction) else { #if DEBUG - cmuxDebugLog("split.browser.shortcut failed dir=\(directionLabel)") + dlog("split.browser.shortcut failed dir=\(directionLabel)") #endif return false } @@ -11539,7 +11001,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG let selectedTabAfter = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil" let focusedPanelAfter = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil" - cmuxDebugLog( + dlog( "split.browser.shortcut post dir=\(directionLabel) " + "created=\(panelId.uuidString.prefix(5)) tab=\(selectedTabAfter) focusedPanel=\(focusedPanelAfter)" ) @@ -11553,57 +11015,193 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent /// through the same app-level shortcut handler used by the local key monitor. @discardableResult func handleBrowserSurfaceKeyEquivalent(_ event: NSEvent) -> Bool { - handleCustomShortcut(event: event) - } - - /// WebKit can consume Cmd+F as a browser find key equivalent before SwiftUI - /// command actions run. Keep this pre-menu route narrow so normal menu-backed - /// browser shortcuts such as Cmd+N, Cmd+W, and Cmd+R still use AppKit. - @discardableResult - func handleBrowserSurfaceKeyEquivalentBeforeMainMenu(_ event: NSEvent) -> Bool { - if matchConfiguredShortcut(event: event, action: .find) { - return performFindShortcutInActiveMainWindow(preferredWindow: resolvedShortcutEventWindow(event)) + if handlePaneFocusNavigationCommandEvent( + event, + preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, + source: "browserSurface" + ) { + return true } - return false + return handleCustomShortcut(event: event) } + /// AppKit text controls can consume Cmd+Ctrl pane-focus chords before the normal + /// shortcut fallback sees them. Route pane navigation at the window boundary. @discardableResult - func requestRenameWorkspaceViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteRenameWorkspace( - preferredWindow: targetWindow, - source: "shortcut.renameWorkspace" + func handlePaneFocusNavigationCommandEvent( + _ event: NSEvent, + preferredWindow: NSWindow?, + source: String + ) -> Bool { + guard event.type == .keyDown else { return false } + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + guard flags.contains(.command) else { return false } + guard NSApp.modalWindow == nil, + preferredWindow?.attachedSheet == nil else { + return false + } + if handlePaneFocusNavigationShortcut( + event: event, + preferredWindow: preferredWindow, + source: source + ) { + return true + } + guard let direction = paneFocusNavigationDirectionIgnoringChordPrefix(event: event) else { + return false + } + return movePaneFocusForShortcut( + direction: direction, + preferredWindow: preferredWindow, + source: "\(source).direct" ) - return true } @discardableResult - func requestEditWorkspaceDescriptionViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + func handleBrowserSearchOverlayKeyDown( + _ event: NSEvent, + panelId: UUID, + in window: NSWindow + ) -> Bool { + guard event.type == .keyDown else { return false } + guard event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command) else { + return false + } + if handlePaneFocusNavigationCommandEvent( + event, + preferredWindow: window, + source: "browserFindOverlay" + ) { #if DEBUG - cmuxDebugLog( - "shortcut.editWorkspaceDescription request target={\(debugWindowToken(targetWindow))} " + - "fr=\(targetWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" - ) + dlog( + "shortcut.browserFindOverlay paneFocus panel=\(panelId.uuidString.prefix(5)) " + + "keyCode=\(event.keyCode)" + ) #endif - requestCommandPaletteEditWorkspaceDescription( - preferredWindow: targetWindow, - source: "shortcut.editWorkspaceDescription" - ) - return true + return true + } + return handleCustomShortcut(event: event) } -#if DEBUG - // Debug/test hook: allow socket-driven shortcut simulation to reuse the same shortcut routing - // logic as the local NSEvent monitor, without relying on AppKit event monitor behavior for - // synthetic NSEvents. - func debugHandleCustomShortcut(event: NSEvent) -> Bool { - handleCustomShortcut(event: event) - } + @discardableResult + func handleBrowserSearchOverlayCommandEvent( + _ event: NSEvent, + preferredWindow: NSWindow? + ) -> Bool { + guard event.type == .keyDown else { return false } + guard event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command) else { + return false + } + + let candidateWindows = [ + preferredWindow, + event.window, + NSApp.keyWindow, + NSApp.mainWindow, + ].compactMap { $0 } + var seenWindows = Set() + + for window in candidateWindows where seenWindows.insert(ObjectIdentifier(window)).inserted { + let searchOverlayPanelId = window.firstResponder.flatMap { + browserSearchOverlayPanelId(for: $0) ?? + BrowserWindowPortalRegistry.searchOverlayPanelId(for: $0, in: window) + } ?? activeBrowserSearchOverlayPanelId(in: window) + guard let searchOverlayPanelId else { continue } + if handleBrowserSearchOverlayKeyDown(event, panelId: searchOverlayPanelId, in: window) { + return true + } + } + + return false + } + + func activeBrowserSearchOverlayPanelId(in window: NSWindow) -> UUID? { + guard let firstResponder = window.firstResponder else { return nil } + let responderLooksLikeSearchOverlay = responderLooksLikeBrowserSearchOverlay(firstResponder, in: window) + let responderIsFieldEditor = (firstResponder as? NSTextView)?.isFieldEditor == true + guard responderLooksLikeSearchOverlay || responderIsFieldEditor else { return nil } + + let routedManager = synchronizeActiveMainWindowContext(preferredWindow: window) ?? tabManager + guard let workspace = routedManager?.selectedWorkspace else { return nil } + let browserSearchPanels = workspace.panels.values + .compactMap { $0 as? BrowserPanel } + .filter { $0.searchState != nil } + + if let focusedPanelId = workspace.focusedPanelId, + let browserPanel = workspace.panels[focusedPanelId] as? BrowserPanel, + browserPanel.searchState != nil { + return browserPanel.id + } + guard responderLooksLikeSearchOverlay else { + if workspace.focusedPanelId == nil, browserSearchPanels.count == 1 { + return browserSearchPanels[0].id + } + return nil + } + if let focusedFindPanel = browserSearchPanels.first(where: { $0.preferredFocusIntent == .findField }) { + return focusedFindPanel.id + } + return browserSearchPanels.first?.id + } + + private func responderLooksLikeBrowserSearchOverlay( + _ responder: NSResponder, + in window: NSWindow + ) -> Bool { + if browserSearchOverlayPanelId(for: responder) != nil || + BrowserWindowPortalRegistry.searchOverlayPanelId(for: responder, in: window) != nil { + return true + } + + if let textField = responder as? NSTextField { + return textField.identifier?.rawValue == "BrowserFindSearchTextField" + } + + guard let editor = responder as? NSTextView, + editor.isFieldEditor else { + return false + } + let field = editor.delegate as? NSTextField + return field?.identifier?.rawValue == "BrowserFindSearchTextField" + } + + @discardableResult + func requestRenameWorkspaceViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteRenameWorkspace( + preferredWindow: targetWindow, + source: "shortcut.renameWorkspace" + ) + return true + } + + @discardableResult + func requestEditWorkspaceDescriptionViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow +#if DEBUG + dlog( + "shortcut.editWorkspaceDescription request target={\(debugWindowToken(targetWindow))} " + + "fr=\(targetWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" + ) +#endif + requestCommandPaletteEditWorkspaceDescription( + preferredWindow: targetWindow, + source: "shortcut.editWorkspaceDescription" + ) + return true + } + +#if DEBUG + // Debug/test hook: allow socket-driven shortcut simulation to reuse the same shortcut routing + // logic as the local NSEvent monitor, without relying on AppKit event monitor behavior for + // synthetic NSEvents. + func debugHandleCustomShortcut(event: NSEvent) -> Bool { + handleCustomShortcut(event: event) + } // Debug/test hook: mirrors local monitor routing (keyDown + keyUp lifecycle). func debugHandleShortcutMonitorEvent(event: NSEvent) -> Bool { - if event.type == .keyDown || event.type == .systemDefined { + if event.type == .keyDown { return handleCustomShortcut(event: event) } handleBrowserOmnibarSelectionRepeatLifecycleEvent(event) @@ -11675,7 +11273,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } - private func matchConfiguredShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { + private func matchConfiguredShortcut(event: NSEvent, action: KeyboardShortcutSettings.Action) -> Bool { + let shortcut = KeyboardShortcutSettings.shortcut(for: action) if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { guard let secondStroke = shortcut.secondStroke, shortcut.firstStroke == prefix else { @@ -11687,10 +11286,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return matchShortcutStroke(event: event, stroke: shortcut.firstStroke) } - private func matchConfiguredShortcut(event: NSEvent, action: KeyboardShortcutSettings.Action) -> Bool { - matchConfiguredShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: action)) - } - private func numberedConfiguredShortcutDigit( event: NSEvent, action: KeyboardShortcutSettings.Action @@ -11735,6 +11330,152 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } + private func matchDirectionalShortcutIgnoringChordPrefix( + event: NSEvent, + action: KeyboardShortcutSettings.Action, + arrowGlyph: String, + arrowKeyCode: UInt16 + ) -> Bool { + let shortcut = KeyboardShortcutSettings.shortcut(for: action) + guard !shortcut.hasChord else { return false } + return matchDirectionalShortcut( + event: event, + stroke: shortcut.firstStroke, + arrowGlyph: arrowGlyph, + arrowKeyCode: arrowKeyCode + ) + } + + @discardableResult + private func handlePaneFocusNavigationShortcut( + event: NSEvent, + preferredWindow: NSWindow?, + source: String + ) -> Bool { + // Pane focus navigation defaults to Cmd+Option+Arrow, but can be customized + // to letter/number keys. Ghostty's goto_split bindings are accepted too. + if matchConfiguredDirectionalShortcut( + event: event, + action: .focusLeft, + arrowGlyph: "←", + arrowKeyCode: 123 + ) || (ghosttyGotoSplitLeftShortcut.map { + matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "←", arrowKeyCode: 123) + } ?? false) { + return movePaneFocusForShortcut( + direction: .left, + preferredWindow: preferredWindow, + source: source + ) + } + if matchConfiguredDirectionalShortcut( + event: event, + action: .focusRight, + arrowGlyph: "→", + arrowKeyCode: 124 + ) || (ghosttyGotoSplitRightShortcut.map { + matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "→", arrowKeyCode: 124) + } ?? false) { + return movePaneFocusForShortcut( + direction: .right, + preferredWindow: preferredWindow, + source: source + ) + } + if matchConfiguredDirectionalShortcut( + event: event, + action: .focusUp, + arrowGlyph: "↑", + arrowKeyCode: 126 + ) || (ghosttyGotoSplitUpShortcut.map { + matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↑", arrowKeyCode: 126) + } ?? false) { + return movePaneFocusForShortcut( + direction: .up, + preferredWindow: preferredWindow, + source: source + ) + } + if matchConfiguredDirectionalShortcut( + event: event, + action: .focusDown, + arrowGlyph: "↓", + arrowKeyCode: 125 + ) || (ghosttyGotoSplitDownShortcut.map { + matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↓", arrowKeyCode: 125) + } ?? false) { + return movePaneFocusForShortcut( + direction: .down, + preferredWindow: preferredWindow, + source: source + ) + } + return false + } + + private func paneFocusNavigationDirectionIgnoringChordPrefix(event: NSEvent) -> NavigationDirection? { + if matchDirectionalShortcutIgnoringChordPrefix( + event: event, + action: .focusLeft, + arrowGlyph: "←", + arrowKeyCode: 123 + ) || (ghosttyGotoSplitLeftShortcut.map { + matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "←", arrowKeyCode: 123) + } ?? false) { + return .left + } + if matchDirectionalShortcutIgnoringChordPrefix( + event: event, + action: .focusRight, + arrowGlyph: "→", + arrowKeyCode: 124 + ) || (ghosttyGotoSplitRightShortcut.map { + matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "→", arrowKeyCode: 124) + } ?? false) { + return .right + } + if matchDirectionalShortcutIgnoringChordPrefix( + event: event, + action: .focusUp, + arrowGlyph: "↑", + arrowKeyCode: 126 + ) || (ghosttyGotoSplitUpShortcut.map { + matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↑", arrowKeyCode: 126) + } ?? false) { + return .up + } + if matchDirectionalShortcutIgnoringChordPrefix( + event: event, + action: .focusDown, + arrowGlyph: "↓", + arrowKeyCode: 125 + ) || (ghosttyGotoSplitDownShortcut.map { + matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↓", arrowKeyCode: 125) + } ?? false) { + return .down + } + return nil + } + + @discardableResult + private func movePaneFocusForShortcut( + direction: NavigationDirection, + preferredWindow: NSWindow?, + source: String + ) -> Bool { + let routedManager = synchronizeActiveMainWindowContext(preferredWindow: preferredWindow) ?? tabManager + guard let routedManager else { return false } + routedManager.movePaneFocus(direction: direction) +#if DEBUG + recordGotoSplitMoveIfNeeded(direction: direction) + dlog( + "shortcut.paneFocus source=\(source) direction=\(direction.rawValue) " + + "target={\(debugWindowToken(preferredWindow))} manager=\(debugManagerToken(routedManager))" + ) +#endif + return true + } + private func configuredShortcutChordWindowNumber(for event: NSEvent) -> Int? { if let window = mainWindowForShortcutEvent(event) { return window.windowNumber @@ -11747,15 +11488,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func armConfiguredShortcutChordIfNeeded( event: NSEvent, - actions: [KeyboardShortcutSettings.Action]? = nil, - shortcuts: [StoredShortcut] = [] + actions: [KeyboardShortcutSettings.Action]? = nil ) -> Bool { - var seen = Set() - let configuredShortcuts = (actions ?? configuredShortcutChordActions).map { - KeyboardShortcutSettings.shortcut(for: $0) - } + shortcuts - for shortcut in configuredShortcuts { - guard seen.insert(shortcut).inserted else { continue } + for action in actions ?? configuredShortcutChordActions { + let shortcut = KeyboardShortcutSettings.shortcut(for: action) guard shortcut.hasChord else { continue } if matchShortcutStroke(event: event, stroke: shortcut.firstStroke) { pendingConfiguredShortcutChord = PendingConfiguredShortcutChord( @@ -11768,85 +11504,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } - private func configuredCmuxShortcutActions( - for context: MainWindowContext? - ) -> [CmuxResolvedConfigAction] { - context?.cmuxConfigStore?.shortcutActions() ?? [] - } - - private func handleConfiguredCmuxShortcut( - event: NSEvent, - actions: [CmuxResolvedConfigAction], - context: MainWindowContext? - ) -> Bool { - for action in actions { - guard let shortcut = action.shortcut, - matchConfiguredShortcut(event: event, shortcut: shortcut) else { - continue - } - return executeConfiguredCmuxActionShortcut( - action, - event: event, - context: context - ) - } - return false - } - - private func executeConfiguredCmuxActionShortcut( - _ action: CmuxResolvedConfigAction, - event: NSEvent, - context: MainWindowContext? - ) -> Bool { - switch action.action { - case .builtIn(let builtIn): - switch builtIn { - case .newTerminal: - tabManager?.newSurface() - return true - case .newBrowser: - _ = openBrowserAndFocusAddressBar(insertAtEnd: true) - return true - case .splitRight: - if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .right) { - return true - } - _ = performSplitShortcut( - direction: .right, - preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - ) - return true - case .splitDown: - if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .down) { - return true - } - _ = performSplitShortcut( - direction: .down, - preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - ) - return true - } - case .command, .agent, .workspaceCommand: - guard let context, - let cmuxConfigStore = context.cmuxConfigStore else { - return false - } - let rawCwd = context.tabManager.selectedWorkspace?.currentDirectory - let baseCwd = (rawCwd?.isEmpty == false) ? rawCwd! - : FileManager.default.homeDirectoryForCurrentUser.path - return CmuxConfigExecutor.execute( - action: action, - commands: cmuxConfigStore.loadedCommands, - commandSourcePaths: cmuxConfigStore.commandSourcePaths, - tabManager: context.tabManager, - baseCwd: baseCwd, - globalConfigPath: cmuxConfigStore.globalConfigPath - ) - case .actionReference: - return false - } - } - /// Match a shortcut stroke against an event, handling normal keys. private func matchShortcutStroke(event: NSEvent, stroke: ShortcutStroke) -> Bool { stroke.matches(event: event, layoutCharacterProvider: shortcutLayoutCharacterProvider) @@ -12025,112 +11682,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent options: [.customDismissAction] ) - // Feed categories with inline decision buttons. Identifiers and - // action strings are matched in `handleFeedNotificationResponse`. - let permissionCategory = UNNotificationCategory( - identifier: "CMUXFeedPermission", - actions: [ - UNNotificationAction( - identifier: "feed.permission.once", - title: String(localized: "feed.notification.permission.allowOnce", defaultValue: "Allow Once") - ), - UNNotificationAction( - identifier: "feed.permission.always", - title: String(localized: "feed.notification.permission.always", defaultValue: "Always") - ), - UNNotificationAction( - identifier: "feed.permission.deny", - title: String(localized: "feed.notification.permission.deny", defaultValue: "Deny"), - options: [.destructive] - ), - ], - intentIdentifiers: [], - options: [] - ) - let exitPlanCategory = UNNotificationCategory( - identifier: "CMUXFeedExitPlan", - actions: [ - UNNotificationAction( - identifier: "feed.exit_plan.ultraplan", - title: String(localized: "feed.notification.exitPlan.ultraplan", defaultValue: "Ultraplan") - ), - UNNotificationAction( - identifier: "feed.exit_plan.manual", - title: String(localized: "feed.notification.exitPlan.manual", defaultValue: "Manual") - ), - UNNotificationAction( - identifier: "feed.exit_plan.autoAccept", - title: String(localized: "feed.notification.exitPlan.autoAccept", defaultValue: "Auto") - ), - ], - intentIdentifiers: [], - options: [] - ) - let questionCategory = UNNotificationCategory( - identifier: "CMUXFeedQuestion", - actions: [ - UNNotificationAction( - identifier: "feed.question.open", - title: String(localized: "feed.notification.question.reply", defaultValue: "Reply"), - options: [.foreground] - ), - ], - intentIdentifiers: [], - options: [] - ) - let center = UNUserNotificationCenter.current() - center.setNotificationCategories([ - category, permissionCategory, exitPlanCategory, questionCategory - ]) + center.setNotificationCategories([category]) center.delegate = self } - /// Routes a notification action identifier like - /// `feed.permission.once` back to `FeedCoordinator.deliverReply`. - /// Returns `true` if the identifier was Feed-owned. - private func handleFeedNotificationResponse(_ response: UNNotificationResponse) -> Bool { - let categoryId = response.notification.request.content.categoryIdentifier - guard categoryId == "CMUXFeedPermission" - || categoryId == "CMUXFeedExitPlan" - || categoryId == "CMUXFeedQuestion" - else { return false } - - guard let requestId = response.notification.request.content.userInfo["requestId"] as? String else { - return true - } + private func disableNativeTabbingShortcut() { + guard let menu = NSApp.mainMenu else { return } + disableMenuItemShortcut(in: menu, action: #selector(NSWindow.toggleTabBar(_:))) + } - switch response.actionIdentifier { - case "feed.permission.once": - FeedCoordinator.shared.deliverReply(requestId: requestId, decision: .permission(.once)) - case "feed.permission.always": - FeedCoordinator.shared.deliverReply(requestId: requestId, decision: .permission(.always)) - case "feed.permission.deny": - FeedCoordinator.shared.deliverReply(requestId: requestId, decision: .permission(.deny)) - case "feed.exit_plan.ultraplan": - FeedCoordinator.shared.deliverReply(requestId: requestId, decision: .exitPlan(.ultraplan)) - case "feed.exit_plan.bypassPermissions": - FeedCoordinator.shared.deliverReply(requestId: requestId, decision: .exitPlan(.bypassPermissions)) - case "feed.exit_plan.autoAccept": - FeedCoordinator.shared.deliverReply(requestId: requestId, decision: .exitPlan(.autoAccept)) - case "feed.exit_plan.manual": - FeedCoordinator.shared.deliverReply(requestId: requestId, decision: .exitPlan(.manual)) - case "feed.question.open": - // Open the app / focus the Feed sidebar; actual reply happens in-app. - NSApp.activate(ignoringOtherApps: true) - case UNNotificationDismissActionIdentifier, - UNNotificationDefaultActionIdentifier: - // Tap on the banner body opens the app so user can act in-UI. - NSApp.activate(ignoringOtherApps: true) - default: - break - } - return true + @objc private func handleNativeToggleSidebar(_ sender: Any?) { + _ = toggleSidebarInActiveMainWindow() } - private func disableNativeTabbingShortcut() { + private func retargetNativeSidebarMenuItem() { guard let menu = NSApp.mainMenu else { return } - disableMenuItemShortcut(in: menu, action: #selector(NSWindow.toggleTabBar(_:))) + retargetMenuItemAction( + in: menu, + action: NSSelectorFromString("toggleSidebar:"), + target: self, + replacementAction: #selector(handleNativeToggleSidebar(_:)) + ) + } + + private func retargetMenuItemAction( + in menu: NSMenu, + action: Selector, + target: AnyObject, + replacementAction: Selector + ) { + for item in menu.items { + if item.action == action { + item.target = target + item.action = replacementAction + } + if let submenu = item.submenu { + retargetMenuItemAction( + in: submenu, + action: action, + target: target, + replacementAction: replacementAction + ) + } + } } private func disableMenuItemShortcut(in menu: NSMenu, action: Selector) { @@ -12265,9 +11860,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func handleNotificationResponse(_ response: UNNotificationResponse) { - if handleFeedNotificationResponse(response) { - return - } guard let tabIdString = response.notification.request.content.userInfo["tabId"] as? String, let tabId = UUID(uuidString: tabIdString) else { return @@ -12309,16 +11901,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func installMainWindowKeyObserver() { - guard windowKeyObserver == nil else { return } - windowKeyObserver = NotificationCenter.default.addObserver( - forName: NSWindow.didBecomeKeyNotification, - object: nil, - queue: .main - ) { [weak self] note in - guard let self, let window = note.object as? NSWindow else { return } - MainActor.assumeIsolated { - self.setActiveMainWindow(window) - _ = self.contextForMainTerminalWindow(window)?.keyboardFocusCoordinator.restoreTargetAfterWindowBecameKey() + if windowKeyObserver == nil { + windowKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: nil, + queue: .main + ) { [weak self] note in + MainActor.assumeIsolated { + guard let self, let window = note.object as? NSWindow else { return } + self.setActiveMainWindow(window) + self.reassertFocusedBrowserAfterWindowBecameKey(window) + } + } + } + if windowResignKeyObserver == nil { + windowResignKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: nil, + queue: .main + ) { [weak self] note in + MainActor.assumeIsolated { + guard let self, let window = note.object as? NSWindow else { return } + self.suspendFocusedBrowserAfterWindowResignedKey(window) + } } } } @@ -12333,14 +11938,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent object: nil, queue: .main ) { [weak self] notification in - guard let self else { return } - guard let panelId = notification.object as? UUID else { return } - self.browserPanel(for: panelId)?.beginSuppressWebViewFocusForAddressBar() - self.browserAddressBarFocusedPanelId = panelId - self.stopBrowserOmnibarSelectionRepeat() + MainActor.assumeIsolated { + guard let self else { return } + guard let panelId = notification.object as? UUID else { return } + self.browserAddressBarFocusedPanelId = panelId + self.stopBrowserOmnibarSelectionRepeat() #if DEBUG - cmuxDebugLog("addressBar FOCUS panelId=\(panelId.uuidString.prefix(8))") + dlog("addressBar FOCUS panelId=\(panelId.uuidString.prefix(8))") #endif + } } browserAddressBarBlurObserver = NotificationCenter.default.addObserver( @@ -12348,15 +11954,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent object: nil, queue: .main ) { [weak self] notification in - guard let self else { return } - guard let panelId = notification.object as? UUID else { return } - self.browserPanel(for: panelId)?.endSuppressWebViewFocusForAddressBar() - if self.browserAddressBarFocusedPanelId == panelId { - self.browserAddressBarFocusedPanelId = nil - self.stopBrowserOmnibarSelectionRepeat() + MainActor.assumeIsolated { + guard let self else { return } + guard let panelId = notification.object as? UUID else { return } + if self.browserAddressBarFocusedPanelId == panelId { + self.browserAddressBarFocusedPanelId = nil + self.stopBrowserOmnibarSelectionRepeat() #if DEBUG - cmuxDebugLog("addressBar BLUR panelId=\(panelId.uuidString.prefix(8))") + dlog("addressBar BLUR panelId=\(panelId.uuidString.prefix(8))") #endif + } } } @@ -12365,44 +11972,61 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent object: nil, queue: .main ) { [weak self] notification in - guard let self else { return } - guard let webView = notification.object as? CmuxWebView, - let panel = self.browserPanelOwning(webView) else { return } - - if let trackedPanelId = self.browserAddressBarFocusedPanelId, - trackedPanelId != panel.id, - let trackedPanel = self.browserPanel(for: trackedPanelId), - !self.shouldPreserveBrowserAddressBarTracking(for: trackedPanel) { - trackedPanel.endSuppressWebViewFocusForAddressBar() - self.browserAddressBarFocusedPanelId = nil - self.stopBrowserOmnibarSelectionRepeat() -#if DEBUG - cmuxDebugLog( - "addressBar CLEAR panelId=\(trackedPanelId.uuidString.prefix(8)) " + - "reason=stale_other_panel_webViewFirstResponder" + MainActor.assumeIsolated { + guard let self else { return } + guard let webView = notification.object as? CmuxWebView else { return } + guard let panel = self.browserPanelOwning(webView) else { return } + + let ownership = self.workspaceContainingPanel( + panelId: panel.id, + preferredWorkspaceId: panel.workspaceId ) + + if let ownership, + ownership.tabManager.selectedTabId == ownership.workspace.id, + ownership.workspace.focusedPanelId != panel.id { + ownership.workspace.focusPanel(panel.id) + } + + if let trackedPanelId = self.browserAddressBarFocusedPanelId, + trackedPanelId != panel.id, + let trackedPanel = self.browserPanel(for: trackedPanelId), + !self.shouldPreserveBrowserAddressBarTracking(for: trackedPanel) { + trackedPanel.setAddressBarFocused(false, reason: "staleOtherPanelWebViewFirstResponder") + self.browserAddressBarFocusedPanelId = nil + self.stopBrowserOmnibarSelectionRepeat() +#if DEBUG + dlog( + "addressBar CLEAR panelId=\(trackedPanelId.uuidString.prefix(8)) " + + "reason=stale_other_panel_webViewFirstResponder" + ) #endif - } + } - guard !self.shouldPreserveBrowserAddressBarTracking(for: panel) else { + guard !self.shouldPreserveBrowserAddressBarTracking(for: panel) else { #if DEBUG - cmuxDebugLog( - "addressBar CLEAR panelId=\(panel.id.uuidString.prefix(8)) " + - "reason=skip_preserve_omnibar_handoff" - ) + dlog( + "addressBar CLEAR panelId=\(panel.id.uuidString.prefix(8)) " + + "reason=skip_preserve_omnibar_handoff" + ) #endif - return - } - panel.endSuppressWebViewFocusForAddressBar() - if self.browserAddressBarFocusedPanelId == panel.id { - self.browserAddressBarFocusedPanelId = nil - self.stopBrowserOmnibarSelectionRepeat() -#if DEBUG - cmuxDebugLog( - "addressBar CLEAR panelId=\(panel.id.uuidString.prefix(8)) " + - "reason=webViewFirstResponder" - ) + return + } + let hasContentFirstResponder = webView.window.map { panel.hasWebContentFirstResponder(in: $0) } ?? false + if hasContentFirstResponder { + panel.noteWebViewFocused() + } + if hasContentFirstResponder, self.browserAddressBarFocusedPanelId == panel.id { + panel.setAddressBarFocused(false, reason: "webViewFirstResponder") + self.browserAddressBarFocusedPanelId = nil + self.stopBrowserOmnibarSelectionRepeat() +#if DEBUG + dlog( + "addressBar CLEAR panelId=\(panel.id.uuidString.prefix(8)) " + + "reason=webViewFirstResponder" + ) #endif + } } } } @@ -12415,16 +12039,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent browserPanelOwning(webView)?.searchState != nil } + private func focusedBrowserPanelForShortcutEvent(_ event: NSEvent) -> BrowserPanel? { + let shortcutWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow + if let shortcutWindow, + let responder = shortcutWindow.firstResponder, + let responderWebView = NSWindow.cmuxOwningWebView(for: responder, in: shortcutWindow, event: event), + let responderPanel = browserPanelOwning(responderWebView) { + return responderPanel + } + + guard let context = preferredMainWindowContextForShortcutRouting(event: event), + let workspace = context.tabManager.selectedWorkspace, + let panelId = workspace.focusedPanelId else { + return nil + } + return workspace.browserPanel(for: panelId) + } + private func shouldLetFocusedBrowserOwnFindShortcut(_ event: NSEvent) -> Bool { let shortcutWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow let shortcutResponder = shortcutWindow?.firstResponder - let owningWebView = tabManager?.focusedBrowserPanel?.webView as? CmuxWebView - guard let owningWebView else { return false } - return shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst( + guard let focusedBrowserPanel = focusedBrowserPanelForShortcutEvent(event), + let owningWebView = focusedBrowserPanel.webView as? CmuxWebView else { + return false + } + let shouldRoute = shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst( event, responder: shortcutResponder, owningWebView: owningWebView ) + return shouldRoute } private func browserPanelOwning(_ webView: CmuxWebView) -> BrowserPanel? { @@ -12477,12 +12121,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent fileExplorerState = context.fileExplorerState TerminalController.shared.setActiveTabManager(context.tabManager) #if DEBUG - cmuxDebugLog( + dlog( "mainWindow.active window={\(debugWindowToken(window))} context={\(debugContextToken(context))} beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) \(debugShortcutRouteSnapshot())" ) #endif } + private func reassertFocusedBrowserAfterWindowBecameKey(_ window: NSWindow) { + guard let context = contextForMainTerminalWindow(window), + let workspace = context.tabManager.selectedWorkspace, + let panelId = workspace.focusedPanelId, + let browserPanel = workspace.browserPanel(for: panelId) else { return } + _ = browserPanel.noteOwningWindowDidBecomeKey( + window, + reason: "window.didBecomeKey" + ) + } + + private func suspendFocusedBrowserAfterWindowResignedKey(_ window: NSWindow) { + guard let context = contextForMainTerminalWindow(window), + let workspace = context.tabManager.selectedWorkspace, + let panelId = workspace.focusedPanelId, + let browserPanel = workspace.browserPanel(for: panelId) else { return } + browserPanel.noteOwningWindowDidResignKey( + window, + reason: "window.didResignKey" + ) + } + private func unregisterMainWindow(_ window: NSWindow) { // Reset cascade point so the next new window appears near the closing // window's position, matching upstream Ghostty behavior. @@ -12905,6 +12571,7 @@ private var cmuxFirstResponderGuardHitViewContext: NSView? private var cmuxFirstResponderGuardContextWindowNumber: Int? private var cmuxBrowserReturnForwardingDepth = 0 private var cmuxBrowserArrowForwardingDepth = 0 +private var cmuxBrowserSpaceForwardingDepth = 0 private var cmuxWindowFirstResponderBypassDepth = 0 private var cmuxFieldEditorOwningWebViewAssociationKey: UInt8 = 0 @@ -12930,28 +12597,16 @@ private final class CmuxFieldEditorOwningWebViewBox: NSObject { } private extension NSApplication { - @objc func cmux_accessibilityAttributeValue(_ attribute: NSAccessibility.Attribute) -> Any? { - if Thread.isMainThread { - switch CmuxApplicationAccessibilityHierarchyCache.shared.resolve( - attribute: attribute, - application: self - ) { - case .handled(let value): - return value - case .passthrough: - break - } - } - - return cmux_accessibilityAttributeValue(attribute) - } - @objc func cmux_applicationSendEvent(_ event: NSEvent) { #if DEBUG let typingTimingStart = event.type == .keyDown ? CmuxTypingTiming.start() : nil let phaseTotalStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 + let isSpaceKeyDown = event.type == .keyDown && event.keyCode == 49 if event.type == .keyDown { CmuxTypingTiming.logEventDelay(path: "app.sendEvent", event: event) + if isSpaceKeyDown { + cmuxLogBrowserSpaceAppProbe(stage: "app.sendEvent.enter", event: event) + } } defer { if event.type == .keyDown { @@ -12971,7 +12626,86 @@ private extension NSApplication { } } #endif + if AppDelegate.shared?.handlePaneFocusNavigationCommandEvent( + event, + preferredWindow: event.window ?? keyWindow ?? mainWindow, + source: "appSendEvent" + ) == true { +#if DEBUG + if isSpaceKeyDown { + cmuxLogBrowserSpaceAppProbe(stage: "app.sendEvent.routePaneFocus", event: event, result: true) + } +#endif + return + } + if AppDelegate.shared?.handleBrowserSearchOverlayCommandEvent( + event, + preferredWindow: event.window ?? keyWindow ?? mainWindow + ) == true { +#if DEBUG + if isSpaceKeyDown { + cmuxLogBrowserSpaceAppProbe(stage: "app.sendEvent.routeSearchOverlay", event: event, result: true) + } +#endif + return + } cmux_applicationSendEvent(event) +#if DEBUG + if isSpaceKeyDown { + cmuxLogBrowserSpaceAppProbe(stage: "app.sendEvent.exit", event: event) + } +#endif + } + +#if DEBUG + private func cmuxLogBrowserSpaceAppProbe( + stage: String, + event: NSEvent, + result: Bool? = nil + ) { + let targetWindow = event.window ?? keyWindow ?? mainWindow + let firstResponder = targetWindow?.firstResponder + let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let firstResponderWebView: CmuxWebView? = { + if let webView = firstResponder as? CmuxWebView { + return webView + } + guard let view = firstResponder as? NSView else { return nil } + var current: NSView? = view + while let candidate = current { + if let webView = candidate as? CmuxWebView { + return webView + } + current = candidate.superview + } + return nil + }() + let resultSuffix = result.map { " result=\($0 ? 1 : 0)" } ?? "" + let charsHex = event.characters?.unicodeScalars + .map { String(format: "%04X", $0.value) } + .joined(separator: ",") ?? "nil" + dlog( + "browser.space.app stage=\(stage) keyWin=\(keyWindow?.windowNumber ?? -1) " + + "mainWin=\(mainWindow?.windowNumber ?? -1) eventWin=\(event.window?.windowNumber ?? -1) " + + "targetWin=\(targetWindow?.windowNumber ?? -1) fr=\(firstResponderType) " + + "frWeb=\(firstResponderWebView.map { String(describing: ObjectIdentifier($0)) } ?? "nil") " + + "charsHex=\(charsHex) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)" + + resultSuffix + ) + } +#endif + + @objc func cmux_sendAction(_ action: Selector, to target: Any?, from sender: Any?) -> Bool { + if let event = currentEvent, + event.type == .keyDown, + AppDelegate.shared?.handlePaneFocusNavigationCommandEvent( + event, + preferredWindow: event.window ?? keyWindow ?? mainWindow, + source: "appSendAction.\(NSStringFromSelector(action))" + ) == true { + return true + } + return cmux_sendAction(action, to: target, from: sender) } } @@ -12987,7 +12721,7 @@ private extension NSWindow { @objc func cmux_makeFirstResponder(_ responder: NSResponder?) -> Bool { if cmuxIsWindowFirstResponderBypassActive() { #if DEBUG - cmuxDebugLog( + dlog( "focus.guard bypassFirstResponder responder=\(String(describing: responder.map { type(of: $0) })) " + "window=\(ObjectIdentifier(self))" ) @@ -13007,7 +12741,7 @@ private extension NSWindow { responder: responder ) == true { #if DEBUG - cmuxDebugLog( + dlog( "focus.guard commandPaletteBlocked responder=\(String(describing: responder.map { type(of: $0) })) " + "window=\(ObjectIdentifier(self))" ) @@ -13028,7 +12762,7 @@ private extension NSWindow { in: self ) #if DEBUG - cmuxDebugLog( + dlog( "focus.guard allowPointerTerminalFirstResponder " + "window=\(ObjectIdentifier(self)) " + "workspace=\(request.workspaceId.uuidString.prefix(5)) " + @@ -13069,24 +12803,28 @@ private extension NSWindow { if pointerInitiatedFocus { pointerInitiatedWebFocus = true #if DEBUG - cmuxDebugLog( - "focus.guard allowPointerFirstResponder responder=\(String(describing: type(of: responder))) " + + let responderDescription = String(describing: type(of: responder)) + let eventTypeDescription = currentEvent.map { String(describing: $0.type) } ?? "nil" + dlog( + "focus.guard allowPointerFirstResponder responder=\(responderDescription) " + "window=\(ObjectIdentifier(self)) " + "web=\(ObjectIdentifier(webView)) " + "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + "pointerDepth=\(webView.debugPointerFocusAllowanceDepth) " + - "eventType=\(currentEvent.map { String(describing: $0.type) } ?? "nil")" + "eventType=\(eventTypeDescription)" ) #endif } else { #if DEBUG - cmuxDebugLog( - "focus.guard blockedFirstResponder responder=\(String(describing: type(of: responder))) " + + let responderDescription = String(describing: type(of: responder)) + let eventTypeDescription = currentEvent.map { String(describing: $0.type) } ?? "nil" + dlog( + "focus.guard blockedFirstResponder responder=\(responderDescription) " + "window=\(ObjectIdentifier(self)) " + "web=\(ObjectIdentifier(webView)) " + "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + "pointerDepth=\(webView.debugPointerFocusAllowanceDepth) " + - "eventType=\(currentEvent.map { String(describing: $0.type) } ?? "nil")" + "eventType=\(eventTypeDescription)" ) #endif return false @@ -13095,7 +12833,7 @@ private extension NSWindow { #if DEBUG if let responder, let webView = responderWebView { - cmuxDebugLog( + dlog( "focus.guard allowFirstResponder responder=\(String(describing: type(of: responder))) " + "window=\(ObjectIdentifier(self)) " + "web=\(ObjectIdentifier(webView)) " + @@ -13131,6 +12869,7 @@ private extension NSWindow { #if DEBUG let typingTimingStart = event.type == .keyDown ? CmuxTypingTiming.start() : nil let phaseTotalStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 + let isSpaceKeyDown = event.type == .keyDown && event.keyCode == 49 var contextSetupMs: Double = 0 var focusRepairMs: Double = 0 var folderGuardMs: Double = 0 @@ -13191,10 +12930,17 @@ private extension NSWindow { #if DEBUG if event.type == .keyDown { contextSetupMs = (ProcessInfo.processInfo.systemUptime - contextSetupStart) * 1000.0 + if isSpaceKeyDown { + cmuxLogBrowserSpaceProbe(stage: "window.sendEvent.afterContext", event: event) + } } let focusRepairStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 #endif if event.type == .keyDown { + AppDelegate.shared?.repairFocusedBrowserKeyboardRoutingIfNeeded( + window: self, + event: event + ) AppDelegate.shared?.repairFocusedTerminalKeyboardRoutingIfNeeded( window: self, event: event @@ -13203,6 +12949,9 @@ private extension NSWindow { #if DEBUG if event.type == .keyDown { focusRepairMs = (ProcessInfo.processInfo.systemUptime - focusRepairStart) * 1000.0 + if isSpaceKeyDown { + cmuxLogBrowserSpaceProbe(stage: "window.sendEvent.afterFocusRepair", event: event) + } } let folderGuardStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 #endif @@ -13212,14 +12961,43 @@ private extension NSWindow { cmuxFirstResponderGuardContextWindowNumber = previousContextWindowNumber } + if cmux_routeBrowserSearchOverlayCommandKeyDown(event) { +#if DEBUG + if isSpaceKeyDown { + cmuxLogBrowserSpaceProbe(stage: "window.sendEvent.routeSearchOverlay", event: event, result: true) + } +#endif + return + } + + if event.type == .keyDown, + AppDelegate.shared?.handlePaneFocusNavigationCommandEvent( + event, + preferredWindow: self, + source: "windowSendEvent" + ) == true { +#if DEBUG + if isSpaceKeyDown { + cmuxLogBrowserSpaceProbe(stage: "window.sendEvent.routePaneFocus", event: event, result: true) + } +#endif + return + } + guard shouldSuppressWindowMoveForFolderDrag(window: self, event: event), let contentView = self.contentView else { #if DEBUG if event.type == .keyDown { folderGuardMs = (ProcessInfo.processInfo.systemUptime - folderGuardStart) * 1000.0 let originalDispatchStart = ProcessInfo.processInfo.systemUptime + if isSpaceKeyDown { + cmuxLogBrowserSpaceProbe(stage: "window.sendEvent.beforeOriginalDispatch", event: event) + } cmux_sendEvent(event) originalDispatchMs = (ProcessInfo.processInfo.systemUptime - originalDispatchStart) * 1000.0 + if isSpaceKeyDown { + cmuxLogBrowserSpaceProbe(stage: "window.sendEvent.afterOriginalDispatch", event: event) + } return } #endif @@ -13242,13 +13020,16 @@ private extension NSWindow { #if DEBUG let hitDesc = hitView.map { String(describing: type(of: $0)) } ?? "nil" - cmuxDebugLog("window.sendEvent.folderDown suppress=1 hit=\(hitDesc) wasMovable=\(previousMovableState)") + dlog("window.sendEvent.folderDown suppress=1 hit=\(hitDesc) wasMovable=\(previousMovableState)") #endif cmux_sendEvent(event) #if DEBUG if event.type == .keyDown { originalDispatchMs = (ProcessInfo.processInfo.systemUptime - originalDispatchStart) * 1000.0 + if isSpaceKeyDown { + cmuxLogBrowserSpaceProbe(stage: "window.sendEvent.afterOriginalDispatch", event: event) + } } #endif @@ -13257,10 +13038,61 @@ private extension NSWindow { } #if DEBUG - cmuxDebugLog("window.sendEvent.folderDown restore nowMovable=\(isMovable)") + dlog("window.sendEvent.folderDown restore nowMovable=\(isMovable)") #endif } + private func cmux_routeBrowserSearchOverlayCommandKeyDown(_ event: NSEvent) -> Bool { + guard AppDelegate.shared?.handleBrowserSearchOverlayCommandEvent( + event, + preferredWindow: self + ) == true else { return false } +#if DEBUG + dlog( + "window.sendEvent.browserSearchOverlay.command " + + "keyCode=\(event.keyCode)" + ) +#endif + return true + } + +#if DEBUG + private func cmuxLogBrowserSpaceProbe( + stage: String, + event: NSEvent, + explicitWebView: CmuxWebView? = nil, + result: Bool? = nil, + route: String? = nil + ) { + let firstResponder = firstResponder + let responderWebView = explicitWebView ?? firstResponder.flatMap { + Self.cmuxOwningWebView(for: $0, in: self, event: event) + } + let hitView = Self.cmuxHitViewForEventDispatch(in: self, event: event) + let hitWebView = hitView.flatMap { Self.cmuxOwningWebView(for: $0) } + let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let hitViewType = hitView.map { String(describing: type(of: $0)) } ?? "nil" + let responderWebID = responderWebView.map { String(describing: ObjectIdentifier($0)) } ?? "nil" + let hitWebID = hitWebView.map { String(describing: ObjectIdentifier($0)) } ?? "nil" + let routeSuffix = route.map { " route=\($0)" } ?? "" + let resultSuffix = result.map { " result=\($0 ? 1 : 0)" } ?? "" + dlog( + "browser.space.window stage=\(stage) window=\(ObjectIdentifier(self)) " + + "win=\(windowNumber) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) " + + "repeat=\(event.isARepeat ? 1 : 0) chars=\(event.characters ?? "nil") " + + "fr=\(firstResponderType) responderWeb=\(responderWebID) " + + "hit=\(hitViewType) hitWeb=\(hitWebID)\(routeSuffix)\(resultSuffix)" + ) + + let webView = explicitWebView ?? responderWebView ?? hitWebView + webView?.debugLogSpaceFocusProbe( + stage: stage, + event: event, + extra: "windowProbe=1\(routeSuffix)\(resultSuffix)" + ) + } +#endif + @objc func cmux_performKeyEquivalent(with event: NSEvent) -> Bool { #if DEBUG let typingTimingStart = CmuxTypingTiming.start() @@ -13272,7 +13104,16 @@ private extension NSWindow { ) } let frType = self.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - cmuxDebugLog("performKeyEquiv: \(Self.keyDescription(event)) fr=\(frType)") + dlog("performKeyEquiv: \(Self.keyDescription(event)) fr=\(frType)") + if isDebugCmdD(event) { + latencyLog( + "cmd_d.window.performKeyEquivalent.begin", + data: [ + "firstResponder": frType, + "keyWindow": String(NSApp.keyWindow?.windowNumber ?? -1), + ] + ) + } #endif // When the terminal surface is the first responder, prevent SwiftUI's @@ -13295,6 +13136,38 @@ private extension NSWindow { Self.cmuxOwningWebView(for: $0, in: self, event: event) } let firstResponderHasMarkedText = browserResponderHasMarkedText(self.firstResponder) +#if DEBUG + if event.keyCode == 49 { + cmuxLogBrowserSpaceProbe( + stage: "window.performKeyEquivalent.enter", + event: event, + explicitWebView: firstResponderWebView + ) + } +#endif + if AppDelegate.shared?.handleBrowserSearchOverlayCommandEvent( + event, + preferredWindow: self + ) == true { +#if DEBUG + dlog( + " → browser search overlay command routed to app" + ) +#endif + return true + } + if AppDelegate.shared?.handlePaneFocusNavigationCommandEvent( + event, + preferredWindow: self, + source: "windowPerformKeyEquivalent" + ) == true { +#if DEBUG + dlog( + " → pane focus routed before performKeyEquivalent" + ) +#endif + return true + } if let ghosttyView = firstResponderGhosttyView { // If the IME is composing and the key has no Cmd modifier, don't intercept — // let it flow through normal AppKit event dispatch so the input method can @@ -13308,7 +13181,7 @@ private extension NSWindow { if !flags.contains(.command) { let result = ghosttyView.performKeyEquivalent(with: event) #if DEBUG - cmuxDebugLog(" → ghostty direct: \(result)") + dlog(" → ghostty direct: \(result)") #endif return result } @@ -13325,7 +13198,7 @@ private extension NSWindow { ) { ghosttyView.keyDown(with: event) #if DEBUG - cmuxDebugLog("zoom.shortcut stage=window.ghosttyKeyDownDirect event=\(Self.keyDescription(event)) handled=1") + dlog("zoom.shortcut stage=window.ghosttyKeyDownDirect event=\(Self.keyDescription(event)) handled=1") #endif return true } @@ -13345,14 +13218,14 @@ private extension NSWindow { // On re-entry, fall back to normal dispatch to avoid an infinite loop. if cmuxBrowserReturnForwardingDepth > 0 { #if DEBUG - cmuxDebugLog(" → browser Return/Enter reentry; using normal dispatch") + dlog(" → browser Return/Enter reentry; using normal dispatch") #endif return false } cmuxBrowserReturnForwardingDepth += 1 defer { cmuxBrowserReturnForwardingDepth = max(0, cmuxBrowserReturnForwardingDepth - 1) } #if DEBUG - cmuxDebugLog(" → browser Return/Enter routed to firstResponder.keyDown") + dlog(" → browser Return/Enter routed to firstResponder.keyDown") #endif self.firstResponder?.keyDown(with: event) return true @@ -13371,19 +13244,80 @@ private extension NSWindow { // performKeyEquivalent while the synthesized keyDown is in flight. if cmuxBrowserArrowForwardingDepth > 0 { #if DEBUG - cmuxDebugLog(" → browser Up/Down reentry; using normal dispatch") + dlog(" → browser Up/Down reentry; using normal dispatch") #endif return false } cmuxBrowserArrowForwardingDepth += 1 defer { cmuxBrowserArrowForwardingDepth = max(0, cmuxBrowserArrowForwardingDepth - 1) } #if DEBUG - cmuxDebugLog(" → browser Up/Down routed to firstResponder.keyDown") + dlog(" → browser Up/Down routed to firstResponder.keyDown") +#endif + self.firstResponder?.keyDown(with: event) + return true + } + + // NSWindow.performKeyEquivalent can claim plain Space when a WKWebView wrapper + // is first responder, which prevents WebKit from inserting a space in focused + // page text inputs. Route just Space through keyDown; do not broaden this to all + // printable keys because WebKit/AppKit can then deliver duplicate text input. + if shouldDispatchBrowserSpaceViaFirstResponderKeyDown( + keyCode: event.keyCode, + firstResponderIsBrowser: firstResponderWebView != nil, + firstResponderHasMarkedText: firstResponderHasMarkedText, + flags: event.modifierFlags + ) { + if cmuxBrowserSpaceForwardingDepth > 0 { +#if DEBUG + dlog(" → browser Space reentry; using normal dispatch") + cmuxLogBrowserSpaceProbe( + stage: "window.performKeyEquivalent.spaceReentry", + event: event, + explicitWebView: firstResponderWebView, + result: false, + route: "normalDispatch" + ) +#endif + return false + } + cmuxBrowserSpaceForwardingDepth += 1 + defer { cmuxBrowserSpaceForwardingDepth = max(0, cmuxBrowserSpaceForwardingDepth - 1) } +#if DEBUG + dlog(" → browser Space routed to firstResponder.keyDown") + cmuxLogBrowserSpaceProbe( + stage: "window.performKeyEquivalent.spaceForward", + event: event, + explicitWebView: firstResponderWebView, + route: "firstResponder.keyDown" + ) #endif self.firstResponder?.keyDown(with: event) +#if DEBUG + cmuxLogBrowserSpaceProbe( + stage: "window.performKeyEquivalent.spaceForward.done", + event: event, + explicitWebView: firstResponderWebView, + result: true, + route: "firstResponder.keyDown" + ) +#endif return true } + if shouldBypassBrowserPlainTextKeyEquivalent( + keyCode: event.keyCode, + firstResponderIsBrowser: firstResponderWebView != nil, + firstResponderHasMarkedText: firstResponderHasMarkedText, + flags: event.modifierFlags, + characters: event.characters, + charactersIgnoringModifiers: event.charactersIgnoringModifiers + ) { +#if DEBUG + dlog(" → browser plain text bypassed performKeyEquivalent") +#endif + return false + } + if let firstResponderWebView, shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst( event, @@ -13393,9 +13327,9 @@ private extension NSWindow { let result = firstResponderWebView.performKeyEquivalent(with: event) #if DEBUG if result { - cmuxDebugLog(" → browser find command resolved before window menu path") + dlog(" → browser find command resolved before window menu path") } else { - cmuxDebugLog(" → browser find command preflight left unclaimed; suppressing replay") + dlog(" → browser find command preflight left unclaimed; suppressing replay") } #endif // The focused web view has already received this Find-family shortcut once. @@ -13407,7 +13341,7 @@ private extension NSWindow { if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true { #if DEBUG - cmuxDebugLog(" → consumed by handleBrowserSurfaceKeyEquivalent") + dlog(" → consumed by handleBrowserSurfaceKeyEquivalent") #endif return true } @@ -13426,25 +13360,17 @@ private extension NSWindow { keyCode: event.keyCode, literalChars: event.characters ) { - cmuxDebugLog( + dlog( "zoom.shortcut stage=window.mainMenuBypass event=\(Self.keyDescription(event)) " + "consumed=\(consumedByMenu ? 1 : 0) fr=GhosttyNSView" ) } #endif if !consumedByMenu { - // After a direct-to-menu miss, let Ghostty resolve the command key - // through its normal binding path so user key overrides still win. - let consumedByGhostty = firstResponderGhosttyView?.performKeyEquivalentAfterMenuMiss(with: event) == true -#if DEBUG - cmuxDebugLog(" → mainMenu miss; ghostty command path: \(consumedByGhostty)") -#endif - if consumedByGhostty { - return true - } + // Fall through to the original performKeyEquivalent path below. } else { #if DEBUG - cmuxDebugLog(" → consumed by mainMenu (bypassed SwiftUI)") + dlog(" → consumed by mainMenu (bypassed SwiftUI)") #endif return true } @@ -13452,7 +13378,16 @@ private extension NSWindow { let result = cmux_performKeyEquivalent(with: event) #if DEBUG - if result { cmuxDebugLog(" → consumed by original performKeyEquivalent") } + if result { dlog(" → consumed by original performKeyEquivalent") } + if event.keyCode == 49 { + cmuxLogBrowserSpaceProbe( + stage: "window.performKeyEquivalent.originalResult", + event: event, + explicitWebView: firstResponderWebView, + result: result, + route: "original" + ) + } #endif return result } @@ -13469,7 +13404,7 @@ private extension NSWindow { return parts.joined(separator: "+") } - private static func cmuxOwningWebView(for responder: NSResponder) -> CmuxWebView? { + static func cmuxOwningWebView(for responder: NSResponder) -> CmuxWebView? { if let webView = responder as? CmuxWebView { return webView } @@ -13496,7 +13431,7 @@ private extension NSWindow { return nil } - private static func cmuxOwningWebView( + static func cmuxOwningWebView( for responder: NSResponder, in window: NSWindow, event: NSEvent? @@ -13525,7 +13460,7 @@ private extension NSWindow { return cmuxTrackedOwningWebView(for: textView) } - private static func cmuxOwningWebView(for view: NSView) -> CmuxWebView? { + static func cmuxOwningWebView(for view: NSView) -> CmuxWebView? { if let webView = view as? CmuxWebView { return webView } diff --git a/Sources/AppleScriptSupport.swift b/Sources/AppleScriptSupport.swift index b90db00aa416..9aa67c0cd54e 100644 --- a/Sources/AppleScriptSupport.swift +++ b/Sources/AppleScriptSupport.swift @@ -539,9 +539,9 @@ final class ScriptTerminal: NSObject { var workingDirectory: String { guard NSApp.isAppleScriptEnabled else { return "" } // TerminalPanel.directory is never updated (updateDirectory is never called). - // Read from Workspace.panelDirectories instead, which is kept up to date + // Read from Workspace's canonical surface state instead, which is kept up to date // via updatePanelDirectory() from OSC 7 / shell integration. - return workspace?.panelDirectories[terminalId] ?? terminal?.directory ?? "" + return workspace?.panelDirectory(panelId: terminalId) ?? terminal?.directory ?? "" } func input(text: String) -> Bool { diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 1ba860d7111e..0cb47aeb7234 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -1,5 +1,4 @@ import AppKit -import Bonsplit import ObjectiveC import SwiftUI import WebKit @@ -101,7 +100,7 @@ private extension WKWebView { } #if DEBUG if !firedSelectors.isEmpty { - cmuxDebugLog( + dlog( "browser.portal.webview.hidden web=\(browserPortalDebugToken(self)) " + "reason=\(reason) selectors=\(firedSelectors.joined(separator: ","))" ) @@ -136,7 +135,7 @@ private extension WKWebView { #if DEBUG if !firedSelectors.isEmpty { - cmuxDebugLog( + dlog( "browser.portal.webview.reattach web=\(browserPortalDebugToken(self)) " + "reason=\(reason) selectors=\(firedSelectors.joined(separator: ",")) " + "frame=\(browserPortalDebugFrame(frame))" @@ -364,7 +363,7 @@ final class WindowBrowserHostView: NSView { return "kind=\(kind),hosted=\(dividerHit.isInHostedContent ? 1 : 0)" }() let windowPoint = convert(point, to: nil) - cmuxDebugLog( + dlog( "browser.portal.pointer stage=\(stage) event=\(String(describing: event?.type)) " + "host=\(browserPortalDebugToken(self)) point=\(browserPortalDebugFrame(NSRect(origin: point, size: .zero))) " + "windowPoint=\(browserPortalDebugFrame(NSRect(origin: windowPoint, size: .zero))) " + @@ -589,7 +588,7 @@ final class WindowBrowserHostView: NSView { initialInspectorFrame: hostedInspectorHit.inspectorView.frame ) #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.manualInspectorDrag stage=start slot=\(browserPortalDebugToken(hostedInspectorHit.slotView)) " + "page=\(browserPortalDebugToken(hostedInspectorHit.pageView)) " + "inspector=\(browserPortalDebugToken(hostedInspectorHit.inspectorView)) " + @@ -657,7 +656,7 @@ final class WindowBrowserHostView: NSView { ) ) #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.manualInspectorDrag stage=update slot=\(browserPortalDebugToken(dragState.slotView)) " + "dividerX=\(String(format: "%.1f", clampedDividerX)) " + "pageFrame=\(browserPortalDebugFrame(appliedFrames.pageFrame)) " + @@ -670,7 +669,7 @@ final class WindowBrowserHostView: NSView { if let dragState = hostedInspectorDividerDrag { dragState.slotView.isHostedInspectorDividerDragActive = false #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.manualInspectorDrag stage=end slot=\(browserPortalDebugToken(dragState.slotView)) " + "pageFrame=\(browserPortalDebugFrame(dragState.pageView.frame)) " + "inspectorFrame=\(browserPortalDebugFrame(dragState.inspectorView.frame))" @@ -726,10 +725,6 @@ final class WindowBrowserHostView: NSView { let visibleSlots = subviews.compactMap { $0 as? WindowBrowserSlotView } .filter { !$0.isHidden && $0.window != nil && $0.frame.width > 1 && $0.frame.height > 1 } - if shouldPassThroughToTrailingSidebarResizer(at: point, visibleSlots: visibleSlots) { - return true - } - // If content is flush to the leading edge, sidebar is effectively hidden. // In that state, treating any internal split edge as a sidebar divider // steals split-divider cursor/drag behavior. @@ -770,17 +765,9 @@ final class WindowBrowserHostView: NSView { return false } - return SidebarResizeInteraction.Edge.leading.hitRange(dividerX: dividerX).contains(point.x) - } - - private func shouldPassThroughToTrailingSidebarResizer( - at point: NSPoint, - visibleSlots: [WindowBrowserSlotView] - ) -> Bool { - guard let rightMostEdge = visibleSlots.map(\.frame.maxX).max() else { return false } - let trailingGap = bounds.maxX - rightMostEdge - guard trailingGap > Self.minimumVisibleLeadingContentWidth else { return false } - return SidebarResizeInteraction.Edge.trailing.hitRange(dividerX: rightMostEdge).contains(point.x) + let regionMinX = dividerX - SidebarResizeInteraction.sidebarSideHitWidth + let regionMaxX = dividerX + SidebarResizeInteraction.contentSideHitWidth + return point.x >= regionMinX && point.x <= regionMaxX } private func updateDividerCursor( @@ -874,9 +861,9 @@ final class WindowBrowserHostView: NSView { case .cursorUpdate, .mouseEntered, .mouseExited, .mouseMoved: // Browser-side tab drags can surface as hover events with a mixed // pasteboard payload (tabtransfer plus promised-file UTIs). Prefer - // the explicit Bonsplit drag types so WKWebView cannot steal the + // the explicit WorkspaceSplit drag types so WKWebView cannot steal the // session as a file upload. - return DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes) + return DragOverlayRoutingPolicy.hasSplitTabTransfer(pasteboardTypes) || DragOverlayRoutingPolicy.hasSidebarTabReorder(pasteboardTypes) default: return false @@ -1018,7 +1005,7 @@ final class WindowBrowserHostView: NSView { fileprivate func reapplyHostedInspectorDividerIfNeeded(in slot: WindowBrowserSlotView, reason: String) -> Bool { guard !slot.isHostedInspectorDividerDragActive else { #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.manualInspectorDrag stage=skipReapply slot=\(browserPortalDebugToken(slot)) " + "reason=\(reason)" ) @@ -1083,7 +1070,7 @@ final class WindowBrowserHostView: NSView { hit.slotView.needsDisplay = true hit.slotView.setNeedsDisplay(hit.slotView.bounds) #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.manualInspectorDrag stage=reapply slot=\(browserPortalDebugToken(hit.slotView)) " + "container=\(browserPortalDebugToken(hit.containerView)) reason=\(reason) " + "preferredWidth=\(String(format: "%.1f", preferredWidth)) " + @@ -1247,12 +1234,15 @@ private final class BrowserDropZoneOverlayView: NSView { struct BrowserPortalSearchOverlayConfiguration { let panelId: UUID let searchState: BrowserSearchState - let focusRequestGeneration: UInt64 - let canApplyFocusRequest: (UInt64) -> Bool + let focusRequestId: UUID? + let canApplyFocusRequest: (UUID) -> Bool let onNext: () -> Void let onPrevious: () -> Void let onClose: () -> Void - let onFieldDidFocus: () -> Void + let onFieldMounted: (UUID?) -> Void + let onFieldDidFocus: (UUID?) -> Void + let onFieldDidEndEditing: (UUID?) -> Void + let onDisappear: () -> Void } struct BrowserPaneDropContext: Equatable { @@ -1271,10 +1261,10 @@ struct BrowserPaneDragTransfer: Equatable { } static func decode(from pasteboard: NSPasteboard) -> BrowserPaneDragTransfer? { - if let data = pasteboard.data(forType: DragOverlayRoutingPolicy.bonsplitTabTransferType) { + if let data = pasteboard.data(forType: DragOverlayRoutingPolicy.splitTabTransferType) { return decode(from: data) } - if let raw = pasteboard.string(forType: DragOverlayRoutingPolicy.bonsplitTabTransferType) { + if let raw = pasteboard.string(forType: DragOverlayRoutingPolicy.splitTabTransferType) { return decode(from: Data(raw.utf8)) } return nil @@ -1425,7 +1415,7 @@ final class BrowserPaneDropTargetView: NSView { override init(frame frameRect: NSRect) { super.init(frame: frameRect) - registerForDraggedTypes([DragOverlayRoutingPolicy.bonsplitTabTransferType]) + registerForDraggedTypes([DragOverlayRoutingPolicy.splitTabTransferType]) } @available(*, unavailable) @@ -1437,7 +1427,7 @@ final class BrowserPaneDropTargetView: NSView { pasteboardTypes: [NSPasteboard.PasteboardType]?, eventType: NSEvent.EventType? ) -> Bool { - guard DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes) else { return false } + guard DragOverlayRoutingPolicy.hasSplitTabTransfer(pasteboardTypes) else { return false } guard let eventType else { return false } switch eventType { @@ -1494,7 +1484,7 @@ final class BrowserPaneDropTargetView: NSView { let transfer = BrowserPaneDragTransfer.decode(from: sender.draggingPasteboard), transfer.isFromCurrentProcess else { #if DEBUG - cmuxDebugLog("browser.paneDrop.perform allowed=0 reason=missingTransfer") + dlog("browser.paneDrop.perform allowed=0 reason=missingTransfer") #endif return false } @@ -1511,7 +1501,7 @@ final class BrowserPaneDropTargetView: NSView { zone: zone ) else { #if DEBUG - cmuxDebugLog( + dlog( "browser.paneDrop.perform allowed=0 panel=\(dropContext.panelId.uuidString.prefix(5)) " + "reason=noAction zone=\(zone)" ) @@ -1522,14 +1512,14 @@ final class BrowserPaneDropTargetView: NSView { switch action { case .noOp: #if DEBUG - cmuxDebugLog( + dlog( "browser.paneDrop.perform allowed=1 panel=\(dropContext.panelId.uuidString.prefix(5)) " + "tab=\(transfer.tabId.uuidString.prefix(5)) action=noop" ) #endif return true case .move(let tabId, let workspaceId, let targetPane, let splitTarget): - let moved = AppDelegate.shared?.moveBonsplitTab( + let moved = AppDelegate.shared?.moveSplitTab( tabId: tabId, toWorkspace: workspaceId, targetPane: targetPane, @@ -1541,7 +1531,7 @@ final class BrowserPaneDropTargetView: NSView { let splitLabel = splitTarget.map { "\($0.orientation.rawValue):\($0.insertFirst ? 1 : 0)" } ?? "none" - cmuxDebugLog( + dlog( "browser.paneDrop.perform panel=\(dropContext.panelId.uuidString.prefix(5)) " + "tab=\(tabId.uuidString.prefix(5)) zone=\(zone) pane=\(targetPane.id.uuidString.prefix(5)) " + "split=\(splitLabel) moved=\(moved ? 1 : 0)" @@ -1568,7 +1558,7 @@ final class BrowserPaneDropTargetView: NSView { activeZone = zone slotView?.setPortalDragDropZone(zone) #if DEBUG - cmuxDebugLog( + dlog( "browser.paneDrop.\(phase) panel=\(dropContext.panelId.uuidString.prefix(5)) " + "tab=\(transfer.tabId.uuidString.prefix(5)) zone=\(zone)" ) @@ -1582,7 +1572,7 @@ final class BrowserPaneDropTargetView: NSView { slotView?.setPortalDragDropZone(nil) #if DEBUG if let dropContext { - cmuxDebugLog( + dlog( "browser.paneDrop.\(phase) panel=\(dropContext.panelId.uuidString.prefix(5)) zone=none" ) } @@ -1595,7 +1585,7 @@ final class BrowserPaneDropTargetView: NSView { pasteboardTypes: [NSPasteboard.PasteboardType]?, eventType: NSEvent.EventType? ) { - let hasTransferType = DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes) + let hasTransferType = DragOverlayRoutingPolicy.hasSplitTabTransfer(pasteboardTypes) guard hasTransferType || capture else { return } let signature = [ @@ -1608,7 +1598,7 @@ final class BrowserPaneDropTargetView: NSView { lastHitTestSignature = signature let types = pasteboardTypes?.map(\.rawValue).joined(separator: ",") ?? "-" - cmuxDebugLog( + dlog( "browser.paneDrop.hitTest capture=\(capture ? 1 : 0) " + "hasTransfer=\(hasTransferType ? 1 : 0) context=\(dropContext != nil ? 1 : 0) " + "event=\(eventType.map { String($0.rawValue) } ?? "nil") types=\(types)" @@ -1744,7 +1734,7 @@ final class WindowBrowserSlotView: NSView { } return String(describing: type(of: firstResponder)) }() - cmuxDebugLog( + dlog( "browser.findbar.portal action=\(action) " + "panel=\(panelId?.uuidString.prefix(5) ?? "nil") " + "window=\(window?.windowNumber ?? -1) " + @@ -1774,12 +1764,15 @@ final class WindowBrowserSlotView: NSView { let rootView = BrowserSearchOverlay( panelId: configuration.panelId, searchState: configuration.searchState, - focusRequestGeneration: configuration.focusRequestGeneration, + focusRequestId: configuration.focusRequestId, canApplyFocusRequest: configuration.canApplyFocusRequest, onNext: configuration.onNext, onPrevious: configuration.onPrevious, onClose: configuration.onClose, - onFieldDidFocus: configuration.onFieldDidFocus + onFieldMounted: configuration.onFieldMounted, + onFieldDidFocus: configuration.onFieldDidFocus, + onFieldDidEndEditing: configuration.onFieldDidEndEditing, + onDisappear: configuration.onDisappear ) if let overlay = searchOverlayHostingView { @@ -1874,7 +1867,7 @@ final class WindowBrowserSlotView: NSView { return false } #if DEBUG - cmuxDebugLog( + dlog( "browser.slot.firstResponder.yield reason=\(reason) " + "slot=\(browserPortalDebugToken(self)) " + "responder=\(String(describing: type(of: firstResponder)))" @@ -2017,10 +2010,9 @@ final class WindowBrowserSlotView: NSView { return } - dropZoneOverlayAnimationGeneration &+= 1 - dropZoneOverlayView.layer?.removeAllAnimations() - if dropZoneOverlayView.isHidden { + dropZoneOverlayAnimationGeneration &+= 1 + dropZoneOverlayView.layer?.removeAllAnimations() applyDropZoneOverlayFrame(targetFrame) dropZoneOverlayView.alphaValue = 0 dropZoneOverlayView.isHidden = false @@ -2034,16 +2026,22 @@ final class WindowBrowserSlotView: NSView { } bringInteractionLayersToFrontIfNeeded() - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.18 - context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) - if needsFrameUpdate { - dropZoneOverlayView.animator().frame = targetFrame - } - if dropZoneOverlayView.alphaValue < 1 { - dropZoneOverlayView.animator().alphaValue = 1 + if needsFrameUpdate { + if zoneChanged { + // Animate discrete drop-zone transitions for smoother visual movement. + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.12 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + dropZoneOverlayView.animator().frame = targetFrame + } + } else { + // Keep same-zone geometry churn immediate so drag tracking stays tight. + applyDropZoneOverlayFrame(targetFrame) } } + if dropZoneOverlayView.alphaValue < 1 { + dropZoneOverlayView.animator().alphaValue = 1 + } } private func interactionLayerPriority(of view: NSView) -> Int { @@ -2142,7 +2140,6 @@ final class WindowBrowserPortal: NSObject { private struct PendingHostedWebViewRefresh { var generation: UInt64 = 0 var asyncWorkItem: DispatchWorkItem? - var delayedWorkItem: DispatchWorkItem? } private var entriesByWebViewId: [ObjectIdentifier: Entry] = [:] @@ -2384,7 +2381,7 @@ final class WindowBrowserPortal: NSObject { hostView.frame = frameInContainer CATransaction.commit() #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.hostFrame.update host=\(browserPortalDebugToken(hostView)) " + "frame=\(browserPortalDebugFrame(frameInContainer))" ) @@ -2451,7 +2448,7 @@ final class WindowBrowserPortal: NSObject { case let (lhs?, rhs?): return lhs.panelId == rhs.panelId && lhs.searchState === rhs.searchState && - lhs.focusRequestGeneration == rhs.focusRequestGeneration + lhs.focusRequestId == rhs.focusRequestId default: return false } @@ -2692,7 +2689,7 @@ final class WindowBrowserPortal: NSObject { created.setSearchOverlay(entry.searchOverlay) created.setPaneTopChromeHeight(entry.paneTopChromeHeight) #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.container.create web=\(browserPortalDebugToken(webView)) " + "container=\(browserPortalDebugToken(created))" ) @@ -2710,7 +2707,7 @@ final class WindowBrowserPortal: NSObject { guard !containerView.isHidden else { return } guard !containerView.isHostedInspectorDividerDragActive else { #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.refresh.skip web=\(browserPortalDebugToken(webView)) " + "container=\(browserPortalDebugToken(containerView)) reason=\(reason) phase=\(phase) " + "drag=1 reattach=\(reattachRenderingState ? 1 : 0)" @@ -2759,7 +2756,7 @@ final class WindowBrowserPortal: NSObject { containerView.displayIfNeeded() (containerView.window ?? webView.window ?? hostView.window)?.displayIfNeeded() #if DEBUG - cmuxDebugLog( + dlog( "\(reattachRenderingState ? "browser.portal.refresh" : "browser.portal.invalidate") " + "web=\(browserPortalDebugToken(webView)) " + "container=\(browserPortalDebugToken(containerView)) reason=\(reason) " + @@ -2774,10 +2771,8 @@ final class WindowBrowserPortal: NSObject { ) { guard var pending = pendingHostedWebViewRefreshes[webViewId] else { return } pending.asyncWorkItem?.cancel() - pending.delayedWorkItem?.cancel() if keepGeneration { pending.asyncWorkItem = nil - pending.delayedWorkItem = nil pendingHostedWebViewRefreshes[webViewId] = pending } else { pendingHostedWebViewRefreshes.removeValue(forKey: webViewId) @@ -2807,8 +2802,8 @@ final class WindowBrowserPortal: NSObject { let webViewId = ObjectIdentifier(webView) // Bind/reveal/fullscreen refreshes can stack up during a single layout churn. - // Keep only the latest follow-up passes so reattach work does not pile up on - // the main thread while browser panes are moving between hosts. + // Keep only the latest next-runloop follow-up so reattach work does not pile + // up on the main thread while browser panes are moving between hosts. cancelPendingHostedWebViewRefreshes(for: webViewId, keepGeneration: true) var pending = pendingHostedWebViewRefreshes[webViewId] ?? PendingHostedWebViewRefresh() nextHostedWebViewRefreshGeneration &+= 1 @@ -2825,42 +2820,26 @@ final class WindowBrowserPortal: NSObject { let asyncWorkItem = DispatchWorkItem { [weak self, weak webView, weak containerView] in guard let self, let webView, let containerView else { return } - guard self.pendingHostedWebViewRefreshes[webViewId]?.generation == generation else { return } - self.runHostedWebViewRefreshPass( - webView, - in: containerView, - reason: reason, - phase: "async", - reattachRenderingState: true - ) - } - pending.asyncWorkItem = asyncWorkItem - - let delayedWorkItem = DispatchWorkItem { [weak self, weak webView, weak containerView] in - guard let self else { return } defer { if var current = self.pendingHostedWebViewRefreshes[webViewId], current.generation == generation { current.asyncWorkItem = nil - current.delayedWorkItem = nil self.pendingHostedWebViewRefreshes[webViewId] = current } } - guard let webView, let containerView else { return } guard self.pendingHostedWebViewRefreshes[webViewId]?.generation == generation else { return } self.runHostedWebViewRefreshPass( webView, in: containerView, reason: reason, - phase: "delayed", + phase: "async", reattachRenderingState: true ) } - pending.delayedWorkItem = delayedWorkItem + pending.asyncWorkItem = asyncWorkItem pendingHostedWebViewRefreshes[webViewId] = pending DispatchQueue.main.async(execute: asyncWorkItem) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.03, execute: delayedWorkItem) } private enum HostedWebViewPresentationUpdateKind { @@ -2912,7 +2891,7 @@ final class WindowBrowserPortal: NSObject { ) guard !relatedSubviews.isEmpty else { return } #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.reparent.batch reason=\(reason) source=\(browserPortalDebugToken(sourceSuperview)) " + "container=\(browserPortalDebugToken(containerView)) count=\(relatedSubviews.count) " + "sourceType=\(String(describing: type(of: sourceSuperview))) targetType=\(String(describing: type(of: containerView))) " + @@ -2928,7 +2907,7 @@ final class WindowBrowserPortal: NSObject { let convertedFrame = containerView.convert(frameInWindow, from: nil) view.frame = convertedFrame #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.reparent.batch.item reason=\(reason) class=\(className) " + "view=\(browserPortalDebugToken(view)) frameInWindow=\(browserPortalDebugFrame(frameInWindow)) " + "converted=\(browserPortalDebugFrame(convertedFrame))" @@ -2946,12 +2925,12 @@ final class WindowBrowserPortal: NSObject { #if DEBUG let hadContainerSuperview = (entry.containerView?.superview === hostView) ? 1 : 0 let hadWebSuperview = entry.webView?.superview == nil ? 0 : 1 - cmuxDebugLog( + let line = "browser.portal.detach web=\(browserPortalDebugToken(entry.webView)) " + "container=\(browserPortalDebugToken(entry.containerView)) " + "anchor=\(browserPortalDebugToken(entry.anchorView)) " + "hadContainerSuperview=\(hadContainerSuperview) hadWebSuperview=\(hadWebSuperview)" - ) + dlog(line) #endif if let webView = entry.webView, let containerView = entry.containerView { notifyHostedWebKitHidden( @@ -2979,14 +2958,14 @@ final class WindowBrowserPortal: NSObject { let portalOwnsWebView = entry.webView?.superview === entry.containerView #if DEBUG - cmuxDebugLog( + let line = "browser.portal.discard web=\(browserPortalDebugToken(entry.webView)) " + "container=\(browserPortalDebugToken(entry.containerView)) " + "anchor=\(browserPortalDebugToken(entry.anchorView)) " + "source=\(source) preserve=\(preserveCurrentSuperview ? 1 : 0) " + "portalOwnsWeb=\(portalOwnsWebView ? 1 : 0) " + "currentSuper=\(browserPortalDebugToken(entry.webView?.superview))" - ) + dlog(line) #endif if !(preserveCurrentSuperview && !portalOwnsWebView) { @@ -3138,7 +3117,7 @@ final class WindowBrowserPortal: NSObject { let previousToken = entriesByWebViewId[previousWebViewId] .map { browserPortalDebugToken($0.webView) } ?? String(describing: previousWebViewId) - cmuxDebugLog( + dlog( "browser.portal.bind.replace anchor=\(browserPortalDebugToken(anchorView)) " + "oldWeb=\(previousToken) newWeb=\(browserPortalDebugToken(webView))" ) @@ -3180,19 +3159,19 @@ final class WindowBrowserPortal: NSObject { priorityIncreased || webView.superview !== containerView || containerView.superview !== hostView { - cmuxDebugLog( + let line = "browser.portal.bind web=\(browserPortalDebugToken(webView)) " + "container=\(browserPortalDebugToken(containerView)) " + "anchor=\(browserPortalDebugToken(anchorView)) prevAnchor=\(browserPortalDebugToken(previousEntry?.anchorView)) " + "visible=\(visibleInUI ? 1 : 0) prevVisible=\((previousEntry?.visibleInUI ?? false) ? 1 : 0) " + "z=\(zPriority) prevZ=\(previousEntry?.zPriority ?? Int.min)" - ) + dlog(line) } #endif if shouldPreserveExternalFullscreenHost { #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.reparent.skip web=\(browserPortalDebugToken(webView)) " + "reason=fullscreenExternalHost super=\(browserPortalDebugToken(webView.superview)) " + "container=\(browserPortalDebugToken(containerView)) " + @@ -3201,7 +3180,7 @@ final class WindowBrowserPortal: NSObject { #endif } else if webView.superview !== containerView { #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.reparent web=\(browserPortalDebugToken(webView)) " + "reason=attachContainer super=\(browserPortalDebugToken(webView.superview)) " + "container=\(browserPortalDebugToken(containerView))" @@ -3226,7 +3205,7 @@ final class WindowBrowserPortal: NSObject { if containerView.superview !== hostView { #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.reparent container=\(browserPortalDebugToken(containerView)) " + "reason=attach super=\(browserPortalDebugToken(containerView.superview))" ) @@ -3234,7 +3213,7 @@ final class WindowBrowserPortal: NSObject { hostView.addSubview(containerView, positioned: .above, relativeTo: nil) } else if (becameVisible || priorityIncreased), hostView.subviews.last !== containerView { #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.reparent container=\(browserPortalDebugToken(containerView)) reason=raise " + "didChangeAnchor=\(didChangeAnchor ? 1 : 0) becameVisible=\(becameVisible ? 1 : 0) " + "priorityIncreased=\(priorityIncreased ? 1 : 0)" @@ -3271,13 +3250,13 @@ final class WindowBrowserPortal: NSObject { guard !hasDeferredFullSyncScheduled else { return } hasDeferredFullSyncScheduled = true #if DEBUG - cmuxDebugLog("browser.portal.sync.defer.schedule entries=\(entriesByWebViewId.count)") + dlog("browser.portal.sync.defer.schedule entries=\(entriesByWebViewId.count)") #endif DispatchQueue.main.async { [weak self] in guard let self else { return } self.hasDeferredFullSyncScheduled = false #if DEBUG - cmuxDebugLog("browser.portal.sync.defer.tick entries=\(self.entriesByWebViewId.count)") + dlog("browser.portal.sync.defer.tick entries=\(self.entriesByWebViewId.count)") #endif self.synchronizeAllWebViews(excluding: nil, source: "deferredTick") } @@ -3312,7 +3291,7 @@ final class WindowBrowserPortal: NSObject { } #if DEBUG if entry.transientRecoveryRetriesRemaining <= 0 { - cmuxDebugLog( + dlog( "browser.portal.sync.deferRecover.skip web=\(browserPortalDebugToken(webView)) " + "reason=\(reason) exhausted=1" ) @@ -3323,7 +3302,7 @@ final class WindowBrowserPortal: NSObject { entry.transientRecoveryRetriesRemaining -= 1 entriesByWebViewId[webViewId] = entry #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.sync.deferRecover web=\(browserPortalDebugToken(webView)) " + "reason=\(reason) remaining=\(entry.transientRecoveryRetriesRemaining)" ) @@ -3392,7 +3371,7 @@ final class WindowBrowserPortal: NSObject { ) guard didScheduleTransientRecovery else { return false } #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.hidden.deferKeep web=\(browserPortalDebugToken(webView)) " + "reason=\(reason) frame=\(browserPortalDebugFrame(containerView.frame))" ) @@ -3415,7 +3394,7 @@ final class WindowBrowserPortal: NSObject { } #if DEBUG if !containerView.isHidden { - cmuxDebugLog( + dlog( "browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " + "web=\(browserPortalDebugToken(webView)) value=1 reason=missingAnchorOrWindow" ) @@ -3447,7 +3426,7 @@ final class WindowBrowserPortal: NSObject { } #if DEBUG if !containerView.isHidden { - cmuxDebugLog( + dlog( "browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " + "web=\(browserPortalDebugToken(webView)) value=1 " + "reason=anchorWindowMismatch anchorWindow=\(browserPortalDebugToken(anchorView.window?.contentView))" @@ -3464,7 +3443,7 @@ final class WindowBrowserPortal: NSObject { var refreshReasons: [String] = [] if containerView.superview !== hostView { #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.reparent container=\(browserPortalDebugToken(containerView)) " + "reason=syncAttach super=\(browserPortalDebugToken(containerView.superview))" ) @@ -3480,7 +3459,7 @@ final class WindowBrowserPortal: NSObject { webView.superview !== containerView if shouldPreserveExternalFullscreenHost { #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.reparent.skip web=\(browserPortalDebugToken(webView)) " + "reason=fullscreenExternalHost super=\(browserPortalDebugToken(webView.superview)) " + "container=\(browserPortalDebugToken(containerView)) " + @@ -3489,7 +3468,7 @@ final class WindowBrowserPortal: NSObject { #endif } else if shouldPreserveExternalHostForHiddenEntry { #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.reparent.skip web=\(browserPortalDebugToken(webView)) " + "reason=hiddenEntryExternalHost super=\(browserPortalDebugToken(webView.superview)) " + "container=\(browserPortalDebugToken(containerView))" @@ -3497,7 +3476,7 @@ final class WindowBrowserPortal: NSObject { #endif } else if webView.superview !== containerView { #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.reparent web=\(browserPortalDebugToken(webView)) " + "reason=syncAttachContainer super=\(browserPortalDebugToken(webView.superview)) " + "container=\(browserPortalDebugToken(containerView))" @@ -3532,7 +3511,7 @@ final class WindowBrowserPortal: NSObject { let hostBoundsReady = hasFiniteHostBounds && hostBounds.width > 1 && hostBounds.height > 1 if !hostBoundsReady { #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.sync.defer container=\(browserPortalDebugToken(containerView)) " + "web=\(browserPortalDebugToken(webView)) " + "reason=hostBoundsNotReady host=\(browserPortalDebugFrame(hostBounds)) " + @@ -3549,7 +3528,7 @@ final class WindowBrowserPortal: NSObject { ) if shouldPreserveVisibleOnTransient { #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.hidden.deferKeep web=\(browserPortalDebugToken(webView)) " + "reason=hostBoundsNotReady frame=\(browserPortalDebugFrame(containerView.frame))" ) @@ -3626,7 +3605,7 @@ final class WindowBrowserPortal: NSObject { #if DEBUG let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame) if frameWasClamped { - cmuxDebugLog( + dlog( "browser.portal.frame.clamp container=\(browserPortalDebugToken(containerView)) " + "web=\(browserPortalDebugToken(webView)) anchor=\(browserPortalDebugToken(anchorView)) " + "raw=\(browserPortalDebugFrame(frameInHost)) clamped=\(browserPortalDebugFrame(targetFrame)) " + @@ -3636,13 +3615,13 @@ final class WindowBrowserPortal: NSObject { let collapsedToTiny = oldFrame.width > 1 && oldFrame.height > 1 && tinyFrame let restoredFromTiny = (oldFrame.width <= 1 || oldFrame.height <= 1) && !tinyFrame if collapsedToTiny { - cmuxDebugLog( + dlog( "browser.portal.frame.collapse container=\(browserPortalDebugToken(containerView)) " + "web=\(browserPortalDebugToken(webView)) anchor=\(browserPortalDebugToken(anchorView)) " + "old=\(browserPortalDebugFrame(oldFrame)) new=\(browserPortalDebugFrame(targetFrame))" ) } else if restoredFromTiny { - cmuxDebugLog( + dlog( "browser.portal.frame.restore container=\(browserPortalDebugToken(containerView)) " + "web=\(browserPortalDebugToken(webView)) anchor=\(browserPortalDebugToken(anchorView)) " + "old=\(browserPortalDebugFrame(oldFrame)) new=\(browserPortalDebugFrame(targetFrame))" @@ -3656,7 +3635,7 @@ final class WindowBrowserPortal: NSObject { containerView.bounds.width > 1 && containerView.bounds.height > 1 #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.hidden.deferKeep web=\(browserPortalDebugToken(webView)) " + "reason=\(transientRecoveryReason ?? "unknown") frame=\(browserPortalDebugFrame(containerView.frame)) " + "keepFrame=\(hasExistingVisibleFrame ? 1 : 0)" @@ -3685,7 +3664,7 @@ final class WindowBrowserPortal: NSObject { containerView.bounds = expectedContainerBounds CATransaction.commit() #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.bounds.normalize container=\(browserPortalDebugToken(containerView)) " + "web=\(browserPortalDebugToken(webView)) old=\(browserPortalDebugFrame(oldContainerBounds)) " + "target=\(browserPortalDebugFrame(expectedContainerBounds))" @@ -3714,7 +3693,7 @@ final class WindowBrowserPortal: NSObject { webView.frame = repairedBottomDockFrame CATransaction.commit() #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.webframe.bottomDockRepair web=\(browserPortalDebugToken(webView)) " + "container=\(browserPortalDebugToken(containerView)) old=\(browserPortalDebugFrame(oldWebFrame)) " + "new=\(browserPortalDebugFrame(repairedBottomDockFrame)) bounds=\(browserPortalDebugFrame(containerBounds)) " + @@ -3733,7 +3712,7 @@ final class WindowBrowserPortal: NSObject { webView.frame = containerBounds CATransaction.commit() #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.webframe.normalize web=\(browserPortalDebugToken(webView)) " + "container=\(browserPortalDebugToken(containerView)) old=\(browserPortalDebugFrame(oldWebFrame)) " + "new=\(browserPortalDebugFrame(webView.frame)) bounds=\(browserPortalDebugFrame(containerBounds)) " + @@ -3750,7 +3729,7 @@ final class WindowBrowserPortal: NSObject { let revealedForDisplay = !shouldHide && containerView.isHidden if shouldHide, !containerView.isHidden, !shouldPreserveVisibleOnTransientGeometry { #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " + "web=\(browserPortalDebugToken(webView)) value=\(shouldHide ? 1 : 0) " + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + @@ -3762,7 +3741,7 @@ final class WindowBrowserPortal: NSObject { hideContainerView(reason: transientRecoveryReason ?? "geometryHidden") } else if !shouldHide, containerView.isHidden { #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " + "web=\(browserPortalDebugToken(webView)) value=0 " + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + @@ -3806,7 +3785,7 @@ final class WindowBrowserPortal: NSObject { !recoveredFromTransientGeometry && !requiresRenderingStateReattach { #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.refresh.skip web=\(browserPortalDebugToken(webView)) " + "container=\(browserPortalDebugToken(containerView)) reason=\(source):" + "\(refreshReasons.joined(separator: ",")) adjustedDuringSync=1" @@ -3841,7 +3820,7 @@ final class WindowBrowserPortal: NSObject { _ = hostView.reapplyHostedInspectorDividerIfNeeded(in: containerView, reason: "portal.sync.postRefresh") } #if DEBUG - cmuxDebugLog( + let line = "browser.portal.sync.result web=\(browserPortalDebugToken(webView)) source=\(source) " + "container=\(browserPortalDebugToken(containerView)) " + "anchor=\(browserPortalDebugToken(anchorView)) host=\(browserPortalDebugToken(hostView)) " + @@ -3859,7 +3838,7 @@ final class WindowBrowserPortal: NSObject { "inspectorInsets=\(String(format: "%.1f", inspectorHeightFromInsets)) " + "inspectorOverflow=\(String(format: "%.1f", inspectorHeightFromOverflow)) " + "inspectorSubviews=\(inspectorSubviews)" - ) + dlog(line) #endif } @@ -3966,7 +3945,6 @@ enum BrowserWindowPortalRegistry { private static var webViewToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:] private static func postRegistryDidChange(for webView: WKWebView) { - NotificationCenter.default.post(name: .browserPortalRegistryDidChange, object: webView) } private static func installWindowCloseObserverIfNeeded(for window: NSWindow) { diff --git a/Sources/CmuxConfig.swift b/Sources/CmuxConfig.swift index 8b442a7e4b62..2cd28ce46dab 100644 --- a/Sources/CmuxConfig.swift +++ b/Sources/CmuxConfig.swift @@ -1,1505 +1,8 @@ -import Bonsplit import Combine -import CryptoKit import Foundation struct CmuxConfigFile: Codable, Sendable { - var actions: [String: CmuxConfigActionDefinition] - var ui: CmuxConfigUIDefinition? - var newWorkspaceCommand: String? - var surfaceTabBarButtons: [CmuxSurfaceTabBarButton]? var commands: [CmuxCommandDefinition] - - private enum CodingKeys: String, CodingKey { - case actions - case ui - case newWorkspaceCommand - case surfaceTabBarButtons - case commands - } - - init( - actions: [String: CmuxConfigActionDefinition] = [:], - ui: CmuxConfigUIDefinition? = nil, - newWorkspaceCommand: String? = nil, - surfaceTabBarButtons: [CmuxSurfaceTabBarButton]? = nil, - commands: [CmuxCommandDefinition] = [] - ) { - self.actions = actions - self.ui = ui - self.newWorkspaceCommand = newWorkspaceCommand - self.surfaceTabBarButtons = surfaceTabBarButtons - self.commands = commands - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let decodedActions = try container.decodeIfPresent( - [String: CmuxConfigActionDefinition].self, - forKey: .actions - ) ?? [:] - actions = try Self.normalizedActions( - decodedActions, - codingPath: decoder.codingPath + [CodingKeys.actions] - ) - ui = try container.decodeIfPresent(CmuxConfigUIDefinition.self, forKey: .ui) - - if let rawNewWorkspaceCommand = try container.decodeIfPresent(String.self, forKey: .newWorkspaceCommand) { - let trimmed = rawNewWorkspaceCommand.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: decoder.codingPath + [CodingKeys.newWorkspaceCommand], - debugDescription: "newWorkspaceCommand must not be blank" - ) - ) - } - newWorkspaceCommand = trimmed - } else { - newWorkspaceCommand = nil - } - - let rootSurfaceButtons = try container.decodeIfPresent( - [CmuxSurfaceTabBarButton].self, - forKey: .surfaceTabBarButtons - ) - let configuredSurfaceButtons = ui?.surfaceTabBar?.buttons ?? rootSurfaceButtons - if let configuredSurfaceButtons { - surfaceTabBarButtons = try Self.validatedSurfaceTabBarButtons( - configuredSurfaceButtons, - codingPath: decoder.codingPath + [ - ui?.surfaceTabBar?.buttons == nil ? CodingKeys.surfaceTabBarButtons : CodingKeys.ui - ] - ) - } else { - surfaceTabBarButtons = nil - } - commands = try container.decodeIfPresent([CmuxCommandDefinition].self, forKey: .commands) ?? [] - } - - private static func normalizedActions( - _ decodedActions: [String: CmuxConfigActionDefinition], - codingPath: [CodingKey] - ) throws -> [String: CmuxConfigActionDefinition] { - var actions: [String: CmuxConfigActionDefinition] = [:] - for (rawID, action) in decodedActions { - let id = rawID.trimmingCharacters(in: .whitespacesAndNewlines) - if id.isEmpty { - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: codingPath, - debugDescription: "actions keys must not be blank" - ) - ) - } - if actions[id] != nil { - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: codingPath, - debugDescription: "actions must not contain duplicate ids" - ) - ) - } - actions[id] = action - } - return actions - } - - private static func validatedSurfaceTabBarButtons( - _ buttons: [CmuxSurfaceTabBarButton], - codingPath: [CodingKey] - ) throws -> [CmuxSurfaceTabBarButton] { - var seen = Set() - for button in buttons { - if !seen.insert(button.id).inserted { - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: codingPath, - debugDescription: "surface tab bar buttons must not contain duplicate ids" - ) - ) - } - } - return buttons - } -} - -enum CmuxSurfaceTabBarBuiltInAction: String, Codable, Sendable, CaseIterable, Hashable { - case newTerminal = "cmux.newTerminal" - case newBrowser = "cmux.newBrowser" - case splitRight = "cmux.splitRight" - case splitDown = "cmux.splitDown" - - init?(configID: String) { - switch configID { - case "cmux.newTerminal", "newTerminal": - self = .newTerminal - case "cmux.newBrowser", "newBrowser": - self = .newBrowser - case "cmux.splitRight", "splitRight": - self = .splitRight - case "cmux.splitDown", "splitDown": - self = .splitDown - default: - return nil - } - } - - var configID: String { - rawValue - } - - var defaultIcon: String { - switch self { - case .newTerminal: - return "terminal" - case .newBrowser: - return "globe" - case .splitRight: - return "square.split.2x1" - case .splitDown: - return "square.split.1x2" - } - } - - var bonsplitAction: BonsplitConfiguration.SplitActionButton.Action { - switch self { - case .newTerminal: - return .newTerminal - case .newBrowser: - return .newBrowser - case .splitRight: - return .splitRight - case .splitDown: - return .splitDown - } - } -} - -enum CmuxConfigTerminalCommandTarget: String, Codable, Sendable, Hashable { - case currentTerminal - case newTabInCurrentPane - - static let defaultForActions: CmuxConfigTerminalCommandTarget = .newTabInCurrentPane -} - -extension CmuxSurfaceTabBarBuiltInAction { - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - let value = try container.decode(String.self) - .trimmingCharacters(in: .whitespacesAndNewlines) - guard let action = Self(configID: value) else { - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Unknown built-in action '\(value)'" - ) - ) - } - self = action - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(configID) - } -} - -enum CmuxConfigAgentKind: Sendable, Hashable { - case codex - case claudeCode - - var commandName: String { - switch self { - case .codex: - return "codex" - case .claudeCode: - return "claude" - } - } - - var defaultIcon: CmuxButtonIcon { - switch self { - case .codex: - return .symbol("sparkles") - case .claudeCode: - return .symbol("brain.head.profile") - } - } -} - -extension CmuxConfigAgentKind: Codable { - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - let value = try container.decode(String.self) - .trimmingCharacters(in: .whitespacesAndNewlines) - switch value { - case "codex": - self = .codex - case "claude", "claudeCode", "claude-code": - self = .claudeCode - default: - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Unknown agent '\(value)'" - ) - ) - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .codex: - try container.encode("codex") - case .claudeCode: - try container.encode("claude") - } - } -} - -enum CmuxButtonIcon: Codable, Sendable, Hashable { - case symbol(String) - case emoji(String, scale: Double = 1) - case imagePath(String) - - var symbolName: String { - if case .symbol(let name) = self { - return name - } - return "questionmark.circle" - } - - private enum CodingKeys: String, CodingKey { - case type - case name - case value - case path - case scale - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let type = try Self.trimmedString(forKey: .type, in: container) - switch type { - case "symbol", "sfSymbol", "systemImage": - self = .symbol(try Self.trimmedString(forKey: .name, in: container)) - case "emoji": - self = .emoji( - try Self.trimmedString(forKey: .value, in: container), - scale: try Self.emojiScale(in: container) - ) - case "image", "file": - self = .imagePath(try Self.trimmedString(forKey: .path, in: container)) - default: - throw DecodingError.dataCorruptedError( - forKey: .type, - in: container, - debugDescription: "Unknown icon type '\(type)'" - ) - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - switch self { - case .symbol(let name): - try container.encode("symbol", forKey: .type) - try container.encode(name, forKey: .name) - case .emoji(let value, let scale): - try container.encode("emoji", forKey: .type) - try container.encode(value, forKey: .value) - if scale != 1 { - try container.encode(scale, forKey: .scale) - } - case .imagePath(let path): - try container.encode("image", forKey: .type) - try container.encode(path, forKey: .path) - } - } - - func bonsplitIcon( - configSourcePath: String?, - globalConfigPath: String, - allowProjectLocalImage: Bool = true - ) -> BonsplitConfiguration.SplitActionButton.Icon { - switch self { - case .symbol(let name): - return .systemImage(name) - case .emoji(let value, let scale): - return .emoji(value, scale: scale) - case .imagePath(let path): - guard let preparedImage = Self.preparedImageAsset( - path, - relativeToConfig: configSourcePath, - globalConfigPath: globalConfigPath - ) else { - NSLog("[CmuxConfig] icon image path is not allowed: %@", path) - return .systemImage("questionmark.circle") - } - if preparedImage.isProjectLocal && !allowProjectLocalImage { - return .systemImage("lock.fill") - } - return .imageData(preparedImage.data) - } - } - - func projectLocalImageFingerprint(configSourcePath: String?, globalConfigPath: String) -> String? { - guard case .imagePath(let path) = self, - let preparedImage = Self.preparedImageAsset( - path, - relativeToConfig: configSourcePath, - globalConfigPath: globalConfigPath - ), - preparedImage.isProjectLocal else { - return nil - } - return preparedImage.fingerprint - } - - func resolvingRelativeImagePath(relativeToConfig configSourcePath: String?) -> CmuxButtonIcon { - guard case .imagePath(let path) = self else { return self } - return .imagePath(Self.resolvePath(path, relativeToConfig: configSourcePath)) - } - - private static let maxImageBytes = 1_000_000 - - private static func emojiScale(in container: KeyedDecodingContainer) throws -> Double { - let scale = try container.decodeIfPresent(Double.self, forKey: .scale) ?? 1 - guard scale.isFinite, scale > 0 else { - throw DecodingError.dataCorruptedError( - forKey: .scale, - in: container, - debugDescription: "Emoji icon scale must be a positive number" - ) - } - return scale - } - - private struct PreparedImageAsset { - let data: Data - let fingerprint: String - let isProjectLocal: Bool - } - - private static func looksLikeSVGPath(_ value: String) -> Bool { - (value as NSString).pathExtension.lowercased() == "svg" - } - - private static func isSafeSVG(data: Data) -> Bool { - guard let text = String(data: data, encoding: .utf8) else { return false } - let lowered = text.lowercased() - guard !lowered.contains(" String { - let expanded = (path as NSString).expandingTildeInPath - if (expanded as NSString).isAbsolutePath { - return expanded - } - guard let configSourcePath else { return expanded } - let base = (configSourcePath as NSString).deletingLastPathComponent - return (base as NSString).appendingPathComponent(expanded) - } - - private static func safeResolvedImagePath( - _ path: String, - relativeToConfig configSourcePath: String?, - globalConfigPath: String - ) -> String? { - let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.lowercased().hasPrefix("http://"), - !trimmed.lowercased().hasPrefix("https://") else { - return nil - } - - let isGlobal = configSourcePath == nil || configSourcePath == globalConfigPath - if !isGlobal { - let expanded = (trimmed as NSString).expandingTildeInPath - guard !(expanded as NSString).isAbsolutePath, - expanded == trimmed else { - return nil - } - } - - let resolvedPath = resolvePath(trimmed, relativeToConfig: configSourcePath) - guard !resolvedPath.isEmpty else { return nil } - let standardizedPath = (resolvedPath as NSString).standardizingPath - - guard !isGlobal, let configSourcePath else { - return standardizedPath - } - - let allowedRoot = projectRoot(forConfigPath: configSourcePath) - let resolvedURL = URL(fileURLWithPath: standardizedPath).resolvingSymlinksInPath() - let allowedURL = URL(fileURLWithPath: allowedRoot).resolvingSymlinksInPath() - let resolved = resolvedURL.path - let allowed = allowedURL.path - guard resolved == allowed || resolved.hasPrefix(allowed + "/") else { - return nil - } - return resolved - } - - private static func preparedImageAsset( - _ path: String, - relativeToConfig configSourcePath: String?, - globalConfigPath: String - ) -> PreparedImageAsset? { - guard let resolvedPath = safeResolvedImagePath( - path, - relativeToConfig: configSourcePath, - globalConfigPath: globalConfigPath - ) else { - return nil - } - guard let data = FileManager.default.contents(atPath: resolvedPath) else { - NSLog("[CmuxConfig] icon image does not exist: %@", resolvedPath) - return nil - } - guard data.count <= maxImageBytes else { - NSLog("[CmuxConfig] icon image is too large: %@", resolvedPath) - return nil - } - if looksLikeSVGPath(resolvedPath), !isSafeSVG(data: data) { - NSLog("[CmuxConfig] icon SVG contains unsupported content: %@", resolvedPath) - return nil - } - - let isProjectLocal = configSourcePath != nil && configSourcePath != globalConfigPath - let fingerprint = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() - return PreparedImageAsset(data: data, fingerprint: fingerprint, isProjectLocal: isProjectLocal) - } - - static func projectRoot(forConfigPath configPath: String) -> String { - let configDir = (configPath as NSString).deletingLastPathComponent - if (configDir as NSString).lastPathComponent == ".cmux" { - return (configDir as NSString).deletingLastPathComponent - } - return configDir - } - - private final class SVGSecurityInspector: NSObject, XMLParserDelegate { - private var isSafe = true - private var elementStack: [String] = [] - - func parse(data: Data) -> Bool { - let parser = XMLParser(data: data) - parser.delegate = self - parser.shouldProcessNamespaces = false - parser.shouldResolveExternalEntities = false - let parsed = parser.parse() - return parsed && isSafe - } - - func parser( - _ parser: XMLParser, - didStartElement elementName: String, - namespaceURI: String?, - qualifiedName qName: String?, - attributes attributeDict: [String: String] = [:] - ) { - let loweredName = elementName.lowercased() - elementStack.append(loweredName) - - if loweredName == "script" || loweredName == "foreignobject" { - markUnsafe(parser) - return - } - - for (name, value) in attributeDict { - guard Self.isSafeSVGAttribute(name: name, value: value) else { - markUnsafe(parser) - return - } - } - } - - func parser( - _ parser: XMLParser, - didEndElement elementName: String, - namespaceURI: String?, - qualifiedName qName: String? - ) { - guard !elementStack.isEmpty else { return } - elementStack.removeLast() - } - - func parser(_ parser: XMLParser, foundCharacters string: String) { - guard elementStack.last == "style" else { return } - guard Self.isSafeSVGStyle(string) else { - markUnsafe(parser) - return - } - } - - func parser(_ parser: XMLParser, foundCDATA CDATABlock: Data) { - guard elementStack.last == "style", - let text = String(data: CDATABlock, encoding: .utf8) else { - return - } - guard Self.isSafeSVGStyle(text) else { - markUnsafe(parser) - return - } - } - - func parser( - _ parser: XMLParser, - foundProcessingInstructionWithTarget target: String, - data: String? - ) { - if target.lowercased() == "xml-stylesheet" { - markUnsafe(parser) - } - } - - private func markUnsafe(_ parser: XMLParser) { - isSafe = false - parser.abortParsing() - } - - private static func isSafeSVGAttribute(name: String, value: String) -> Bool { - let loweredName = name.lowercased() - let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) - let loweredValue = trimmedValue.lowercased() - - if loweredName.hasPrefix("on") { - return false - } - - if loweredName == "xmlns" || loweredName.hasPrefix("xmlns:") { - return true - } - - if loweredName == "href" || loweredName == "xlink:href" { - return isSafeSVGReference(trimmedValue) - } - - if containsBlockedSVGValue(loweredValue) { - return false - } - - if loweredValue.contains("url(") { - return containsOnlyInternalSVGURLs(trimmedValue) - } - - return true - } - - private static func isSafeSVGStyle(_ value: String) -> Bool { - let loweredValue = value.lowercased() - guard !loweredValue.contains("@import"), - !containsBlockedSVGValue(loweredValue) else { - return false - } - if loweredValue.contains("url(") { - return containsOnlyInternalSVGURLs(value) - } - return true - } - - private static func isSafeSVGReference(_ value: String) -> Bool { - let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedValue.isEmpty else { return true } - if trimmedValue.hasPrefix("#") { - return true - } - if trimmedValue.lowercased().hasPrefix("url(") { - return containsOnlyInternalSVGURLs(trimmedValue) - } - return false - } - - private static func containsBlockedSVGValue(_ value: String) -> Bool { - let blockedFragments = [ - "javascript:", - "data:", - "http://", - "https://", - "file://", - "blob:" - ] - return blockedFragments.contains { value.contains($0) } - } - - private static func containsOnlyInternalSVGURLs(_ value: String) -> Bool { - let loweredValue = value.lowercased() - var searchStart = loweredValue.startIndex - - while let range = loweredValue.range( - of: "url(", - options: [], - range: searchStart.. - ) throws -> String { - let raw = try container.decode(String.self, forKey: key) - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - throw DecodingError.dataCorruptedError( - forKey: key, - in: container, - debugDescription: "\(key.stringValue) must not be blank" - ) - } - return trimmed - } -} - -struct CmuxConfigUIDefinition: Codable, Sendable, Hashable { - var newWorkspace: CmuxConfigButtonPlacement? - var surfaceTabBar: CmuxSurfaceTabBarUIDefinition? -} - -struct CmuxSurfaceTabBarUIDefinition: Codable, Sendable, Hashable { - var buttons: [CmuxSurfaceTabBarButton]? -} - -struct CmuxConfigButtonPlacement: Codable, Sendable, Hashable { - var action: String? - var icon: CmuxButtonIcon? - var tooltip: String? - - private enum CodingKeys: String, CodingKey { - case action - case icon - case tooltip - } - - init(action: String? = nil, icon: CmuxButtonIcon? = nil, tooltip: String? = nil) { - self.action = action - self.icon = icon - self.tooltip = tooltip - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - action = try Self.trimmedString(forKey: .action, in: container) - icon = try container.decodeIfPresent(CmuxButtonIcon.self, forKey: .icon) - tooltip = try Self.trimmedString(forKey: .tooltip, in: container, allowBlankAsNil: true) - } - - private static func trimmedString( - forKey key: CodingKeys, - in container: KeyedDecodingContainer, - allowBlankAsNil: Bool = false - ) throws -> String? { - guard container.contains(key) else { return nil } - let raw = try container.decode(String.self, forKey: key) - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - if allowBlankAsNil { return nil } - throw DecodingError.dataCorruptedError( - forKey: key, - in: container, - debugDescription: "\(key.stringValue) must not be blank" - ) - } - return trimmed - } -} - -struct CmuxConfigActionDefinition: Codable, Sendable, Hashable { - var action: CmuxSurfaceTabBarButtonAction? - var title: String? - var subtitle: String? - var keywords: [String]? - var palette: Bool? - var shortcut: StoredShortcut? - var icon: CmuxButtonIcon? - var tooltip: String? - var confirm: Bool? - var terminalCommandTarget: CmuxConfigTerminalCommandTarget? - - private enum CodingKeys: String, CodingKey { - case type - case builtin - case command - case commandName - case name - case agent - case args - case title - case subtitle - case description - case keywords - case palette - case shortcut - case icon - case tooltip - case confirm - case target - } - - init( - action: CmuxSurfaceTabBarButtonAction? = nil, - title: String? = nil, - subtitle: String? = nil, - keywords: [String]? = nil, - palette: Bool? = nil, - shortcut: StoredShortcut? = nil, - icon: CmuxButtonIcon? = nil, - tooltip: String? = nil, - confirm: Bool? = nil, - terminalCommandTarget: CmuxConfigTerminalCommandTarget? = nil - ) { - self.action = action - self.title = title - self.subtitle = subtitle - self.keywords = keywords - self.palette = palette - self.shortcut = shortcut - self.icon = icon - self.tooltip = tooltip - self.confirm = confirm - self.terminalCommandTarget = terminalCommandTarget - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let type = try Self.trimmedString(forKey: .type, in: container) - title = try Self.trimmedString(forKey: .title, in: container, allowBlankAsNil: true) - subtitle = try Self.trimmedString(forKey: .subtitle, in: container, allowBlankAsNil: true) - ?? Self.trimmedString(forKey: .description, in: container, allowBlankAsNil: true) - keywords = try container.decodeIfPresent([String].self, forKey: .keywords)? - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - palette = try container.decodeIfPresent(Bool.self, forKey: .palette) - shortcut = try Self.decodeShortcut(forKey: .shortcut, in: container) - icon = try container.decodeIfPresent(CmuxButtonIcon.self, forKey: .icon) - tooltip = try Self.trimmedString(forKey: .tooltip, in: container, allowBlankAsNil: true) - confirm = try container.decodeIfPresent(Bool.self, forKey: .confirm) - terminalCommandTarget = try container.decodeIfPresent(CmuxConfigTerminalCommandTarget.self, forKey: .target) - - let inferredType: String? - if let type { - inferredType = type - } else if container.contains(.agent) { - inferredType = "agent" - } else if container.contains(.builtin) { - inferredType = "builtin" - } else if container.contains(.command) { - inferredType = "command" - } else { - inferredType = nil - } - - switch inferredType { - case "builtin": - let raw = try Self.trimmedString(forKey: .builtin, in: container) ?? "" - guard let builtIn = CmuxSurfaceTabBarBuiltInAction(configID: raw) else { - throw DecodingError.dataCorruptedError( - forKey: .builtin, - in: container, - debugDescription: "Unknown built-in action '\(raw)'" - ) - } - action = .builtIn(builtIn) - case "command": - let command = try Self.requiredTrimmedString(forKey: .command, in: container) - action = .command(command) - case "agent": - let agent = try container.decode(CmuxConfigAgentKind.self, forKey: .agent) - let args = try Self.trimmedString(forKey: .args, in: container, allowBlankAsNil: true) - action = .agent(agent, args: args) - case "workspaceCommand": - let commandName = try Self.trimmedString(forKey: .commandName, in: container) - ?? Self.trimmedString(forKey: .name, in: container) - ?? Self.trimmedString(forKey: .command, in: container) - guard let commandName else { - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "workspaceCommand actions require commandName" - ) - ) - } - action = .workspaceCommand(commandName) - case nil: - action = nil - default: - throw DecodingError.dataCorruptedError( - forKey: .type, - in: container, - debugDescription: "Unknown action type '\(inferredType ?? "")'" - ) - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encodeIfPresent(title, forKey: .title) - try container.encodeIfPresent(subtitle, forKey: .subtitle) - try container.encodeIfPresent(keywords, forKey: .keywords) - try container.encodeIfPresent(palette, forKey: .palette) - try Self.encodeShortcut(shortcut, forKey: .shortcut, in: &container) - try container.encodeIfPresent(icon, forKey: .icon) - try container.encodeIfPresent(tooltip, forKey: .tooltip) - try container.encodeIfPresent(confirm, forKey: .confirm) - try container.encodeIfPresent(terminalCommandTarget, forKey: .target) - guard let action else { return } - switch action { - case .builtIn(let builtIn): - try container.encode("builtin", forKey: .type) - try container.encode(builtIn.configID, forKey: .builtin) - case .command(let command): - try container.encode("command", forKey: .type) - try container.encode(command, forKey: .command) - case .agent(let agent, let args): - try container.encode("agent", forKey: .type) - try container.encode(agent, forKey: .agent) - try container.encodeIfPresent(args, forKey: .args) - case .workspaceCommand(let commandName): - try container.encode("workspaceCommand", forKey: .type) - try container.encode(commandName, forKey: .commandName) - case .actionReference(let identifier): - try container.encode("builtin", forKey: .type) - try container.encode(identifier, forKey: .builtin) - } - } - - private static func requiredTrimmedString( - forKey key: CodingKeys, - in container: KeyedDecodingContainer - ) throws -> String { - guard let value = try trimmedString(forKey: key, in: container) else { - throw DecodingError.keyNotFound( - key, - DecodingError.Context( - codingPath: container.codingPath, - debugDescription: "\(key.stringValue) is required" - ) - ) - } - return value - } - - private static func trimmedString( - forKey key: CodingKeys, - in container: KeyedDecodingContainer, - allowBlankAsNil: Bool = false - ) throws -> String? { - guard container.contains(key) else { return nil } - let raw = try container.decode(String.self, forKey: key) - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - if allowBlankAsNil { return nil } - throw DecodingError.dataCorruptedError( - forKey: key, - in: container, - debugDescription: "\(key.stringValue) must not be blank" - ) - } - return trimmed - } - - private static func decodeShortcut( - forKey key: CodingKeys, - in container: KeyedDecodingContainer - ) throws -> StoredShortcut? { - guard container.contains(key) else { return nil } - if let rawShortcut = try? container.decode(String.self, forKey: key) { - guard let shortcut = StoredShortcut.parseConfig(rawShortcut) else { - throw DecodingError.dataCorruptedError( - forKey: key, - in: container, - debugDescription: "shortcut must use modifier+key syntax like 'cmd+shift+t'" - ) - } - return shortcut - } - if let rawShortcut = try? container.decode([String].self, forKey: key) { - guard let shortcut = StoredShortcut.parseConfig(strokes: rawShortcut) else { - throw DecodingError.dataCorruptedError( - forKey: key, - in: container, - debugDescription: "shortcut chords must be one or two non-empty strokes" - ) - } - return shortcut - } - throw DecodingError.dataCorruptedError( - forKey: key, - in: container, - debugDescription: "shortcut must be a string or array of one or two strings" - ) - } - - private static func encodeShortcut( - _ shortcut: StoredShortcut?, - forKey key: CodingKeys, - in container: inout KeyedEncodingContainer - ) throws { - guard let shortcut else { return } - if let secondStroke = shortcut.secondStroke { - try container.encode( - [shortcut.firstStroke.configString(), secondStroke.configString()], - forKey: key - ) - } else { - try container.encode(shortcut.firstStroke.configString(), forKey: key) - } - } -} - -enum CmuxSurfaceTabBarButtonAction: Sendable, Hashable { - case builtIn(CmuxSurfaceTabBarBuiltInAction) - case command(String) - case agent(CmuxConfigAgentKind, args: String?) - case workspaceCommand(String) - case actionReference(String) - - var defaultId: String { - switch self { - case .builtIn(let action): - return action.configID - case .command(let command): - return "command." + Self.generatedCommandId(for: command) - case .agent(let agent, _): - return agent.commandName - case .workspaceCommand(let commandName): - return "workspaceCommand." + Self.generatedCommandId(for: commandName) - case .actionReference(let identifier): - return identifier - } - } - - var defaultIcon: String { - defaultButtonIcon.symbolName - } - - var defaultButtonIcon: CmuxButtonIcon { - switch self { - case .builtIn(let action): - return .symbol(action.defaultIcon) - case .command: - return .symbol("terminal") - case .agent(let agent, _): - return agent.defaultIcon - case .workspaceCommand: - return .symbol("rectangle.stack.badge.plus") - case .actionReference: - return .symbol("questionmark.circle") - } - } - - var terminalCommand: String? { - switch self { - case .command(let command): - return command - case .agent(let agent, let args): - let trimmedArgs = args?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmedArgs.isEmpty ? agent.commandName : "\(agent.commandName) \(trimmedArgs)" - case .builtIn, .workspaceCommand, .actionReference: - return nil - } - } - - var workspaceCommandName: String? { - if case .workspaceCommand(let name) = self { - return name - } - return nil - } - - private static func generatedCommandId(for command: String) -> String { - let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) - let encoded = command.addingPercentEncoding(withAllowedCharacters: allowed) ?? command - return encoded.isEmpty ? "command" : encoded - } -} - -struct CmuxSurfaceTabBarButton: Codable, Sendable, Hashable, Identifiable { - var id: String - var title: String? - var icon: CmuxButtonIcon? - var tooltip: String? - var action: CmuxSurfaceTabBarButtonAction - var confirm: Bool? - var terminalCommandTarget: CmuxConfigTerminalCommandTarget? - var actionSourcePath: String? - var iconSourcePath: String? - - private enum CodingKeys: String, CodingKey { - case id - case title - case icon - case tooltip - case action - case builtin - case command - case agent - case args - case type - case commandName - case name - case confirm - case target - } - - static let newTerminal = actionReference(CmuxSurfaceTabBarBuiltInAction.newTerminal.configID) - static let newBrowser = actionReference(CmuxSurfaceTabBarBuiltInAction.newBrowser.configID) - static let splitRight = actionReference(CmuxSurfaceTabBarBuiltInAction.splitRight.configID) - static let splitDown = actionReference(CmuxSurfaceTabBarBuiltInAction.splitDown.configID) - - static let defaults: [CmuxSurfaceTabBarButton] = [ - .newTerminal, - .newBrowser, - .splitRight, - .splitDown - ] - - static func builtIn( - _ action: CmuxSurfaceTabBarBuiltInAction, - id: String? = nil, - title: String? = nil, - icon: CmuxButtonIcon? = nil, - tooltip: String? = nil - ) -> CmuxSurfaceTabBarButton { - CmuxSurfaceTabBarButton( - id: id ?? action.configID, - title: title, - icon: icon, - tooltip: tooltip, - action: .builtIn(action), - confirm: nil, - terminalCommandTarget: nil - ) - } - - static func actionReference( - _ actionID: String, - title: String? = nil, - icon: CmuxButtonIcon? = nil, - tooltip: String? = nil - ) -> CmuxSurfaceTabBarButton { - CmuxSurfaceTabBarButton( - id: actionID, - title: title, - icon: icon, - tooltip: tooltip, - action: .actionReference(actionID) - ) - } - - var command: String? { - action.terminalCommand - } - - var terminalCommand: String? { - action.terminalCommand - } - - var resolvedTerminalCommandTarget: CmuxConfigTerminalCommandTarget { - terminalCommandTarget ?? CmuxConfigTerminalCommandTarget.defaultForActions - } - - var workspaceCommandName: String? { - action.workspaceCommandName - } - - func bonsplitActionButton( - configSourcePath: String?, - globalConfigPath: String, - allowProjectLocalIcon: Bool = true - ) -> BonsplitConfiguration.SplitActionButton { - let bonsplitAction: BonsplitConfiguration.SplitActionButton.Action = { - switch action { - case .builtIn(let builtIn): - return builtIn.bonsplitAction - case .command, .agent, .workspaceCommand, .actionReference: - return .custom(id) - } - }() - - return BonsplitConfiguration.SplitActionButton( - id: id, - icon: (icon ?? action.defaultButtonIcon).bonsplitIcon( - configSourcePath: iconSourcePath ?? configSourcePath, - globalConfigPath: globalConfigPath, - allowProjectLocalImage: allowProjectLocalIcon - ), - tooltip: tooltip ?? title ?? terminalCommand, - action: bonsplitAction - ) - } - - init( - id: String, - title: String? = nil, - icon: CmuxButtonIcon? = nil, - tooltip: String? = nil, - action: CmuxSurfaceTabBarButtonAction, - confirm: Bool? = nil, - terminalCommandTarget: CmuxConfigTerminalCommandTarget? = nil, - actionSourcePath: String? = nil, - iconSourcePath: String? = nil - ) { - self.id = id - self.title = title - self.icon = icon - self.tooltip = tooltip - self.action = action - self.confirm = confirm - self.terminalCommandTarget = terminalCommandTarget - self.actionSourcePath = actionSourcePath - self.iconSourcePath = iconSourcePath - } - - init(from decoder: Decoder) throws { - if let legacy = try? decoder.singleValueContainer().decode(String.self) { - let trimmed = legacy.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "surface tab bar button action must not be blank" - ) - ) - } - self = CmuxSurfaceTabBarButton( - id: CmuxSurfaceTabBarBuiltInAction(configID: trimmed)?.configID ?? trimmed, - action: .actionReference(CmuxSurfaceTabBarBuiltInAction(configID: trimmed)?.configID ?? trimmed) - ) - return - } - - let container = try decoder.container(keyedBy: CodingKeys.self) - let explicitId = try Self.trimmedString(forKey: .id, in: container) - let explicitTitle = try Self.trimmedString(forKey: .title, in: container, allowBlankAsNil: true) - let explicitIcon = try container.decodeIfPresent(CmuxButtonIcon.self, forKey: .icon) - let explicitTooltip = try Self.trimmedString(forKey: .tooltip, in: container, allowBlankAsNil: true) - let rawAction = try Self.trimmedString(forKey: .action, in: container) - let rawBuiltin = try Self.trimmedString(forKey: .builtin, in: container) - let rawCommand = try Self.trimmedString(forKey: .command, in: container) - let rawAgent = try container.decodeIfPresent(CmuxConfigAgentKind.self, forKey: .agent) - let rawArgs = try Self.trimmedString(forKey: .args, in: container, allowBlankAsNil: true) - let rawType = try Self.trimmedString(forKey: .type, in: container) - let rawCommandName = try Self.trimmedString(forKey: .commandName, in: container) - ?? Self.trimmedString(forKey: .name, in: container) - confirm = try container.decodeIfPresent(Bool.self, forKey: .confirm) - terminalCommandTarget = try container.decodeIfPresent(CmuxConfigTerminalCommandTarget.self, forKey: .target) - actionSourcePath = nil - iconSourcePath = nil - - let definedActionForms = [ - rawAction != nil, - rawBuiltin != nil, - rawCommand != nil, - rawAgent != nil, - rawType != nil - ].filter(\.self).count - if definedActionForms > 1 { - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "surfaceTabBarButtons entries must define only one of 'action', 'builtin', 'command', 'agent', or 'type'" - ) - ) - } - - if let rawType { - switch rawType { - case "workspaceCommand": - guard let rawCommandName else { - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "workspaceCommand surface tab bar buttons require commandName" - ) - ) - } - action = .workspaceCommand(rawCommandName) - default: - throw DecodingError.dataCorruptedError( - forKey: .type, - in: container, - debugDescription: "Unknown surface tab bar button type '\(rawType)'" - ) - } - } else if let rawCommand { - action = .command(rawCommand) - } else if let rawAgent { - action = .agent(rawAgent, args: rawArgs) - } else if let rawBuiltin { - guard let builtIn = CmuxSurfaceTabBarBuiltInAction(configID: rawBuiltin) else { - throw DecodingError.dataCorruptedError( - forKey: .builtin, - in: container, - debugDescription: "Unknown built-in surface tab bar action '\(rawBuiltin)'" - ) - } - action = .builtIn(builtIn) - } else if let rawAction { - action = .actionReference(rawAction) - } else if let explicitId, - let builtIn = CmuxSurfaceTabBarBuiltInAction(configID: explicitId) { - action = .builtIn(builtIn) - } else { - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "surfaceTabBarButtons entries must define 'action', 'builtin', 'command', 'agent', or 'type'" - ) - ) - } - - id = explicitId ?? action.defaultId - title = explicitTitle - icon = explicitIcon - tooltip = explicitTooltip - } - - func resolved( - actions: [String: CmuxResolvedConfigAction], - codingPath: [CodingKey] - ) throws -> CmuxSurfaceTabBarButton { - guard case .actionReference(let identifier) = action else { - return self - } - - if let definition = actions[identifier] { - return CmuxSurfaceTabBarButton( - id: id, - title: title ?? definition.title, - icon: icon ?? definition.icon, - tooltip: tooltip ?? definition.tooltip, - action: definition.action, - confirm: confirm ?? definition.confirm, - terminalCommandTarget: terminalCommandTarget ?? definition.terminalCommandTarget, - actionSourcePath: definition.actionSourcePath, - iconSourcePath: icon == nil ? definition.iconSourcePath : iconSourcePath - ) - } - - if let builtIn = CmuxSurfaceTabBarBuiltInAction(configID: identifier) { - return CmuxSurfaceTabBarButton( - id: id, - title: title, - icon: icon, - tooltip: tooltip, - action: .builtIn(builtIn), - confirm: confirm, - terminalCommandTarget: terminalCommandTarget - ) - } - - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: codingPath, - debugDescription: "Unknown action reference '\(identifier)'" - ) - ) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encodeIfPresent(title, forKey: .title) - try container.encodeIfPresent(icon, forKey: .icon) - try container.encodeIfPresent(tooltip, forKey: .tooltip) - try container.encodeIfPresent(confirm, forKey: .confirm) - try container.encodeIfPresent(terminalCommandTarget, forKey: .target) - - switch action { - case .builtIn(let builtIn): - try container.encode(builtIn.configID, forKey: .builtin) - case .command(let command): - try container.encode(command, forKey: .command) - case .agent(let agent, let args): - try container.encode(agent, forKey: .agent) - try container.encodeIfPresent(args, forKey: .args) - case .workspaceCommand(let commandName): - try container.encode("workspaceCommand", forKey: .type) - try container.encode(commandName, forKey: .commandName) - case .actionReference(let identifier): - try container.encode(identifier, forKey: .action) - } - } - - private static func trimmedString( - forKey key: CodingKeys, - in container: KeyedDecodingContainer, - allowBlankAsNil: Bool = false - ) throws -> String? { - guard container.contains(key) else { return nil } - let raw = try container.decode(String.self, forKey: key) - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - if allowBlankAsNil { return nil } - throw DecodingError.dataCorruptedError( - forKey: key, - in: container, - debugDescription: "\(key.stringValue) must not be blank" - ) - } - return trimmed - } -} - -struct CmuxResolvedConfigAction: Identifiable, Sendable, Hashable { - var id: String - var title: String - var subtitle: String? - var keywords: [String] - var palette: Bool - var shortcut: StoredShortcut? - var icon: CmuxButtonIcon? - var tooltip: String? - var action: CmuxSurfaceTabBarButtonAction - var confirm: Bool? - var terminalCommandTarget: CmuxConfigTerminalCommandTarget? - var actionSourcePath: String? - var iconSourcePath: String? - - var terminalCommand: String? { - action.terminalCommand - } - - var workspaceCommandName: String? { - action.workspaceCommandName - } - - func applying( - _ definition: CmuxConfigActionDefinition, - sourcePath: String? - ) -> CmuxResolvedConfigAction? { - var next = self - next.title = definition.title ?? next.title - next.subtitle = definition.subtitle ?? next.subtitle - if let keywords = definition.keywords { - next.keywords = keywords - } - next.palette = definition.palette ?? next.palette - next.shortcut = definition.shortcut ?? next.shortcut - if let icon = definition.icon { - next.icon = icon - next.iconSourcePath = sourcePath - } - next.tooltip = definition.tooltip ?? next.tooltip - next.confirm = definition.confirm ?? next.confirm - next.terminalCommandTarget = definition.terminalCommandTarget ?? next.terminalCommandTarget - next.actionSourcePath = sourcePath ?? next.actionSourcePath - if let action = definition.action { - next.action = action - } - return next - } - - static func fromDefinition( - id: String, - definition: CmuxConfigActionDefinition, - sourcePath: String? - ) -> CmuxResolvedConfigAction? { - guard let action = definition.action else { return nil } - let title = definition.title - ?? definition.tooltip - ?? Self.defaultTitle(for: id, action: action) - return CmuxResolvedConfigAction( - id: id, - title: title, - subtitle: definition.subtitle, - keywords: definition.keywords ?? [], - palette: definition.palette ?? true, - shortcut: definition.shortcut, - icon: definition.icon ?? action.defaultButtonIcon, - tooltip: definition.tooltip, - action: action, - confirm: definition.confirm, - terminalCommandTarget: definition.terminalCommandTarget, - actionSourcePath: sourcePath, - iconSourcePath: definition.icon == nil ? nil : sourcePath - ) - } - - static func builtIn(_ builtIn: CmuxSurfaceTabBarBuiltInAction) -> CmuxResolvedConfigAction { - let title: String - let keywords: [String] - switch builtIn { - case .newTerminal: - title = String(localized: "command.newTerminalTab.title", defaultValue: "New Terminal Tab") - keywords = ["new", "terminal", "tab", "surface"] - case .newBrowser: - title = String(localized: "command.newBrowserTab.title", defaultValue: "New Browser Tab") - keywords = ["new", "browser", "tab", "surface"] - case .splitRight: - title = String(localized: "command.terminalSplitRight.title", defaultValue: "Split Right") - keywords = ["terminal", "split", "right"] - case .splitDown: - title = String(localized: "command.terminalSplitDown.title", defaultValue: "Split Down") - keywords = ["terminal", "split", "down"] - } - - return CmuxResolvedConfigAction( - id: builtIn.configID, - title: title, - subtitle: String(localized: "command.cmuxConfig.builtInSubtitle", defaultValue: "cmux"), - keywords: keywords, - palette: true, - shortcut: nil, - icon: .symbol(builtIn.defaultIcon), - tooltip: nil, - action: .builtIn(builtIn), - confirm: nil, - terminalCommandTarget: nil, - actionSourcePath: nil, - iconSourcePath: nil - ) - } - - private static func defaultTitle(for id: String, action: CmuxSurfaceTabBarButtonAction) -> String { - switch action { - case .agent(let agent, _): - switch agent { - case .codex: - return String(localized: "command.cmuxConfig.defaultCodexTitle", defaultValue: "Codex") - case .claudeCode: - return String(localized: "command.cmuxConfig.defaultClaudeCodeTitle", defaultValue: "Claude Code") - } - case .command: - return id - case .workspaceCommand(let commandName): - return commandName - case .builtIn(let builtIn): - return builtIn.configID - case .actionReference(let identifier): - return identifier - } - } } struct CmuxCommandDefinition: Codable, Sendable, Identifiable { @@ -1752,131 +255,23 @@ enum CmuxSurfaceType: String, Codable, Sendable { case browser } -struct CmuxResolvedCommand: Sendable { - let command: CmuxCommandDefinition - let sourcePath: String? -} - -struct CmuxConfigIssue: Identifiable, Equatable, Sendable { - enum Kind: String, Sendable { - case newWorkspaceActionRequiresWorkspaceCommand - case newWorkspaceCommandNotFound - case newWorkspaceCommandRequiresWorkspace - case schemaError - } - - let kind: Kind - let settingName: String - let commandName: String? - let sourcePath: String? - let message: String? - - init( - kind: Kind, - settingName: String, - commandName: String? = nil, - sourcePath: String? = nil, - message: String? = nil - ) { - self.kind = kind - self.settingName = settingName - self.commandName = commandName - self.sourcePath = sourcePath - self.message = message - } - - var id: String { - [ - kind.rawValue, - settingName, - commandName ?? "", - sourcePath ?? "", - message ?? "" - ].joined(separator: "|") - } - - var logMessage: String { - switch kind { - case .newWorkspaceActionRequiresWorkspaceCommand: - return "\(settingName) must reference a workspaceCommand action" - case .newWorkspaceCommandNotFound: - return "\(settingName) '\(commandName ?? "")' does not match any loaded command" - case .newWorkspaceCommandRequiresWorkspace: - return "\(settingName) '\(commandName ?? "")' must reference a workspace command" - case .schemaError: - return "\(settingName) has a schema error: \(message ?? "unknown error")" - } - } -} - @MainActor final class CmuxConfigStore: ObservableObject { @Published private(set) var loadedCommands: [CmuxCommandDefinition] = [] - @Published private(set) var loadedActions: [CmuxResolvedConfigAction] = [] - @Published private(set) var newWorkspaceCommandName: String? - @Published private(set) var newWorkspaceAction: CmuxConfigActionDefinition? - @Published private(set) var surfaceTabBarButtons: [CmuxSurfaceTabBarButton] = CmuxSurfaceTabBarButton.defaults - @Published private(set) var configurationIssues: [CmuxConfigIssue] = [] @Published private(set) var configRevision: UInt64 = 0 /// Which config file each command came from, keyed by command id. private(set) var commandSourcePaths: [String: String] = [:] - private(set) var actionLookup: [String: CmuxResolvedConfigAction] = [:] - private(set) var surfaceTabBarButtonSourcePath: String? - private(set) var surfaceTabBarCommandSourcePaths: [String: String] = [:] - private(set) var newWorkspaceActionSourcePath: String? private(set) var localConfigPath: String? - private weak var tabManager: TabManager? - let globalConfigPath: String - - nonisolated private static func defaultGlobalConfigPath() -> String { + let globalConfigPath: String = { let home = FileManager.default.homeDirectoryForCurrentUser.path return (home as NSString).appendingPathComponent(".config/cmux/cmux.json") - } - - private struct ActionEntry { - let definition: CmuxConfigActionDefinition - let sourcePath: String? - } - - private struct ResolvedSurfaceTabBarButtonEntry { - let button: CmuxSurfaceTabBarButton - let terminalCommandSourcePath: String? - } - - private struct ResolvedSurfaceTabBarButtons { - let buttons: [CmuxSurfaceTabBarButton] - let terminalCommandSourcePaths: [String: String] - } - - private struct NewWorkspaceCommandResolution { - let command: CmuxResolvedCommand? - let issue: CmuxConfigIssue? - } - - private struct ParsedConfigCacheEntry { - let fileSize: UInt64 - let modificationDate: Date? - let config: CmuxConfigFile? - let issue: CmuxConfigIssue? - } - - private struct ParsedConfigResult { - let config: CmuxConfigFile? - let issue: CmuxConfigIssue? - } + }() - private var surfaceTabBarWorkspaceCommands: [String: CmuxResolvedCommand] = [:] - private var resolvedNewWorkspaceCommandCache: CmuxResolvedCommand? - private var parsedConfigCache: [String: ParsedConfigCacheEntry] = [:] - private var lifetimeCancellables = Set() - private var trackingCancellables = Set() + private var cancellables = Set() private var localFileWatchSource: DispatchSourceFileSystemObject? private var localFileDescriptor: Int32 = -1 - private var localConfigSearchDirectory: String? - private var localFallbackDirectoryWatchSource: DispatchSourceFileSystemObject? - private var localFallbackDirectoryDescriptor: Int32 = -1 private var globalFileWatchSource: DispatchSourceFileSystemObject? private var globalFileDescriptor: Int32 = -1 private let watchQueue = DispatchQueue(label: "com.cmux.config-file-watch") @@ -1884,49 +279,19 @@ final class CmuxConfigStore: ObservableObject { private static let maxReattachAttempts = 5 private static let reattachDelay: TimeInterval = 0.5 - private static func searchDirectoryForLocalConfigPath(_ path: String) -> String { - let configDirectory = (path as NSString).deletingLastPathComponent - if (configDirectory as NSString).lastPathComponent == ".cmux" { - return (configDirectory as NSString).deletingLastPathComponent - } - return configDirectory - } - - init( - globalConfigPath: String = CmuxConfigStore.defaultGlobalConfigPath(), - localConfigPath: String? = nil, - startFileWatchers: Bool = true - ) { - self.globalConfigPath = globalConfigPath - self.localConfigPath = localConfigPath - self.localConfigSearchDirectory = localConfigPath.map(Self.searchDirectoryForLocalConfigPath(_:)) - NotificationCenter.default.publisher(for: CmuxActionTrust.didChangeNotification) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self else { return } - self.applySurfaceTabBarButtonsToCurrentManager() - self.configRevision &+= 1 - } - .store(in: &lifetimeCancellables) - if startFileWatchers { - if localConfigPath != nil { - startLocalFileWatcher() - } - startGlobalFileWatcher() - } + init() { + startGlobalFileWatcher() } deinit { localFileWatchSource?.cancel() - localFallbackDirectoryWatchSource?.cancel() globalFileWatchSource?.cancel() } // MARK: - Public API func wireDirectoryTracking(tabManager: TabManager) { - trackingCancellables.removeAll() - self.tabManager = tabManager + cancellables.removeAll() tabManager.$selectedTabId .compactMap { [weak tabManager] tabId -> Workspace? in @@ -1934,33 +299,28 @@ final class CmuxConfigStore: ObservableObject { return tabManager.tabs.first(where: { $0.id == tabId }) } .removeDuplicates(by: { $0.id == $1.id }) - .map { workspace -> AnyPublisher in - workspace.$surfaceTabBarDirectory.eraseToAnyPublisher() + .map { workspace -> AnyPublisher in + workspace.$currentDirectory.eraseToAnyPublisher() } .switchToLatest() .removeDuplicates() + .receive(on: DispatchQueue.main) .sink { [weak self] directory in self?.updateLocalConfigPath(directory) } - .store(in: &trackingCancellables) - - tabManager.$tabs - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.applySurfaceTabBarButtonsToCurrentManager() - } - .store(in: &trackingCancellables) + .store(in: &cancellables) - updateLocalConfigPath(tabManager.selectedWorkspace?.surfaceTabBarDirectory) + if let directory = tabManager.selectedWorkspace?.currentDirectory { + updateLocalConfigPath(directory) + } } private func updateLocalConfigPath(_ directory: String?) { let newPath: String? if let directory, !directory.isEmpty { - localConfigSearchDirectory = directory - newPath = resolvedLocalConfigPath(startingFrom: directory) + newPath = findCmuxConfig(startingFrom: directory) + ?? (directory as NSString).appendingPathComponent("cmux.json") } else { - localConfigSearchDirectory = nil newPath = nil } @@ -1973,26 +333,12 @@ final class CmuxConfigStore: ObservableObject { loadAll() } - private func resolvedLocalConfigPath(startingFrom directory: String) -> String { - findCmuxConfig(startingFrom: directory) - ?? defaultLocalConfigPath(startingFrom: directory) - } - - private func defaultLocalConfigPath(startingFrom directory: String) -> String { - (((directory as NSString).appendingPathComponent(".cmux") as NSString) - .appendingPathComponent("cmux.json")) - } - private func findCmuxConfig(startingFrom directory: String) -> String? { var current = directory let fs = FileManager.default while true { - let candidates = [ - ((current as NSString).appendingPathComponent(".cmux") as NSString) - .appendingPathComponent("cmux.json"), - (current as NSString).appendingPathComponent("cmux.json") - ] - for candidate in candidates where fs.fileExists(atPath: candidate) { + let candidate = (current as NSString).appendingPathComponent("cmux.json") + if fs.fileExists(atPath: candidate) { return candidate } let parent = (current as NSString).deletingLastPathComponent @@ -2006,52 +352,14 @@ final class CmuxConfigStore: ObservableObject { var commands: [CmuxCommandDefinition] = [] var seenNames = Set() var sourcePaths: [String: String] = [:] - var configuredNewWorkspaceCommandName: String? - var configuredNewWorkspaceCommandSourcePath: String? - var configuredNewWorkspaceAction: CmuxConfigActionDefinition? - var configuredNewWorkspaceActionSourcePath: String? - var configuredSurfaceTabBarButtons: [CmuxSurfaceTabBarButton]? - var configuredSurfaceTabBarButtonSourcePath: String? - let localPath = localConfigPath - let localParseResult = localPath.map { parseConfig(at: $0) } - let globalParseResult = parseConfig(at: globalConfigPath) - let localConfig = localParseResult?.config - let globalConfig = globalParseResult.config - var issues = [CmuxConfigIssue]() - if let issue = localParseResult?.issue { - issues.append(issue) - } - if let issue = globalParseResult.issue { - issues.append(issue) - } - let localActions = localConfig.map { actionEntries(from: $0.actions, sourcePath: localPath) } ?? [:] - let globalActions = globalConfig.map { actionEntries(from: $0.actions, sourcePath: globalConfigPath) } ?? [:] - let localActionLookup = mergedActionEntries(primary: localActions, fallback: globalActions) // Local config takes precedence - if let localConfig { - if let newWorkspaceActionID = localConfig.ui?.newWorkspace?.action { - if let action = localActionLookup[newWorkspaceActionID] { - configuredNewWorkspaceAction = action.definition - configuredNewWorkspaceActionSourcePath = action.sourcePath - } else { - NSLog("[CmuxConfig] ui.newWorkspace.action '%@' does not match any local or global action", newWorkspaceActionID) - } - } - if configuredNewWorkspaceAction == nil, - let newWorkspaceCommand = localConfig.newWorkspaceCommand { - configuredNewWorkspaceCommandName = newWorkspaceCommand - configuredNewWorkspaceCommandSourcePath = localPath - } - if let buttons = localConfig.surfaceTabBarButtons { - configuredSurfaceTabBarButtons = buttons - configuredSurfaceTabBarButtonSourcePath = localPath - } - for command in localConfig.commands { - if !seenNames.contains(command.name) { - commands.append(command) - seenNames.insert(command.name) - if let localPath { + if let localPath = localConfigPath { + if let localConfig = parseConfig(at: localPath) { + for command in localConfig.commands { + if !seenNames.contains(command.name) { + commands.append(command) + seenNames.insert(command.name) sourcePaths[command.id] = localPath } } @@ -2059,28 +367,7 @@ final class CmuxConfigStore: ObservableObject { } // Global config fills in the rest - if let globalConfig { - if configuredNewWorkspaceAction == nil, - configuredNewWorkspaceCommandName == nil, - let newWorkspaceActionID = globalConfig.ui?.newWorkspace?.action { - if let action = localActionLookup[newWorkspaceActionID] { - configuredNewWorkspaceAction = action.definition - configuredNewWorkspaceActionSourcePath = action.sourcePath - } else { - NSLog("[CmuxConfig] ui.newWorkspace.action '%@' does not match any local or global action", newWorkspaceActionID) - } - } - if configuredNewWorkspaceAction == nil, - configuredNewWorkspaceCommandName == nil, - let newWorkspaceCommand = globalConfig.newWorkspaceCommand { - configuredNewWorkspaceCommandName = newWorkspaceCommand - configuredNewWorkspaceCommandSourcePath = globalConfigPath - } - if configuredSurfaceTabBarButtons == nil, - let buttons = globalConfig.surfaceTabBarButtons { - configuredSurfaceTabBarButtons = buttons - configuredSurfaceTabBarButtonSourcePath = globalConfigPath - } + if let globalConfig = parseConfig(at: globalConfigPath) { for command in globalConfig.commands { if !seenNames.contains(command.name) { commands.append(command) @@ -2090,506 +377,27 @@ final class CmuxConfigStore: ObservableObject { } } - let resolvedActions = resolvedActionRegistry( - globalActions: globalActions, - localActions: localActions, - commands: commands, - commandSourcePaths: sourcePaths - ) - let resolvedActionLookup = Dictionary(uniqueKeysWithValues: resolvedActions.map { ($0.id, $0) }) - let configuredButtons = configuredSurfaceTabBarButtons ?? CmuxSurfaceTabBarButton.defaults - let defaultResolvedButtons = (try? CmuxSurfaceTabBarButton.defaults.map { - try $0.resolved(actions: resolvedActionLookup, codingPath: []) - }) ?? [ - .builtIn(.newTerminal), - .builtIn(.newBrowser), - .builtIn(.splitRight), - .builtIn(.splitDown) - ] - let resolvedButtons = resolvedSurfaceTabBarButtons( - configuredButtons, - actions: resolvedActionLookup, - settingName: "ui.surfaceTabBar.buttons" - ) ?? ResolvedSurfaceTabBarButtons( - buttons: defaultResolvedButtons, - terminalCommandSourcePaths: [:] - ) - let resolvedWorkspaceButtons = resolvedSurfaceTabBarWorkspaceCommands( - resolvedButtons.buttons, - commands: commands, - sourcePaths: sourcePaths - ) - let resolvedNewWorkspaceCommand = resolvedConfiguredNewWorkspaceCommand( - action: configuredNewWorkspaceAction, - actionSourcePath: configuredNewWorkspaceActionSourcePath, - commandName: configuredNewWorkspaceCommandName, - commandSourcePath: configuredNewWorkspaceCommandSourcePath, - commands: commands, - sourcePaths: sourcePaths - ) - loadedCommands = commands - loadedActions = resolvedActions commandSourcePaths = sourcePaths - actionLookup = resolvedActionLookup - newWorkspaceAction = configuredNewWorkspaceAction - newWorkspaceActionSourcePath = configuredNewWorkspaceActionSourcePath - newWorkspaceCommandName = configuredNewWorkspaceCommandName - surfaceTabBarButtonSourcePath = configuredSurfaceTabBarButtonSourcePath - surfaceTabBarCommandSourcePaths = resolvedButtons.terminalCommandSourcePaths - surfaceTabBarWorkspaceCommands = resolvedWorkspaceButtons.workspaceCommands - surfaceTabBarButtons = resolvedWorkspaceButtons.buttons - resolvedNewWorkspaceCommandCache = resolvedNewWorkspaceCommand.command - if let issue = resolvedNewWorkspaceCommand.issue { - issues.append(issue) - } - configurationIssues = issues - applySurfaceTabBarButtonsToCurrentManager() configRevision &+= 1 } - private func actionEntries( - from actions: [String: CmuxConfigActionDefinition], - sourcePath: String? - ) -> [String: ActionEntry] { - actions.mapValues { ActionEntry(definition: $0, sourcePath: sourcePath) } - } - - private func mergedActionEntries( - primary: [String: ActionEntry], - fallback: [String: ActionEntry] - ) -> [String: ActionEntry] { - fallback.merging(primary) { _, primary in primary } - } - - private func sanitizeConfigText(_ text: String) -> String { - let dangerous: Set = [ - "\u{200B}", "\u{200C}", "\u{200D}", "\u{200E}", "\u{200F}", - "\u{202A}", "\u{202B}", "\u{202C}", "\u{202D}", "\u{202E}", - "\u{2066}", "\u{2067}", "\u{2068}", "\u{2069}", - "\u{FEFF}", - ] - let filtered = String(text.unicodeScalars.filter { !dangerous.contains($0) }) - return filtered.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private func resolvedActionRegistry( - globalActions: [String: ActionEntry], - localActions: [String: ActionEntry], - commands: [CmuxCommandDefinition], - commandSourcePaths: [String: String] - ) -> [CmuxResolvedConfigAction] { - var registry = Dictionary( - uniqueKeysWithValues: CmuxSurfaceTabBarBuiltInAction.allCases.map { - ($0.configID, CmuxResolvedConfigAction.builtIn($0)) - } - ) - - func apply(_ entries: [String: ActionEntry]) { - for (id, entry) in entries { - if let existing = registry[id] { - guard let resolved = existing.applying(entry.definition, sourcePath: entry.sourcePath) else { continue } - registry[id] = resolved - } else if let resolved = CmuxResolvedConfigAction.fromDefinition( - id: id, - definition: entry.definition, - sourcePath: entry.sourcePath - ) { - registry[id] = resolved - } else { - NSLog("[CmuxConfig] action '%@' ignored because it does not define a runnable action", id) - } - } - } - - apply(globalActions) - apply(localActions) - - for command in commands where registry[command.id] == nil { - let sourcePath = commandSourcePaths[command.id] - registry[command.id] = CmuxResolvedConfigAction( - id: command.id, - title: String( - localized: "command.cmuxConfig.customTitle", - defaultValue: "Custom: \(sanitizeConfigText(command.name))" - ), - subtitle: command.description.map { sanitizeConfigText($0) } - ?? String(localized: "command.cmuxConfig.subtitle", defaultValue: "cmux.json"), - keywords: command.keywords ?? [], - palette: true, - shortcut: nil, - icon: .symbol(command.workspace == nil ? "terminal" : "rectangle.stack.badge.plus"), - tooltip: command.description, - action: command.workspace == nil - ? .command(command.command ?? "") - : .workspaceCommand(command.name), - confirm: command.confirm, - terminalCommandTarget: command.workspace == nil ? .currentTerminal : nil, - actionSourcePath: sourcePath, - iconSourcePath: nil - ) - } - - return registry.values.sorted { lhs, rhs in - lhs.id.localizedStandardCompare(rhs.id) == .orderedAscending - } - } - - private func resolvedSurfaceTabBarButtons( - _ buttons: [CmuxSurfaceTabBarButton], - actions: [String: CmuxResolvedConfigAction], - settingName: String - ) -> ResolvedSurfaceTabBarButtons? { - var resolvedButtons: [CmuxSurfaceTabBarButton] = [] - var terminalCommandSourcePaths: [String: String] = [:] - resolvedButtons.reserveCapacity(buttons.count) - - for button in buttons { - do { - let resolved = try resolvedSurfaceTabBarButton(button, actions: actions) - resolvedButtons.append(resolved.button) - guard resolved.button.terminalCommand != nil else { continue } - if let commandSourcePath = resolved.terminalCommandSourcePath { - terminalCommandSourcePaths[resolved.button.id] = commandSourcePath - } - } catch { - NSLog("[CmuxConfig] %@ ignored: %@", settingName, String(describing: error)) - return nil - } - } - - return ResolvedSurfaceTabBarButtons( - buttons: resolvedButtons, - terminalCommandSourcePaths: terminalCommandSourcePaths - ) - } - - private func resolvedSurfaceTabBarButton( - _ button: CmuxSurfaceTabBarButton, - actions: [String: CmuxResolvedConfigAction] - ) throws -> ResolvedSurfaceTabBarButtonEntry { - guard case .actionReference(let identifier) = button.action else { - return ResolvedSurfaceTabBarButtonEntry(button: button, terminalCommandSourcePath: nil) - } - - if let entry = actions[identifier] { - let resolvedButton = CmuxSurfaceTabBarButton( - id: button.id, - title: button.title ?? entry.title, - icon: button.icon ?? entry.icon, - tooltip: button.tooltip ?? entry.tooltip ?? entry.title, - action: entry.action, - confirm: button.confirm ?? entry.confirm, - terminalCommandTarget: button.terminalCommandTarget ?? entry.terminalCommandTarget, - actionSourcePath: entry.actionSourcePath, - iconSourcePath: button.icon == nil ? entry.iconSourcePath : button.iconSourcePath - ) - return ResolvedSurfaceTabBarButtonEntry( - button: resolvedButton, - terminalCommandSourcePath: resolvedButton.terminalCommand == nil ? nil : entry.actionSourcePath - ) - } - - if let builtIn = CmuxSurfaceTabBarBuiltInAction(configID: identifier) { - return ResolvedSurfaceTabBarButtonEntry( - button: CmuxSurfaceTabBarButton( - id: button.id, - title: button.title, - icon: button.icon, - tooltip: button.tooltip, - action: .builtIn(builtIn), - confirm: button.confirm, - terminalCommandTarget: button.terminalCommandTarget - ), - terminalCommandSourcePath: nil - ) - } - - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: [], - debugDescription: "Unknown action reference '\(identifier)'" - ) - ) - } - - private func applySurfaceTabBarButtonsToCurrentManager() { - tabManager?.applySurfaceTabBarButtons( - surfaceTabBarButtons, - sourcePath: surfaceTabBarButtonSourcePath, - globalConfigPath: globalConfigPath, - terminalCommandSourcePaths: surfaceTabBarCommandSourcePaths, - workspaceCommands: surfaceTabBarWorkspaceCommands - ) - } - - private func resolvedSurfaceTabBarWorkspaceCommands( - _ buttons: [CmuxSurfaceTabBarButton], - commands: [CmuxCommandDefinition], - sourcePaths: [String: String] - ) -> (buttons: [CmuxSurfaceTabBarButton], workspaceCommands: [String: CmuxResolvedCommand]) { - var visibleButtons: [CmuxSurfaceTabBarButton] = [] - var workspaceCommands: [String: CmuxResolvedCommand] = [:] - visibleButtons.reserveCapacity(buttons.count) - - for button in buttons { - guard let commandName = button.workspaceCommandName else { - visibleButtons.append(button) - continue - } - - guard let command = resolvedWorkspaceCommand( - named: commandName, - settingName: "surfaceTabBarButtons action", - commands: commands, - sourcePaths: sourcePaths - ) else { - NSLog( - "[CmuxConfig] surfaceTabBarButtons action '%@' hidden because workspace command '%@' is unavailable", - button.id, - commandName - ) - continue - } - - visibleButtons.append(button) - workspaceCommands[button.id] = command - } - - return (visibleButtons, workspaceCommands) - } - - func resolvedNewWorkspaceCommand() -> CmuxResolvedCommand? { - resolvedNewWorkspaceCommandCache - } - - func resolvedAction(id: String) -> CmuxResolvedConfigAction? { - actionLookup[id] - } - - func paletteCustomActions() -> [CmuxResolvedConfigAction] { - let builtInIDs = Set(CmuxSurfaceTabBarBuiltInAction.allCases.map(\.configID)) - return loadedActions.filter { action in - action.palette && !builtInIDs.contains(action.id) - } - } - - func shortcutActions() -> [CmuxResolvedConfigAction] { - let builtInIDs = Set(CmuxSurfaceTabBarBuiltInAction.allCases.map(\.configID)) - return loadedActions.filter { action in - action.shortcut != nil && (builtInIDs.contains(action.id) || action.actionSourcePath != nil) - }.sorted { lhs, rhs in - let lhsPriority = builtInIDs.contains(lhs.id) ? 0 : 1 - let rhsPriority = builtInIDs.contains(rhs.id) ? 0 : 1 - if lhsPriority != rhsPriority { - return lhsPriority < rhsPriority - } - return lhs.id.localizedStandardCompare(rhs.id) == .orderedAscending - } - } - - private func resolvedConfiguredNewWorkspaceCommand( - action: CmuxConfigActionDefinition?, - actionSourcePath: String?, - commandName: String?, - commandSourcePath: String?, - commands: [CmuxCommandDefinition], - sourcePaths: [String: String] - ) -> NewWorkspaceCommandResolution { - if let action { - guard let actionCommandName = action.action?.workspaceCommandName else { - return newWorkspaceResolutionIssue( - kind: .newWorkspaceActionRequiresWorkspaceCommand, - settingName: "ui.newWorkspace.action", - commandName: nil, - sourcePath: actionSourcePath - ) - } - return resolvedConfiguredNewWorkspaceCommand( - named: actionCommandName, - settingName: "ui.newWorkspace.action", - settingSourcePath: actionSourcePath, - commands: commands, - sourcePaths: sourcePaths - ) - } - - guard let commandName else { - return NewWorkspaceCommandResolution(command: nil, issue: nil) - } - return resolvedConfiguredNewWorkspaceCommand( - named: commandName, - settingName: "newWorkspaceCommand", - settingSourcePath: commandSourcePath, - commands: commands, - sourcePaths: sourcePaths - ) - } - - private func resolvedConfiguredNewWorkspaceCommand( - named commandName: String, - settingName: String, - settingSourcePath: String?, - commands: [CmuxCommandDefinition], - sourcePaths: [String: String] - ) -> NewWorkspaceCommandResolution { - guard let command = commands.first(where: { $0.name == commandName }) else { - return newWorkspaceResolutionIssue( - kind: .newWorkspaceCommandNotFound, - settingName: settingName, - commandName: commandName, - sourcePath: settingSourcePath - ) - } - guard command.workspace != nil else { - return newWorkspaceResolutionIssue( - kind: .newWorkspaceCommandRequiresWorkspace, - settingName: settingName, - commandName: commandName, - sourcePath: sourcePaths[command.id] ?? settingSourcePath - ) - } - return NewWorkspaceCommandResolution( - command: CmuxResolvedCommand(command: command, sourcePath: sourcePaths[command.id]), - issue: nil - ) - } - - private func newWorkspaceResolutionIssue( - kind: CmuxConfigIssue.Kind, - settingName: String, - commandName: String?, - sourcePath: String? - ) -> NewWorkspaceCommandResolution { - let issue = CmuxConfigIssue( - kind: kind, - settingName: settingName, - commandName: commandName, - sourcePath: sourcePath - ) - NSLog("[CmuxConfig] %@", issue.logMessage) - return NewWorkspaceCommandResolution(command: nil, issue: issue) - } - - private func resolvedWorkspaceCommand( - named commandName: String, - settingName: String - ) -> CmuxResolvedCommand? { - resolvedWorkspaceCommand( - named: commandName, - settingName: settingName, - commands: loadedCommands, - sourcePaths: commandSourcePaths - ) - } - - private func resolvedWorkspaceCommand( - named commandName: String, - settingName: String, - commands: [CmuxCommandDefinition], - sourcePaths: [String: String] - ) -> CmuxResolvedCommand? { - guard let command = commands.first(where: { $0.name == commandName }) else { - NSLog("[CmuxConfig] %@ '%@' does not match any loaded command", settingName, commandName) - return nil - } - guard command.workspace != nil else { - NSLog("[CmuxConfig] %@ '%@' must reference a workspace command", settingName, commandName) - return nil - } - return CmuxResolvedCommand(command: command, sourcePath: sourcePaths[command.id]) - } - // MARK: - Parsing - private func parseConfig(at path: String) -> ParsedConfigResult { - let fileManager = FileManager.default - guard fileManager.fileExists(atPath: path) else { - parsedConfigCache.removeValue(forKey: path) - return ParsedConfigResult(config: nil, issue: nil) - } - - let attributes = try? fileManager.attributesOfItem(atPath: path) - let fileSize = (attributes?[.size] as? NSNumber)?.uint64Value ?? 0 - let modificationDate = attributes?[.modificationDate] as? Date - - if let cached = parsedConfigCache[path], - cached.fileSize == fileSize, - cached.modificationDate == modificationDate { - return ParsedConfigResult(config: cached.config, issue: cached.issue) - } - - guard let data = fileManager.contents(atPath: path), + private func parseConfig(at path: String) -> CmuxConfigFile? { + guard FileManager.default.fileExists(atPath: path), + let data = FileManager.default.contents(atPath: path), !data.isEmpty else { - let issue = schemaIssue(path: path, message: "cmux.json is empty") - parsedConfigCache[path] = ParsedConfigCacheEntry( - fileSize: fileSize, - modificationDate: modificationDate, - config: nil, - issue: issue - ) - return ParsedConfigResult(config: nil, issue: issue) + return nil } do { - let config = try JSONDecoder().decode(CmuxConfigFile.self, from: data) - parsedConfigCache[path] = ParsedConfigCacheEntry( - fileSize: fileSize, - modificationDate: modificationDate, - config: config, - issue: nil - ) - return ParsedConfigResult(config: config, issue: nil) + return try JSONDecoder().decode(CmuxConfigFile.self, from: data) } catch { - let issue = schemaIssue(path: path, message: schemaErrorMessage(error)) - parsedConfigCache[path] = ParsedConfigCacheEntry( - fileSize: fileSize, - modificationDate: modificationDate, - config: nil, - issue: issue - ) NSLog("[CmuxConfig] parse error at %@: %@", path, String(describing: error)) - return ParsedConfigResult(config: nil, issue: issue) - } - } - - private func schemaIssue(path: String, message: String) -> CmuxConfigIssue { - CmuxConfigIssue( - kind: .schemaError, - settingName: (path as NSString).lastPathComponent, - sourcePath: path, - message: message - ) - } - - private func schemaErrorMessage(_ error: Error) -> String { - switch error { - case DecodingError.typeMismatch(_, let context): - return schemaErrorMessage(context) - case DecodingError.valueNotFound(_, let context): - return schemaErrorMessage(context) - case DecodingError.keyNotFound(let key, let context): - let path = schemaCodingPath(context.codingPath + [key]) - let detail = sanitizeConfigText(context.debugDescription) - return "\(path): \(detail)" - case DecodingError.dataCorrupted(let context): - return schemaErrorMessage(context) - default: - let message = sanitizeConfigText(error.localizedDescription) - return message.isEmpty ? String(describing: error) : message + return nil } } - private func schemaErrorMessage(_ context: DecodingError.Context) -> String { - let path = schemaCodingPath(context.codingPath) - let detail = sanitizeConfigText(context.debugDescription) - return detail.isEmpty ? path : "\(path): \(detail)" - } - - private func schemaCodingPath(_ codingPath: [CodingKey]) -> String { - let path = codingPath.map(\.stringValue).filter { !$0.isEmpty }.joined(separator: ".") - return path.isEmpty ? "root" : path - } - // MARK: - File watching (local) private func startLocalFileWatcher() { @@ -2634,72 +442,35 @@ final class CmuxConfigStore: ObservableObject { private func startLocalDirectoryWatcher() { guard let path = localConfigPath else { return } - let configDirectory = (path as NSString).deletingLastPathComponent - let fs = FileManager.default - let dirPath: String - if fs.fileExists(atPath: configDirectory) { - dirPath = configDirectory - } else if let searchDirectory = localConfigSearchDirectory, - fs.fileExists(atPath: searchDirectory) { - dirPath = searchDirectory - } else { - dirPath = (configDirectory as NSString).deletingLastPathComponent - } - let eventHandler = { [weak self] in - DispatchQueue.main.async { - self?.handleLocalDirectoryWatchEvent() - } - } - - guard let primaryWatch = startLocalDirectoryWatchSource(at: dirPath, eventHandler: eventHandler) else { - return - } - localFileWatchSource = primaryWatch.source - localFileDescriptor = primaryWatch.fileDescriptor - - if let searchDirectory = localConfigSearchDirectory, - fs.fileExists(atPath: configDirectory), - searchDirectory != dirPath, - let fallbackWatch = startLocalDirectoryWatchSource(at: searchDirectory, eventHandler: eventHandler) { - localFallbackDirectoryWatchSource = fallbackWatch.source - localFallbackDirectoryDescriptor = fallbackWatch.fileDescriptor - } - } - - private func startLocalDirectoryWatchSource( - at dirPath: String, - eventHandler: @escaping () -> Void - ) -> (source: DispatchSourceFileSystemObject, fileDescriptor: Int32)? { + let dirPath = (path as NSString).deletingLastPathComponent let fd = open(dirPath, O_EVTONLY) - guard fd >= 0 else { return nil } + guard fd >= 0 else { return } + localFileDescriptor = fd + let source = DispatchSource.makeFileSystemObjectSource( fileDescriptor: fd, eventMask: [.write, .link, .rename], queue: watchQueue ) - source.setEventHandler(handler: eventHandler) + + source.setEventHandler { [weak self] in + guard let self else { return } + DispatchQueue.main.async { + guard let configPath = self.localConfigPath, + FileManager.default.fileExists(atPath: configPath) else { return } + // File appeared — switch to file-level watching + self.stopLocalFileWatcher() + self.loadAll() + self.startLocalFileWatcher() + } + } + source.setCancelHandler { Darwin.close(fd) } - source.resume() - return (source, fd) - } - private func handleLocalDirectoryWatchEvent() { - if let searchDirectory = localConfigSearchDirectory { - let resolvedPath = resolvedLocalConfigPath(startingFrom: searchDirectory) - if resolvedPath != localConfigPath { - localConfigPath = resolvedPath - } - } - guard let configPath = localConfigPath else { return } - let configDirectory = (configPath as NSString).deletingLastPathComponent - guard FileManager.default.fileExists(atPath: configPath) || - FileManager.default.fileExists(atPath: configDirectory) else { return } - // File or its parent directory appeared — switch to file-level watching. - stopLocalFileWatcher() - loadAll() - startLocalFileWatcher() + source.resume() + localFileWatchSource = source } private func scheduleLocalReattach(attempt: Int) { @@ -2723,12 +494,7 @@ final class CmuxConfigStore: ObservableObject { source.cancel() localFileWatchSource = nil } - if let source = localFallbackDirectoryWatchSource { - source.cancel() - localFallbackDirectoryWatchSource = nil - } localFileDescriptor = -1 - localFallbackDirectoryDescriptor = -1 } // MARK: - File watching (global) diff --git a/Sources/CmuxConfigExecutor.swift b/Sources/CmuxConfigExecutor.swift index 6940ecc7f11e..df7276155c2f 100644 --- a/Sources/CmuxConfigExecutor.swift +++ b/Sources/CmuxConfigExecutor.swift @@ -4,410 +4,70 @@ import Foundation @MainActor struct CmuxConfigExecutor { - @discardableResult static func execute( command: CmuxCommandDefinition, tabManager: TabManager, baseCwd: String, configSourcePath: String?, - globalConfigPath: String, - displayTitle: String? = nil, - actionID: String? = nil, - icon: CmuxButtonIcon? = nil, - iconSourcePath: String? = nil, - presentingWindow: NSWindow? = nil, - onExecuted: (() -> Void)? = nil - ) -> Bool { + globalConfigPath: String + ) { if let workspace = command.workspace { - return authorizeProjectActionIfNeeded( - descriptor: workspaceTrustDescriptor( - command: command, - actionID: actionID ?? command.id, - configSourcePath: configSourcePath, - icon: icon, - iconSourcePath: iconSourcePath, + executeWorkspaceCommand(command: command, workspace: workspace, tabManager: tabManager, baseCwd: baseCwd) + } else if let rawCommand = command.command { + let shellCommand = sanitizeForDisplay(rawCommand) + let needsConfirm = command.confirm ?? false + if needsConfirm, let sourcePath = configSourcePath { + let trusted = CmuxDirectoryTrust.shared.isTrusted( + configPath: sourcePath, globalConfigPath: globalConfigPath - ), - confirm: command.confirm ?? false, - configSourcePath: configSourcePath, - globalConfigPath: globalConfigPath, - displayCommand: command.name, - displayTitle: displayTitle ?? command.name, - presentingWindow: presentingWindow - ) { - executeWorkspaceCommand( - command: command, - workspace: workspace, - tabManager: tabManager, - baseCwd: baseCwd ) - onExecuted?() - } - } else if let rawCommand = command.command { - let targetTerminal = tabManager.selectedWorkspace?.focusedTerminalPanel - guard let targetTerminal else { return false } - return prepareShellInputIfAuthorized( - rawCommand, - confirm: command.confirm ?? false, - actionID: actionID ?? command.id, - target: .currentTerminal, - configSourcePath: configSourcePath, - globalConfigPath: globalConfigPath, - displayTitle: displayTitle ?? command.name, - icon: icon, - iconSourcePath: iconSourcePath, - presentingWindow: presentingWindow - ) { shellInput in - targetTerminal.sendInput(shellInput) - onExecuted?() - } - } - return false - } - - @discardableResult - static func execute( - action: CmuxResolvedConfigAction, - commands: [CmuxCommandDefinition], - commandSourcePaths: [String: String], - tabManager: TabManager, - baseCwd: String, - globalConfigPath: String, - presentingWindow: NSWindow? = nil, - onExecuted: (() -> Void)? = nil - ) -> Bool { - if let commandName = action.workspaceCommandName, - let command = commands.first(where: { $0.name == commandName }) { - guard command.workspace != nil else { return false } - return execute( - command: command, - tabManager: tabManager, - baseCwd: baseCwd, - configSourcePath: commandSourcePaths[command.id] ?? action.actionSourcePath, - globalConfigPath: globalConfigPath, - displayTitle: action.title, - actionID: action.id, - icon: action.icon, - iconSourcePath: action.iconSourcePath, - presentingWindow: presentingWindow, - onExecuted: onExecuted - ) - } - - guard let command = action.terminalCommand else { return false } - let target = action.terminalCommandTarget ?? .newTabInCurrentPane - let targetTerminal = (target == .currentTerminal) ? tabManager.selectedWorkspace?.focusedTerminalPanel : nil - let targetWorkspace = (target == .newTabInCurrentPane) ? tabManager.selectedWorkspace : nil - return prepareShellInputIfAuthorized( - command, - confirm: action.confirm ?? false, - actionID: action.id, - target: target, - configSourcePath: action.actionSourcePath, - globalConfigPath: globalConfigPath, - displayTitle: action.title, - icon: action.icon, - iconSourcePath: action.iconSourcePath, - presentingWindow: presentingWindow - ) { shellInput in - switch target { - case .currentTerminal: - targetTerminal?.sendInput(shellInput) - case .newTabInCurrentPane: - targetWorkspace?.clearSplitZoom() - targetWorkspace?.newTerminalSurfaceInFocusedPane(focus: true, initialInput: shellInput) - } - onExecuted?() - } - } - - @discardableResult - static func prepareShellInputIfAuthorized( - _ rawCommand: String, - confirm: Bool, - actionID: String, - target: CmuxConfigTerminalCommandTarget, - configSourcePath: String?, - globalConfigPath: String, - displayTitle: String? = nil, - icon: CmuxButtonIcon? = nil, - iconSourcePath: String? = nil, - presentingWindow: NSWindow? = nil, - onAuthorized: @escaping (String) -> Void - ) -> Bool { - let shellCommand = sanitizeForDisplay(rawCommand) - guard !shellCommand.isEmpty else { return false } - - let descriptor = terminalTrustDescriptor( - command: shellCommand, - actionID: actionID, - target: target, - configSourcePath: configSourcePath, - icon: icon, - iconSourcePath: iconSourcePath, - globalConfigPath: globalConfigPath - ) - return authorizeProjectActionIfNeeded( - descriptor: descriptor, - confirm: confirm, - configSourcePath: configSourcePath, - globalConfigPath: globalConfigPath, - displayCommand: shellCommand, - displayTitle: displayTitle, - presentingWindow: presentingWindow - ) { - onAuthorized(shellCommand + "\n") - } - } - - @discardableResult - private static func authorizeProjectActionIfNeeded( - descriptor: CmuxActionTrustDescriptor, - confirm: Bool, - configSourcePath: String?, - globalConfigPath: String, - displayCommand: String, - displayTitle: String?, - presentingWindow: NSWindow?, - onAuthorized: @escaping () -> Void - ) -> Bool { - let sourcePath = configSourcePath.map(canonicalPath) - let canonicalGlobalConfigPath = canonicalPath(globalConfigPath) - let isTrusted = CmuxActionTrust.shared.isTrusted(descriptor) - let resolvedPresentingWindow = presentingWindow ?? NSApp.keyWindow ?? NSApp.mainWindow - guard let sourcePath, - sourcePath != canonicalGlobalConfigPath else { - onAuthorized() - return true - } - if !confirm, isTrusted { - onAuthorized() - return true - } - if let resolvedPresentingWindow { - presentConfirmDialog( - command: displayCommand, - displayTitle: displayTitle, - descriptor: descriptor, - configPath: sourcePath, - presentingWindow: resolvedPresentingWindow - ) { allowed in - if allowed { - onAuthorized() + if !trusted { + guard showConfirmDialog(command: shellCommand, configPath: sourcePath) else { return } } } - return true - } - let allowed = runConfirmDialog( - command: displayCommand, - displayTitle: displayTitle, - descriptor: descriptor, - configPath: sourcePath - ) - if allowed { - onAuthorized() - } - return allowed - } - - private static func presentConfirmDialog( - command: String, - displayTitle: String?, - descriptor: CmuxActionTrustDescriptor, - configPath: String, - presentingWindow: NSWindow, - completion: @escaping (Bool) -> Void - ) { - let alert = makeConfirmDialog( - command: command, - displayTitle: displayTitle, - configPath: configPath - ) - alert.beginSheetModal(for: presentingWindow) { response in - completion(handleConfirmDialogResponse(response, descriptor: descriptor)) + guard let terminal = tabManager.selectedWorkspace?.focusedTerminalPanel else { return } + terminal.sendInput(shellCommand + "\n") } } - private static func runConfirmDialog( - command: String, - displayTitle: String?, - descriptor: CmuxActionTrustDescriptor, - configPath: String - ) -> Bool { - let alert = makeConfirmDialog( - command: command, - displayTitle: displayTitle, - configPath: configPath - ) - return handleConfirmDialogResponse(alert.runModal(), descriptor: descriptor) - } - - private static func makeConfirmDialog( - command: String, - displayTitle: String?, - configPath: String - ) -> NSAlert { + /// Show a confirmation dialog with the command text and a "trust this directory" checkbox. + /// Returns true if the user chose to run, false if cancelled. + private static func showConfirmDialog(command: String, configPath: String) -> Bool { let alert = NSAlert() - let trimmedDisplayTitle = displayTitle?.trimmingCharacters(in: .whitespacesAndNewlines) - alert.messageText = (trimmedDisplayTitle?.isEmpty == false) - ? trimmedDisplayTitle! - : String( - localized: "dialog.cmuxConfig.confirmCommand.title", - defaultValue: "Run Project Action?" - ) + alert.messageText = String( + localized: "dialog.cmuxConfig.confirmCommand.title", + defaultValue: "Run Command" + ) let messageFormat = String( localized: "dialog.cmuxConfig.confirmCommand.messageWithCommand", - defaultValue: "This project action comes from:\n\n%@\n\nIt will run:\n\n%@" - ) - alert.informativeText = String( - format: messageFormat, - sanitizeForDisplay(configPath), - sanitizeForDisplay(command) + defaultValue: "This will run the following command:\n\n%@" ) + alert.informativeText = String(format: messageFormat, sanitizeForDisplay(command)) alert.alertStyle = .warning alert.addButton(withTitle: String( localized: "dialog.cmuxConfig.confirmCommand.run", - defaultValue: "Run Once" - )) - alert.addButton(withTitle: String( - localized: "dialog.cmuxConfig.confirmCommand.trustAndRun", - defaultValue: "Trust and Run" + defaultValue: "Run" )) alert.addButton(withTitle: String( localized: "dialog.cmuxConfig.confirmCommand.cancel", defaultValue: "Cancel" )) - return alert - } - private static func handleConfirmDialogResponse( - _ response: NSApplication.ModalResponse, - descriptor: CmuxActionTrustDescriptor - ) -> Bool { - switch response { - case .alertFirstButtonReturn: - return true - case .alertSecondButtonReturn: - CmuxActionTrust.shared.trust(descriptor) - return true - default: - return false - } - } + let checkbox = NSButton(checkboxWithTitle: String( + localized: "dialog.cmuxConfig.confirmCommand.trustDirectory", + defaultValue: "Always trust commands from this folder" + ), target: nil, action: nil) + checkbox.state = .off + alert.accessoryView = checkbox - private static func terminalTrustDescriptor( - command: String, - actionID: String, - target: CmuxConfigTerminalCommandTarget, - configSourcePath: String?, - icon: CmuxButtonIcon?, - iconSourcePath: String?, - globalConfigPath: String - ) -> CmuxActionTrustDescriptor { - CmuxActionTrustDescriptor( - actionID: actionID, - kind: "terminalCommand", - command: command, - target: target.rawValue, - workspaceCommand: nil, - configPath: configSourcePath.map(canonicalPath), - projectRoot: configSourcePath.map { canonicalPath(CmuxButtonIcon.projectRoot(forConfigPath: $0)) }, - iconFingerprint: icon?.projectLocalImageFingerprint( - configSourcePath: iconSourcePath ?? configSourcePath, - globalConfigPath: globalConfigPath - ) - ) - } + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { return false } - private static func workspaceTrustDescriptor( - command: CmuxCommandDefinition, - actionID: String, - configSourcePath: String?, - icon: CmuxButtonIcon?, - iconSourcePath: String?, - globalConfigPath: String - ) -> CmuxActionTrustDescriptor { - CmuxActionTrustDescriptor( - actionID: actionID, - kind: "workspaceCommand", - command: nil, - target: nil, - workspaceCommand: command, - configPath: configSourcePath.map(canonicalPath), - projectRoot: configSourcePath.map { canonicalPath(CmuxButtonIcon.projectRoot(forConfigPath: $0)) }, - iconFingerprint: icon?.projectLocalImageFingerprint( - configSourcePath: iconSourcePath ?? configSourcePath, - globalConfigPath: globalConfigPath - ) - ) - } - - static func isTrustedSurfaceButton( - _ button: CmuxSurfaceTabBarButton, - workspaceCommand: CmuxResolvedCommand?, - terminalCommandSourcePath: String?, - surfaceTabBarConfigSourcePath: String?, - globalConfigPath: String - ) -> Bool { - guard let descriptor = surfaceButtonTrustDescriptor( - button, - workspaceCommand: workspaceCommand, - terminalCommandSourcePath: terminalCommandSourcePath, - surfaceTabBarConfigSourcePath: surfaceTabBarConfigSourcePath, - globalConfigPath: globalConfigPath - ) else { - return true - } - guard let configPath = descriptor.configPath, - configPath != canonicalPath(globalConfigPath) else { - return true - } - return CmuxActionTrust.shared.isTrusted(descriptor) - } - - private static func surfaceButtonTrustDescriptor( - _ button: CmuxSurfaceTabBarButton, - workspaceCommand: CmuxResolvedCommand?, - terminalCommandSourcePath: String?, - surfaceTabBarConfigSourcePath: String?, - globalConfigPath: String - ) -> CmuxActionTrustDescriptor? { - let configSourcePath = terminalCommandSourcePath - ?? workspaceCommand?.sourcePath - ?? button.actionSourcePath - ?? surfaceTabBarConfigSourcePath - let iconSourcePath = button.iconSourcePath - ?? (button.icon == nil ? nil : surfaceTabBarConfigSourcePath) - let resolvedIcon = button.icon ?? button.action.defaultButtonIcon - - if let workspaceCommand { - return workspaceTrustDescriptor( - command: workspaceCommand.command, - actionID: button.id, - configSourcePath: configSourcePath, - icon: resolvedIcon, - iconSourcePath: iconSourcePath, - globalConfigPath: globalConfigPath - ) - } - - guard let terminalCommand = button.terminalCommand else { - return nil + if checkbox.state == .on { + CmuxDirectoryTrust.shared.trust(configPath: configPath) } - return terminalTrustDescriptor( - command: sanitizeForDisplay(terminalCommand), - actionID: button.id, - target: button.resolvedTerminalCommandTarget, - configSourcePath: configSourcePath, - icon: resolvedIcon, - iconSourcePath: iconSourcePath, - globalConfigPath: globalConfigPath - ) - } - - private static func canonicalPath(_ path: String) -> String { - URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL.path + return true } private static func sanitizeForDisplay(_ text: String) -> String { diff --git a/Sources/CmuxDirectoryTrust.swift b/Sources/CmuxDirectoryTrust.swift new file mode 100644 index 000000000000..4c0190fb5130 --- /dev/null +++ b/Sources/CmuxDirectoryTrust.swift @@ -0,0 +1,114 @@ +import Foundation + +/// Manages trusted directories for cmux.json command execution. +/// When a directory (or its git repo root) is trusted, `confirm: true` commands +/// from that directory's cmux.json skip the confirmation dialog. +/// Global config (~/.config/cmux/cmux.json) is always trusted. +final class CmuxDirectoryTrust { + static let shared = CmuxDirectoryTrust() + static let didChangeNotification = Notification.Name("cmux.directoryTrustDidChange") + + private let storePath: String + private var trustedPaths: Set + + private init() { + let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, in: .userDomainMask + ).first!.appendingPathComponent("cmux") + storePath = appSupport.appendingPathComponent("trusted-directories.json").path + + let fm = FileManager.default + if !fm.fileExists(atPath: appSupport.path) { + try? fm.createDirectory(atPath: appSupport.path, withIntermediateDirectories: true) + } + + if let data = fm.contents(atPath: storePath), + let paths = try? JSONDecoder().decode([String].self, from: data) { + trustedPaths = Set(paths) + } else { + trustedPaths = [] + } + } + + /// Check if a cmux.json path is trusted. + /// Global config is always trusted. For local configs, check the git repo root + /// (or the cmux.json parent directory if not in a git repo). + func isTrusted(configPath: String, globalConfigPath: String) -> Bool { + if configPath == globalConfigPath { return true } + let trustKey = Self.trustKey(for: configPath) + return trustedPaths.contains(trustKey) + } + + /// Trust the directory containing a cmux.json. If the cmux.json is inside a git + /// repo, trusts the repo root (covering all subdirectories). + func trust(configPath: String) { + let trustKey = Self.trustKey(for: configPath) + trustedPaths.insert(trustKey) + save() + } + + /// Remove trust for a directory. + func revokeTrust(configPath: String) { + let trustKey = Self.trustKey(for: configPath) + trustedPaths.remove(trustKey) + save() + } + + /// Remove trust by the trust key directly (as stored/displayed in settings). + func revokeTrustByPath(_ path: String) { + trustedPaths.remove(path) + save() + } + + /// All currently trusted paths. + var allTrustedPaths: [String] { + Array(trustedPaths).sorted() + } + + /// Replace all trusted paths (used by Settings textarea save). + func replaceAll(with paths: [String]) { + trustedPaths = Set(paths) + save() + } + + /// Clear all trusted directories. + func clearAll() { + trustedPaths.removeAll() + save() + } + + // MARK: - Private + + /// Resolve the trust key for a cmux.json path: git repo root if inside a repo, + /// otherwise the cmux.json's parent directory. + static func trustKey(for configPath: String) -> String { + let configDir = (configPath as NSString).deletingLastPathComponent + if let gitRoot = findGitRoot(from: configDir) { + return gitRoot + } + return configDir + } + + /// Walk up from `directory` looking for a `.git` directory or file. + private static func findGitRoot(from directory: String) -> String? { + let fm = FileManager.default + var current = directory + while true { + let gitPath = (current as NSString).appendingPathComponent(".git") + if fm.fileExists(atPath: gitPath) { + return current + } + let parent = (current as NSString).deletingLastPathComponent + if parent == current { break } + current = parent + } + return nil + } + + private func save() { + let sorted = trustedPaths.sorted() + guard let data = try? JSONEncoder().encode(sorted) else { return } + FileManager.default.createFile(atPath: storePath, contents: data) + NotificationCenter.default.post(name: Self.didChangeNotification, object: nil) + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 5f3b1ddedc28..529948e0bf91 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1,5 +1,4 @@ import AppKit -import Bonsplit import Combine import ImageIO import SwiftUI @@ -7,15 +6,43 @@ import ObjectiveC import UniformTypeIdentifiers import WebKit +func sidebarSelectedWorkspaceBackgroundNSColor(for colorScheme: ColorScheme) -> NSColor { + if let hex = UserDefaults.standard.string(forKey: "sidebarSelectionColorHex"), + let parsed = NSColor(hex: hex) { + return parsed + } + return cmuxAccentNSColor(for: colorScheme) +} + +/// CALayer-backed titlebar background. Uses layer-level opacity (not per-pixel alpha) +/// to match how the terminal's Metal surface composites its background. +struct TitlebarLayerBackground: NSViewRepresentable { + var backgroundColor: NSColor + var opacity: CGFloat + + func makeNSView(context: Context) -> NSView { + let view = NSView() + view.wantsLayer = true + view.layer?.backgroundColor = backgroundColor.withAlphaComponent(1.0).cgColor + view.layer?.opacity = Float(opacity) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + nsView.layer?.backgroundColor = backgroundColor.withAlphaComponent(1.0).cgColor + nsView.layer?.opacity = Float(opacity) + } +} + // MARK: - File Drop Overlay enum DragOverlayRoutingPolicy { - static let bonsplitTabTransferType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") + static let splitTabTransferType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") static let sidebarTabReorderType = NSPasteboard.PasteboardType(SidebarTabDragPayload.typeIdentifier) - static func hasBonsplitTabTransfer(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { + static func hasSplitTabTransfer(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { guard let pasteboardTypes else { return false } - return pasteboardTypes.contains(bonsplitTabTransferType) + return pasteboardTypes.contains(splitTabTransferType) } static func hasSidebarTabReorder(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { @@ -34,13 +61,13 @@ enum DragOverlayRoutingPolicy { ) -> Bool { // Local file drags (e.g. in-app draggable folder views) are valid drop // inputs; rely on explicit non-file drag types below to avoid hijacking - // Bonsplit/sidebar drags. + // WorkspaceSplit/sidebar drags. _ = hasLocalDraggingSource guard hasFileURL(pasteboardTypes) else { return false } // Prefer explicit non-file drag types so stale fileURL entries cannot hijack - // Bonsplit tab drags or sidebar tab reorder drags. - if hasBonsplitTabTransfer(pasteboardTypes) { return false } + // WorkspaceSplit tab drags or sidebar tab reorder drags. + if hasSplitTabTransfer(pasteboardTypes) { return false } if hasSidebarTabReorder(pasteboardTypes) { return false } return true } @@ -86,7 +113,7 @@ enum DragOverlayRoutingPolicy { eventType: NSEvent.EventType? ) -> Bool { guard isPortalDragEvent(eventType) else { return false } - return hasBonsplitTabTransfer(pasteboardTypes) || hasSidebarTabReorder(pasteboardTypes) + return hasSplitTabTransfer(pasteboardTypes) || hasSidebarTabReorder(pasteboardTypes) } private static func isDragMouseEvent(_ eventType: NSEvent.EventType?) -> Bool { @@ -109,7 +136,7 @@ enum DragOverlayRoutingPolicy { } /// Transparent NSView installed on the window's theme frame (above the NSHostingView) to -/// handle file/URL drags from Finder. Nested NSHostingController layers (created by bonsplit's +/// handle file/URL drags from Finder. Nested NSHostingController layers (created by WorkspaceSplit's /// SinglePaneWrapper) prevent AppKit's NSDraggingDestination routing from reaching deeply /// embedded terminal views. This overlay sits above the entire content view hierarchy and /// intercepts file drags, forwarding drops to the GhosttyNSView under the cursor. @@ -178,7 +205,7 @@ final class FileDropOverlayView: NSView { } // MARK: Hit-testing — participation is routed by DragOverlayRoutingPolicy so - // file-drop, bonsplit tab drags, and sidebar tab reorder drags cannot conflict. + // file-drop, WorkspaceSplit tab drags, and sidebar tab reorder drags cannot conflict. override func hitTest(_ point: NSPoint) -> NSView? { let pb = NSPasteboard(name: .drag) @@ -475,7 +502,7 @@ final class FileDropOverlayView: NSView { let interestingTypes = types.filter { type in let raw = type.rawValue return raw == NSPasteboard.PasteboardType.fileURL.rawValue - || raw == DragOverlayRoutingPolicy.bonsplitTabTransferType.rawValue + || raw == DragOverlayRoutingPolicy.splitTabTransferType.rawValue || raw == DragOverlayRoutingPolicy.sidebarTabReorderType.rawValue || raw.contains("public.text") || raw.contains("public.url") @@ -492,7 +519,7 @@ final class FileDropOverlayView: NSView { private func hasRelevantDragTypes(_ types: [NSPasteboard.PasteboardType]?) -> Bool { guard let types else { return false } return types.contains(.fileURL) - || types.contains(DragOverlayRoutingPolicy.bonsplitTabTransferType) + || types.contains(DragOverlayRoutingPolicy.splitTabTransferType) || types.contains(DragOverlayRoutingPolicy.sidebarTabReorderType) } @@ -536,7 +563,7 @@ final class FileDropOverlayView: NSView { let signature = "\(shouldCapture ? 1 : 0)|\(debugEventName(eventType))|\(debugPasteboardTypes(pasteboardTypes))" guard lastHitTestLogSignature != signature else { return } lastHitTestLogSignature = signature - cmuxDebugLog( + dlog( "overlay.fileDrop.hitTest capture=\(shouldCapture ? 1 : 0) " + "event=\(debugEventName(eventType)) " + "topHit=\(debugTopHitViewForCurrentEvent()) " + @@ -560,7 +587,7 @@ final class FileDropOverlayView: NSView { ].joined(separator: "|") guard lastDragRouteLogSignatureByPhase[phase] != signature else { return } lastDragRouteLogSignatureByPhase[phase] = signature - cmuxDebugLog( + dlog( "overlay.fileDrop.\(phase) capture=\(shouldCapture ? 1 : 0) " + "localSource=\(hasLocalDraggingSource ? 1 : 0) " + "hasTerminal=\(hasTerminalTarget ? 1 : 0) " + @@ -868,7 +895,7 @@ private final class WindowCommandPaletteOverlayController: NSObject { private func focusPaletteTextInput(in window: NSWindow) -> Bool { guard let input = firstEditableTextInput(in: hostingView) else { #if DEBUG - cmuxDebugLog( + dlog( "palette.focus.direct missingInput window={\(debugCommandPaletteWindowSummary(window))} " + "fr=\(debugCommandPaletteResponderSummary(window.firstResponder))" ) @@ -876,7 +903,7 @@ private final class WindowCommandPaletteOverlayController: NSObject { return false } #if DEBUG - cmuxDebugLog( + dlog( "palette.focus.direct attempt window={\(debugCommandPaletteWindowSummary(window))} " + "input=\(debugCommandPaletteResponderSummary(input)) " + "frBefore=\(debugCommandPaletteResponderSummary(window.firstResponder))" @@ -884,7 +911,7 @@ private final class WindowCommandPaletteOverlayController: NSObject { #endif guard window.makeFirstResponder(input) else { #if DEBUG - cmuxDebugLog( + dlog( "palette.focus.direct failedMakeFirstResponder window={\(debugCommandPaletteWindowSummary(window))} " + "input=\(debugCommandPaletteResponderSummary(input)) " + "frAfter=\(debugCommandPaletteResponderSummary(window.firstResponder))" @@ -902,7 +929,7 @@ private final class WindowCommandPaletteOverlayController: NSObject { let didSettle = isPaletteTextInputFirstResponder(window.firstResponder) #if DEBUG - cmuxDebugLog( + dlog( "palette.focus.direct settled window={\(debugCommandPaletteWindowSummary(window))} " + "didSettle=\(didSettle ? 1 : 0) frAfter=\(debugCommandPaletteResponderSummary(window.firstResponder))" ) @@ -913,13 +940,13 @@ private final class WindowCommandPaletteOverlayController: NSObject { private func scheduleFocusIntoPalette(retries: Int = 4) { #if DEBUG if let window { - cmuxDebugLog( + dlog( "palette.focus.schedule retries=\(retries) " + "window={\(debugCommandPaletteWindowSummary(window))} " + "fr=\(debugCommandPaletteResponderSummary(window.firstResponder))" ) } else { - cmuxDebugLog("palette.focus.schedule retries=\(retries) window=nil") + dlog("palette.focus.schedule retries=\(retries) window=nil") } #endif scheduledFocusWorkItem?.cancel() @@ -934,7 +961,7 @@ private final class WindowCommandPaletteOverlayController: NSObject { private func focusIntoPalette(retries: Int) { guard let window else { return } #if DEBUG - cmuxDebugLog( + dlog( "palette.focus.retry start retries=\(retries) " + "window={\(debugCommandPaletteWindowSummary(window))} " + "fr=\(debugCommandPaletteResponderSummary(window.firstResponder))" @@ -942,7 +969,7 @@ private final class WindowCommandPaletteOverlayController: NSObject { #endif if isPaletteTextInputFirstResponder(window.firstResponder) { #if DEBUG - cmuxDebugLog( + dlog( "palette.focus.retry alreadyFocused window={\(debugCommandPaletteWindowSummary(window))} " + "fr=\(debugCommandPaletteResponderSummary(window.firstResponder))" ) @@ -952,7 +979,7 @@ private final class WindowCommandPaletteOverlayController: NSObject { if focusPaletteTextInput(in: window) { #if DEBUG - cmuxDebugLog( + dlog( "palette.focus.retry directSuccess retries=\(retries) " + "window={\(debugCommandPaletteWindowSummary(window))}" ) @@ -962,7 +989,7 @@ private final class WindowCommandPaletteOverlayController: NSObject { let containerFocused = window.makeFirstResponder(containerView) #if DEBUG - cmuxDebugLog( + dlog( "palette.focus.retry containerResult retries=\(retries) " + "window={\(debugCommandPaletteWindowSummary(window))} " + "didFocusContainer=\(containerFocused ? 1 : 0) " + @@ -972,7 +999,7 @@ private final class WindowCommandPaletteOverlayController: NSObject { if containerFocused { if focusPaletteTextInput(in: window) { #if DEBUG - cmuxDebugLog( + dlog( "palette.focus.retry containerAssistedSuccess retries=\(retries) " + "window={\(debugCommandPaletteWindowSummary(window))}" ) @@ -983,7 +1010,7 @@ private final class WindowCommandPaletteOverlayController: NSObject { guard retries > 0 else { #if DEBUG - cmuxDebugLog( + dlog( "palette.focus.retry exhausted window={\(debugCommandPaletteWindowSummary(window))} " + "fr=\(debugCommandPaletteResponderSummary(window.firstResponder))" ) @@ -991,7 +1018,7 @@ private final class WindowCommandPaletteOverlayController: NSObject { return } #if DEBUG - cmuxDebugLog( + dlog( "palette.focus.retry reschedule nextRetries=\(retries - 1) " + "window={\(debugCommandPaletteWindowSummary(window))}" ) @@ -1030,7 +1057,7 @@ private final class WindowCommandPaletteOverlayController: NSObject { } guard isPaletteVisible else { #if DEBUG - cmuxDebugLog( + dlog( "palette.focus.lock inactive visible=0 window={\(debugCommandPaletteWindowSummary(window))}" ) #endif @@ -1040,7 +1067,7 @@ private final class WindowCommandPaletteOverlayController: NSObject { guard window.isKeyWindow else { #if DEBUG - cmuxDebugLog( + dlog( "palette.focus.lock keyWindowMissing window={\(debugCommandPaletteWindowSummary(window))} " + "fr=\(debugCommandPaletteResponderSummary(window.firstResponder))" ) @@ -1055,7 +1082,7 @@ private final class WindowCommandPaletteOverlayController: NSObject { startFocusLockTimer() if !isPaletteTextInputFirstResponder(window.firstResponder) { #if DEBUG - cmuxDebugLog( + dlog( "palette.focus.lock requestRestore window={\(debugCommandPaletteWindowSummary(window))} " + "fr=\(debugCommandPaletteResponderSummary(window.firstResponder))" ) @@ -1116,13 +1143,13 @@ private final class WindowCommandPaletteOverlayController: NSObject { ) #if DEBUG if let window { - cmuxDebugLog( + dlog( "palette.overlay.update visible=\(isVisible ? 1 : 0) promote=\(shouldPromote ? 1 : 0) " + "window={\(debugCommandPaletteWindowSummary(window))} " + "fr=\(debugCommandPaletteResponderSummary(window.firstResponder))" ) } else { - cmuxDebugLog("palette.overlay.update visible=\(isVisible ? 1 : 0) promote=\(shouldPromote ? 1 : 0) window=nil") + dlog("palette.overlay.update visible=\(isVisible ? 1 : 0) promote=\(shouldPromote ? 1 : 0) window=nil") } #endif isPaletteVisible = isVisible @@ -1180,18 +1207,13 @@ private final class WindowTmuxWorkspacePaneOverlayController: NSObject { private weak var window: NSWindow? private let containerView = PassthroughWindowOverlayContainerView(frame: .zero) private let model = TmuxWorkspacePaneOverlayModel() - private let hostingView: NSHostingView + private let hostingView: NSHostingView private var installConstraints: [NSLayoutConstraint] = [] init(window: NSWindow) { self.window = window self.hostingView = NSHostingView( - rootView: TmuxWorkspacePaneOverlayView( - unreadRects: [], - flashRect: nil, - flashStartedAt: nil, - flashReason: nil - ) + rootView: TmuxWorkspacePaneOverlayObservedView(model: model) ) super.init() containerView.translatesAutoresizingMaskIntoConstraints = false @@ -1240,22 +1262,10 @@ private final class WindowTmuxWorkspacePaneOverlayController: NSObject { guard ensureInstalled() else { return } if let state { model.apply(state) - hostingView.rootView = TmuxWorkspacePaneOverlayView( - unreadRects: model.unreadRects, - flashRect: model.flashRect, - flashStartedAt: model.flashStartedAt, - flashReason: model.flashReason - ) containerView.alphaValue = 1 containerView.isHidden = false } else { model.clear() - hostingView.rootView = TmuxWorkspacePaneOverlayView( - unreadRects: [], - flashRect: nil, - flashStartedAt: nil, - flashReason: nil - ) containerView.alphaValue = 0 containerView.isHidden = true } @@ -1306,10 +1316,34 @@ private func commandPaletteOwningWebView(for responder: NSResponder?) -> WKWebVi return nil } +/// Installs a FileDropOverlayView on the window's theme frame for Finder file drag support. +func installFileDropOverlay(on window: NSWindow, tabManager: TabManager) { + guard objc_getAssociatedObject(window, &fileDropOverlayKey) == nil, + let contentView = window.contentView, + let themeFrame = contentView.superview else { return } + + let overlay = FileDropOverlayView(frame: contentView.frame) + overlay.translatesAutoresizingMaskIntoConstraints = false + overlay.onDrop = { [weak tabManager] urls in + MainActor.assumeIsolated { + guard let tabManager, let terminal = tabManager.selectedWorkspace?.focusedTerminalPanel else { return false } + return terminal.hostedView.handleDroppedURLs(urls) + } + } + + themeFrame.addSubview(overlay, positioned: .above, relativeTo: contentView) + NSLayoutConstraint.activate([ + overlay.topAnchor.constraint(equalTo: contentView.topAnchor), + overlay.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + overlay.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + overlay.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) + ]) + + objc_setAssociatedObject(window, &fileDropOverlayKey, overlay, .OBJC_ASSOCIATION_RETAIN) +} + enum WorkspaceMountPolicy { - // Keep only the selected workspace mounted to minimize layer-tree traversal. static let maxMountedWorkspaces = 1 - // During workspace cycling, keep only a minimal handoff pair (selected + retiring). static let maxMountedWorkspacesDuringCycle = 2 static func nextMountedWorkspaceIds( @@ -1337,14 +1371,10 @@ enum WorkspaceMountPolicy { } } - if isCycleHot, - pinnedIds.isEmpty, - let selected { + if isCycleHot, pinnedIds.isEmpty, let selected { ordered.removeAll { $0 != selected } } - // Ensure pinned ids (retiring handoff workspaces) are always retained at highest priority. - // This runs after warming to prevent neighbor warming from evicting the retiring workspace. let prioritizedPinnedIds = pinnedIds .filter { existing.contains($0) && $0 != selected } .sorted { lhs, rhs in @@ -1373,8 +1403,6 @@ enum WorkspaceMountPolicy { private static func cycleWarmIds(selected: UUID, orderedTabIds: [UUID]) -> [UUID] { guard orderedTabIds.contains(selected) else { return [selected] } - // Keep warming focused to the selected workspace. Retiring/target workspaces are - // pinned by handoff logic, so warming adjacent neighbors here just adds layout work. return [selected] } } @@ -1397,9 +1425,6 @@ enum MountedWorkspacePresentationPolicy { return 1 } if shouldPrimeInBackground { - // Keep the workspace mounted long enough to warm the terminal surface, but do - // not mark it panel-visible. Visible portal entries intentionally survive - // transient anchor loss during bonsplit drag/reparent churn. return 0.001 } return 0 @@ -1413,87 +1438,6 @@ enum MountedWorkspacePresentationPolicy { } } -/// Installs a FileDropOverlayView on the window's theme frame for Finder file drag support. -private func findFileDropOverlayView(in root: NSView?) -> FileDropOverlayView? { - guard let root else { return nil } - if let overlay = root as? FileDropOverlayView { - return overlay - } - for subview in root.subviews { - if let overlay = findFileDropOverlayView(in: subview) { - return overlay - } - } - return nil -} - -private func configureFileDropOverlay(_ overlay: FileDropOverlayView, tabManager: TabManager) { - overlay.onDrop = { [weak tabManager] urls in - MainActor.assumeIsolated { - guard let tabManager, let terminal = tabManager.selectedWorkspace?.focusedTerminalPanel else { return false } - return terminal.hostedView.handleDroppedURLs(urls) - } - } -} - -private func attachFileDropOverlay(_ overlay: FileDropOverlayView, to contentView: NSView, in themeFrame: NSView) { - overlay.translatesAutoresizingMaskIntoConstraints = false - themeFrame.addSubview(overlay, positioned: .above, relativeTo: contentView) - NSLayoutConstraint.activate([ - overlay.topAnchor.constraint(equalTo: contentView.topAnchor), - overlay.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - overlay.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - overlay.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) - ]) -} - -@discardableResult -func installFileDropOverlay(on window: NSWindow, tabManager: TabManager) -> Bool { - guard let contentView = window.contentView, - let themeFrame = contentView.superview else { return false } - - let existingOverlay = - (objc_getAssociatedObject(window, &fileDropOverlayKey) as? FileDropOverlayView) - ?? findFileDropOverlayView(in: themeFrame) - - if let existingOverlay { - configureFileDropOverlay(existingOverlay, tabManager: tabManager) - objc_setAssociatedObject(window, &fileDropOverlayKey, existingOverlay, .OBJC_ASSOCIATION_RETAIN) - guard existingOverlay.superview !== themeFrame else { return true } - existingOverlay.removeFromSuperview() - attachFileDropOverlay(existingOverlay, to: contentView, in: themeFrame) - return true - } - - let overlay = FileDropOverlayView(frame: contentView.frame) - configureFileDropOverlay(overlay, tabManager: tabManager) - // Publish the overlay before mutating the view tree so any re-entrant lookup resolves - // the in-flight view instead of installing a second overlay during layout. - objc_setAssociatedObject(window, &fileDropOverlayKey, overlay, .OBJC_ASSOCIATION_RETAIN) - attachFileDropOverlay(overlay, to: contentView, in: themeFrame) - return true -} - -private func installFileDropOverlayWhenReady( - on window: NSWindow, - tabManager: TabManager, - remainingAttempts: Int = 16 -) { - guard !installFileDropOverlay(on: window, tabManager: tabManager), - remainingAttempts > 0 else { return } - - // Defer retrying until the next main-loop turn so we don't mutate the - // NSThemeFrame hierarchy while SwiftUI/AppKit is still attaching views. - DispatchQueue.main.async { [weak window, weak tabManager] in - guard let window, let tabManager else { return } - installFileDropOverlayWhenReady( - on: window, - tabManager: tabManager, - remainingAttempts: remainingAttempts - 1 - ) - } -} - @MainActor private final class SelectedWorkspaceDirectoryObserver: ObservableObject { @Published private(set) var directoryChangeGeneration: UInt64 = 0 @@ -1807,7 +1751,7 @@ struct ContentView: View { let workspace = tabManager.selectedWorkspace else { return nil } let layoutSnapshot = WorkspaceContentView.effectiveTmuxLayoutSnapshot( cachedSnapshot: workspace.tmuxLayoutSnapshot, - liveSnapshot: workspace.bonsplitController.layoutSnapshot() + liveSnapshot: workspace.layoutSnapshot() ) let contentView = window.contentView @@ -1816,17 +1760,17 @@ struct ContentView: View { unreadRects = layoutSnapshot.panes.compactMap { pane in guard let selectedTabId = pane.selectedTabId, let tabUUID = UUID(uuidString: selectedTabId), - let panelId = workspace.panelIdFromSurfaceId(TabID(uuid: tabUUID)), - let panel = workspace.panels[panelId] else { + let panel = workspace.panel(for: TabID(uuid: tabUUID)) else { return nil } + let panelId = panel.id let shouldShowUnread = Workspace.shouldShowUnreadIndicator( hasUnreadNotification: notificationStore.hasVisibleNotificationIndicator( forTabId: workspace.id, surfaceId: panelId ), - isManuallyUnread: workspace.manualUnreadPanelIds.contains(panelId) + isManuallyUnread: workspace.isPanelManuallyUnread(panelId) ) guard shouldShowUnread else { return nil } @@ -2095,6 +2039,14 @@ struct ContentView: View { } } + private var sidebarResizerSidebarHitWidth: CGFloat { + SidebarResizeInteraction.sidebarSideHitWidth + } + + private var sidebarResizerContentHitWidth: CGFloat { + SidebarResizeInteraction.contentSideHitWidth + } + private func maxSidebarWidth(availableWidth: CGFloat? = nil) -> CGFloat { let resolvedAvailableWidth = availableWidth ?? observedWindow?.contentView?.bounds.width @@ -2461,7 +2413,7 @@ struct ContentView: View { } /// Space at top of content area for the titlebar. This must be at least the actual titlebar - /// height; otherwise controls like Bonsplit tab dragging can be interpreted as window drags. + /// height; otherwise controls like WorkspaceSplit tab dragging can be interpreted as window drags. @State private var titlebarPadding: CGFloat = WindowChromeMetrics.defaultTitlebarHeight /// SwiftUI WindowGroup windows can still report a titlebar safe area; manually created /// main windows use MainWindowHostingView and report zero. @@ -2512,7 +2464,7 @@ struct ContentView: View { ) // Keep the retiring workspace visible during handoff, but never input-active. // Allowing both selected+retiring workspaces to be input-active lets the - // old workspace steal first responder (notably with WKWebView), which can + // old workspace steal first responder, notably with WKWebView, which can // delay handoff completion and make browser returns feel laggy. let isInputActive = isSelectedWorkspace let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0) @@ -2668,6 +2620,14 @@ struct ContentView: View { } } + private var fileExplorerResizerHandle: some View { + sidebarResizerHandleOverlay( + .explorerDivider, + width: SidebarResizeInteraction.totalHitWidth, + availableWidth: observedWindow?.contentView?.bounds.width ?? 1920 + ) + } + @AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue @AppStorage("sidebarMatchTerminalBackground") private var sidebarMatchTerminalBackground = false @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity @@ -2724,12 +2684,7 @@ struct ContentView: View { anchorView: fullscreenControlsViewModel.notificationsAnchorView ) }, - onNewTab: { - AppDelegate.shared?.performNewWorkspaceAction( - tabManager: tabManager, - debugSource: "titlebar.fullscreenNewWorkspace" - ) - }, + onNewTab: { tabManager.addTab() }, visibilityMode: .alwaysVisible ) } @@ -2779,7 +2734,9 @@ struct ContentView: View { private func syncTrafficLightInset() { let inset: CGFloat = (isMinimalMode && !sidebarState.isVisible && !isFullScreen) ? 80 : 0 - tabManager.syncWorkspaceTabBarLeadingInset(inset) + for tab in tabManager.tabs { + tab.tabBarLeadingInset = inset + } } private func updateTitlebarText() { @@ -2847,11 +2804,6 @@ struct ContentView: View { let inputWithReturn = entry.resumeCommand + "\n" let targetCwd = entry.cwd - // Smart placement: if the focused workspace's tracked cwd matches, open a - // new tab inside that workspace. Otherwise create a new workspace. - // Remote workspaces are excluded from cwd-match: a session indexed from - // the local filesystem must not be resumed inside a remote shell just - // because the path string happens to coincide. let selected = tabManager.selectedWorkspace let selectedTab = tabManager.selectedTabId.flatMap { id in tabManager.tabs.first(where: { $0.id == id }) @@ -2869,27 +2821,26 @@ struct ContentView: View { if pwdMatches, let workspace = selected, - let paneId = workspace.bonsplitController.focusedPaneId { - workspace.newTerminalSurface( + let paneId = workspace.splitController.focusedPaneId { + if let panel = workspace.createTerminalPanel( inPane: paneId, focus: true, - workingDirectory: targetCwd, - initialInput: inputWithReturn - ) + workingDirectory: targetCwd + ) { + panel.sendText(inputWithReturn) + } return } tabManager.addWorkspace( workingDirectory: targetCwd, - initialTerminalInput: inputWithReturn + initialTerminalCommand: entry.resumeCommand ) } private func syncFileExplorerDirectory() { guard let selectedId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == selectedId }) else { - // No selection means we have no local cwd to scope by; clear so the - // sessions panel doesn't keep filtering by a stale previous tab. sessionIndexStore.setCurrentDirectoryIfChanged(nil) return } @@ -2923,7 +2874,7 @@ struct ContentView: View { } ?? "" #if DEBUG - cmuxDebugLog("fileExplorer.sync remote dir=\(dir) ready=\(isReady) dest=\(config?.destination ?? "nil")") + dlog("fileExplorer.sync remote dir=\(dir) ready=\(isReady) dest=\(config?.destination ?? "nil")") #endif if let existingProvider = fileExplorerStore.provider as? SSHFileExplorerProvider, @@ -2964,7 +2915,7 @@ struct ContentView: View { } // Use focused panel's directory if available if let focusedPanelId = tab.focusedPanelId, - let panelDir = tab.panelDirectories[focusedPanelId] { + let panelDir = tab.panelDirectory(panelId: focusedPanelId) { let trimmed = panelDir.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return trimmed @@ -2981,9 +2932,8 @@ struct ContentView: View { let useWithinWindow = sidebarBlendMode == SidebarBlendModeOption.withinWindow.rawValue && !sidebarMatchTerminalBackground if useWithinWindow { - // Overlay mode keeps the left sidebar on top, but the right - // sidebar stays in an HStack so terminal rows are clipped before - // the sidebar backdrop samples the window. + // Overlay mode: terminal extends full width, sidebar on top + // This allows withinWindow blur to see the terminal content layout = AnyView( ZStack(alignment: .leading) { HStack(spacing: 0) { @@ -3070,70 +3020,23 @@ struct ContentView: View { applyUITestSidebarSelectionIfNeeded(tabs: tabManager.tabs) updateTitlebarText() syncTrafficLightInset() - - // Startup recovery (#399): if session restore or a race condition leaves the - // view in a broken state (empty tabs, no selection, unmounted workspaces), - // detect and recover after a short delay. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak tabManager] in - guard let tabManager else { return } - var didRecover = false - - // Ensure there is at least one workspace. - if tabManager.tabs.isEmpty { - tabManager.addWorkspace() - didRecover = true - } - - // Ensure selectedTabId points to an existing workspace. - if tabManager.selectedTabId == nil || !tabManager.tabs.contains(where: { $0.id == tabManager.selectedTabId }) { - tabManager.selectedTabId = tabManager.tabs.first?.id - didRecover = true - } - - // Ensure mountedWorkspaceIds is populated. - if mountedWorkspaceIds.isEmpty || !mountedWorkspaceIds.contains(where: { id in tabManager.tabs.contains { $0.id == id } }) { - reconcileMountedWorkspaceIds() - didRecover = true - } - - // Ensure sidebar selection is valid. - if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { - selectedTabIds = [selectedId] - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } - didRecover = true - } - - syncSidebarSelectedWorkspaceIds() - applyUITestSidebarSelectionIfNeeded(tabs: tabManager.tabs) - - if didRecover { -#if DEBUG - cmuxDebugLog("startup.recovery tabCount=\(tabManager.tabs.count) selected=\(tabManager.selectedTabId?.uuidString.prefix(8) ?? "nil") mounted=\(mountedWorkspaceIds.count)") -#endif - sentryBreadcrumb("startup.recovery", data: [ - "tabCount": tabManager.tabs.count, - "selectedTabId": tabManager.selectedTabId?.uuidString ?? "nil", - "mountedCount": mountedWorkspaceIds.count - ]) - } - } }) view = AnyView(view.onChange(of: tabManager.selectedTabId) { newValue in #if DEBUG if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 - cmuxDebugLog( + dlog( "ws.view.selectedChange id=\(snapshot.id) dt=\(debugMsText(dtMs)) selected=\(debugShortWorkspaceId(newValue))" ) } else { - cmuxDebugLog("ws.view.selectedChange id=none selected=\(debugShortWorkspaceId(newValue))") + dlog("ws.view.selectedChange id=none selected=\(debugShortWorkspaceId(newValue))") } #endif tabManager.applyWindowBackgroundForSelectedTab() startWorkspaceHandoffIfNeeded(newSelectedId: newValue) reconcileMountedWorkspaceIds(selectedId: newValue) - AppDelegate.shared?.syncBonsplitTabShortcutHintEligibility(in: observedWindow) + AppDelegate.shared?.syncWorkspaceSplitTabShortcutHintEligibility(in: observedWindow) guard let newValue else { return } if selectedTabIds.count <= 1 { selectedTabIds = [newValue] @@ -3146,7 +3049,6 @@ struct ContentView: View { syncSidebarSelectedWorkspaceIds() }) - // File explorer: keep the Combine subscription stable across body re-evaluations. view = AnyView(view.onChange(of: selectedWorkspaceDirectoryObserver.directoryChangeGeneration) { _ in syncFileExplorerDirectory() }) @@ -3155,11 +3057,11 @@ struct ContentView: View { #if DEBUG if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 - cmuxDebugLog( + dlog( "ws.view.hotChange id=\(snapshot.id) dt=\(debugMsText(dtMs)) hot=\(tabManager.isWorkspaceCycleHot ? 1 : 0)" ) } else { - cmuxDebugLog("ws.view.hotChange id=none hot=\(tabManager.isWorkspaceCycleHot ? 1 : 0)") + dlog("ws.view.hotChange id=none hot=\(tabManager.isWorkspaceCycleHot ? 1 : 0)") } #endif reconcileMountedWorkspaceIds() @@ -3326,7 +3228,7 @@ struct ContentView: View { let tabId = SidebarDragLifecycleNotification.tabId(from: notification) sidebarDraggedTabId = tabId #if DEBUG - cmuxDebugLog( + dlog( "sidebar.dragState.content tab=\(debugShortWorkspaceId(tabId)) " + "reason=\(SidebarDragLifecycleNotification.reason(from: notification))" ) @@ -3421,7 +3323,7 @@ struct ContentView: View { mainWindow: NSApp.mainWindow ) #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.request observed={\(debugCommandPaletteWindowSummary(observedWindow))} " + "requested={\(debugCommandPaletteWindowSummary(requestedWindow))} " + "shouldHandle=\(shouldHandle ? 1 : 0) presented=\(isCommandPalettePresented ? 1 : 0) " + @@ -3521,9 +3423,8 @@ struct ContentView: View { view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: NSWindow.didResizeNotification)) { notification in guard let window = notification.object as? NSWindow, window === observedWindow else { return } - let availableWidth = window.contentView?.bounds.width ?? window.contentLayoutRect.width - clampSidebarWidthIfNeeded(availableWidth: availableWidth) - clampRightSidebarWidthIfNeeded(availableWidth: availableWidth) + clampSidebarWidthIfNeeded(availableWidth: window.contentView?.bounds.width ?? window.contentLayoutRect.width) + clampRightSidebarWidthIfNeeded(availableWidth: window.contentView?.bounds.width ?? window.contentLayoutRect.width) updateSidebarResizerBandState() }) @@ -3556,17 +3457,6 @@ struct ContentView: View { syncTrafficLightInset() }) - view = AnyView(view.onChange(of: fileExplorerState.isVisible) { isVisible in - if !isVisible { - _ = AppDelegate.shared?.restoreTerminalFocusAfterRightSidebarHidden(in: observedWindow) - } - if let observedWindow { - TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: observedWindow) - } else { - TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() - } - }) - view = AnyView(view.onChange(of: sidebarMatchTerminalBackground) { _ in tabManager.applyWindowBackdropModeForAllTabs(reason: "sidebarMatchTerminalBackgroundChanged") guard sidebarState.isVisible, @@ -3582,10 +3472,6 @@ struct ContentView: View { syncTrafficLightInset() }) - view = AnyView(view.onChange(of: tabManager.tabs.map(\.id)) { _ in - syncTrafficLightInset() - }) - view = AnyView(view.onChange(of: sidebarState.persistedWidth) { newValue in let sanitized = normalizedSidebarWidth(newValue) if abs(newValue - sanitized) > 0.5 { @@ -3614,7 +3500,6 @@ struct ContentView: View { view = AnyView(view.background(WindowAccessor { [appearance] window in window.identifier = NSUserInterfaceItemIdentifier(windowIdentifier) - window.isRestorable = false window.titlebarAppearsTransparent = true // Keep window immovable; the sidebar's WindowDragHandleView handles // drag-to-move via performDrag with temporary movable override. @@ -3629,16 +3514,14 @@ struct ContentView: View { DispatchQueue.main.async { observedWindow = window isFullScreen = window.styleMask.contains(.fullScreen) - let availableWidth = window.contentView?.bounds.width ?? window.contentLayoutRect.width - clampSidebarWidthIfNeeded(availableWidth: availableWidth) - clampRightSidebarWidthIfNeeded(availableWidth: availableWidth) + clampSidebarWidthIfNeeded(availableWidth: window.contentView?.bounds.width ?? window.contentLayoutRect.width) syncCommandPaletteDebugStateForObservedWindow() installSidebarResizerPointerMonitorIfNeeded() updateSidebarResizerBandState() } } - // Keep content below the titlebar so drags on Bonsplit's tab bar don't + // Keep content below the titlebar so drags on WorkspaceSplit's tab bar don't // get interpreted as window drags. // User settings decide whether window glass is active. The native Tahoe // NSGlassEffectView path vs the older NSVisualEffectView fallback is chosen @@ -3704,7 +3587,7 @@ struct ContentView: View { fileExplorerState: fileExplorerState, cmuxConfigStore: cmuxConfigStore ) - installFileDropOverlayWhenReady(on: window, tabManager: tabManager) + installFileDropOverlay(on: window, tabManager: tabManager) })) return view @@ -3714,7 +3597,7 @@ struct ContentView: View { let currentTabs = tabs ?? tabManager.tabs let orderedTabIds = currentTabs.map { $0.id } let effectiveSelectedId = selectedId ?? tabManager.selectedTabId - let handoffPinnedIds = retiringWorkspaceId.map { Set([ $0 ]) } ?? [] + let handoffPinnedIds = retiringWorkspaceId.map { Set([$0]) } ?? [] let pinnedIds = handoffPinnedIds .union(tabManager.pendingBackgroundWorkspaceLoadIds) .union(tabManager.debugPinnedWorkspaceLoadIds) @@ -3745,14 +3628,14 @@ struct ContentView: View { let added = mountedWorkspaceIds.filter { !previousMountedIds.contains($0) } if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 - cmuxDebugLog( + dlog( "ws.mount.reconcile id=\(snapshot.id) dt=\(debugMsText(dtMs)) hot=\(isCycleHot ? 1 : 0) " + "selected=\(debugShortWorkspaceId(effectiveSelectedId)) " + "mounted=\(debugShortWorkspaceIds(mountedWorkspaceIds)) " + "added=\(debugShortWorkspaceIds(added)) removed=\(debugShortWorkspaceIds(removedIds))" ) } else { - cmuxDebugLog( + dlog( "ws.mount.reconcile id=none hot=\(isCycleHot ? 1 : 0) selected=\(debugShortWorkspaceId(effectiveSelectedId)) " + "mounted=\(debugShortWorkspaceIds(mountedWorkspaceIds))" ) @@ -3791,7 +3674,7 @@ struct ContentView: View { #if DEBUG let startedAt = ProcessInfo.processInfo.systemUptime - cmuxDebugLog("workspace.backgroundPrime.start workspace=\(workspaceId.uuidString.prefix(5))") + dlog("workspace.backgroundPrime.start workspace=\(workspaceId.uuidString.prefix(5))") #endif let initialState = await MainActor.run { @@ -3809,7 +3692,7 @@ struct ContentView: View { } #if DEBUG let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000 - cmuxDebugLog( + dlog( "workspace.backgroundPrime.finish workspace=\(workspaceId.uuidString.prefix(5)) " + "reason=\(completionReason) ms=\(String(format: "%.2f", elapsedMs))" ) @@ -3943,6 +3826,104 @@ struct ContentView: View { } } + private func startWorkspaceHandoffIfNeeded(newSelectedId: UUID?) { + let oldSelectedId = previousSelectedWorkspaceId + previousSelectedWorkspaceId = newSelectedId + + guard let oldSelectedId, let newSelectedId, oldSelectedId != newSelectedId else { + tabManager.completePendingWorkspaceUnfocus(reason: "no_handoff") + retiringWorkspaceId = nil + workspaceHandoffFallbackTask?.cancel() + workspaceHandoffFallbackTask = nil + return + } + + workspaceHandoffGeneration &+= 1 + let generation = workspaceHandoffGeneration + retiringWorkspaceId = oldSelectedId + workspaceHandoffFallbackTask?.cancel() + +#if DEBUG + if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.handoff.start id=\(snapshot.id) dt=\(debugMsText(dtMs)) old=\(debugShortWorkspaceId(oldSelectedId)) " + + "new=\(debugShortWorkspaceId(newSelectedId))" + ) + } else { + dlog( + "ws.handoff.start id=none old=\(debugShortWorkspaceId(oldSelectedId)) new=\(debugShortWorkspaceId(newSelectedId))" + ) + } +#endif + + if canCompleteWorkspaceHandoffImmediately(for: newSelectedId) { +#if DEBUG + if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.handoff.fastReady id=\(snapshot.id) dt=\(debugMsText(dtMs)) selected=\(debugShortWorkspaceId(newSelectedId))" + ) + } else { + dlog("ws.handoff.fastReady id=none selected=\(debugShortWorkspaceId(newSelectedId))") + } +#endif + completeWorkspaceHandoff(reason: "ready") + return + } + + workspaceHandoffFallbackTask = Task { [generation] in + do { + try await Task.sleep(nanoseconds: 150_000_000) + } catch { + return + } + await MainActor.run { + guard workspaceHandoffGeneration == generation else { return } + completeWorkspaceHandoff(reason: "timeout") + } + } + } + + private func completeWorkspaceHandoffIfNeeded(focusedTabId: UUID, reason: String) { + guard focusedTabId == tabManager.selectedTabId else { return } + guard retiringWorkspaceId != nil else { return } + completeWorkspaceHandoff(reason: reason) + } + + private func canCompleteWorkspaceHandoffImmediately(for workspaceId: UUID) -> Bool { + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return true } + if let focusedPanelId = workspace.focusedPanelId, + workspace.browserPanel(for: focusedPanelId) != nil { + return true + } + return workspace.hasLoadedTerminalSurface() + } + + private func completeWorkspaceHandoff(reason: String) { + workspaceHandoffFallbackTask?.cancel() + workspaceHandoffFallbackTask = nil + let retiring = retiringWorkspaceId + + if let retiring, let workspace = tabManager.tabs.first(where: { $0.id == retiring }) { + workspace.hideAllTerminalPortalViews() + workspace.hideAllBrowserPortalViews() + } + + retiringWorkspaceId = nil + tabManager.completePendingWorkspaceUnfocus(reason: reason) +#if DEBUG + if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.handoff.complete id=\(snapshot.id) dt=\(debugMsText(dtMs)) reason=\(reason) retiring=\(debugShortWorkspaceId(retiring))" + ) + } else { + dlog("ws.handoff.complete id=none reason=\(reason) retiring=\(debugShortWorkspaceId(retiring))") + } +#endif + } + private func addTab() { tabManager.addTab() sidebarSelectionState.selection = .tabs @@ -4175,6 +4156,7 @@ struct ContentView: View { } private func setTitlebarControlsHidden(_ hidden: Bool, in window: NSWindow) { + guard window.cmuxSupportsTitlebarAccessoryControllers else { return } let controlsId = NSUserInterfaceItemIdentifier("cmux.titlebarControls") for accessory in window.titlebarAccessoryViewControllers { if accessory.view.identifier == controlsId { @@ -4184,109 +4166,6 @@ struct ContentView: View { } } - private func startWorkspaceHandoffIfNeeded(newSelectedId: UUID?) { - let oldSelectedId = previousSelectedWorkspaceId - previousSelectedWorkspaceId = newSelectedId - - guard let oldSelectedId, let newSelectedId, oldSelectedId != newSelectedId else { - tabManager.completePendingWorkspaceUnfocus(reason: "no_handoff") - retiringWorkspaceId = nil - workspaceHandoffFallbackTask?.cancel() - workspaceHandoffFallbackTask = nil - return - } - - workspaceHandoffGeneration &+= 1 - let generation = workspaceHandoffGeneration - retiringWorkspaceId = oldSelectedId - workspaceHandoffFallbackTask?.cancel() - -#if DEBUG - if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { - let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 - cmuxDebugLog( - "ws.handoff.start id=\(snapshot.id) dt=\(debugMsText(dtMs)) old=\(debugShortWorkspaceId(oldSelectedId)) " + - "new=\(debugShortWorkspaceId(newSelectedId))" - ) - } else { - cmuxDebugLog( - "ws.handoff.start id=none old=\(debugShortWorkspaceId(oldSelectedId)) new=\(debugShortWorkspaceId(newSelectedId))" - ) - } -#endif - - if canCompleteWorkspaceHandoffImmediately(for: newSelectedId) { -#if DEBUG - if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { - let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 - cmuxDebugLog( - "ws.handoff.fastReady id=\(snapshot.id) dt=\(debugMsText(dtMs)) selected=\(debugShortWorkspaceId(newSelectedId))" - ) - } else { - cmuxDebugLog("ws.handoff.fastReady id=none selected=\(debugShortWorkspaceId(newSelectedId))") - } -#endif - completeWorkspaceHandoff(reason: "ready") - return - } - - workspaceHandoffFallbackTask = Task { [generation] in - do { - try await Task.sleep(nanoseconds: 150_000_000) - } catch { - return - } - await MainActor.run { - guard workspaceHandoffGeneration == generation else { return } - completeWorkspaceHandoff(reason: "timeout") - } - } - } - - private func completeWorkspaceHandoffIfNeeded(focusedTabId: UUID, reason: String) { - guard focusedTabId == tabManager.selectedTabId else { return } - guard retiringWorkspaceId != nil else { return } - completeWorkspaceHandoff(reason: reason) - } - - private func canCompleteWorkspaceHandoffImmediately(for workspaceId: UUID) -> Bool { - guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return true } - if let focusedPanelId = workspace.focusedPanelId, - workspace.browserPanel(for: focusedPanelId) != nil { - return true - } - return workspace.hasLoadedTerminalSurface() - } - - private func completeWorkspaceHandoff(reason: String) { - workspaceHandoffFallbackTask?.cancel() - workspaceHandoffFallbackTask = nil - let retiring = retiringWorkspaceId - - // Hide portal-hosted views for the retiring workspace BEFORE clearing - // retiringWorkspaceId. Once cleared, reconcileMountedWorkspaceIds unmounts - // the workspace — but dismantleNSView intentionally doesn't hide portal views - // during transient rebuilds. Hiding here prevents stale terminal/browser - // portals from covering the newly selected workspace. - if let retiring, let workspace = tabManager.tabs.first(where: { $0.id == retiring }) { - workspace.hideAllTerminalPortalViews() - workspace.hideAllBrowserPortalViews() - } - - retiringWorkspaceId = nil - tabManager.completePendingWorkspaceUnfocus(reason: reason) -#if DEBUG - if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { - let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 - cmuxDebugLog( - "ws.handoff.complete id=\(snapshot.id) dt=\(debugMsText(dtMs)) reason=\(reason) retiring=\(debugShortWorkspaceId(retiring))" - ) - } else { - cmuxDebugLog("ws.handoff.complete id=none reason=\(reason) retiring=\(debugShortWorkspaceId(retiring))") - } -#endif - } - private var commandPaletteOverlay: some View { GeometryReader { proxy in let maxAllowedWidth = max(340, proxy.size.width - 260) @@ -4693,7 +4572,7 @@ struct ContentView: View { } .onAppear { #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.view.appear workspace=\(target.workspaceId.uuidString.prefix(8)) " + "draftLen=\((commandPaletteWorkspaceDescriptionDraft as NSString).length) " + "height=\(String(format: "%.1f", commandPaletteWorkspaceDescriptionHeight)) " + @@ -4704,7 +4583,7 @@ struct ContentView: View { } .onChange(of: commandPaletteShouldFocusWorkspaceDescriptionEditor) { _, newValue in #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.focus.binding new=\(newValue ? 1 : 0) " + "mode=\(debugCommandPaletteModeLabel(commandPaletteMode)) " + "window={\(debugCommandPaletteWindowSummary(observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow))} " + @@ -4962,7 +4841,7 @@ struct ContentView: View { override func flagsChanged(with event: NSEvent) { #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.editor.flagsChanged " + "\(debugCommandPaletteKeyEventSummary(event))" ) @@ -4973,7 +4852,7 @@ struct ContentView: View { override func becomeFirstResponder() -> Bool { let becameFirstResponder = super.becomeFirstResponder() #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.editor.textView.becomeFirstResponder success=\(becameFirstResponder ? 1 : 0) " + "window={\(debugCommandPaletteWindowSummary(window))} " + "fr=\(debugCommandPaletteResponderSummary(window?.firstResponder))" @@ -4988,7 +4867,7 @@ struct ContentView: View { override func keyDown(with event: NSEvent) { if hasMarkedText() { #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.editor.keyDown markedText=1 " + "\(debugCommandPaletteKeyEventSummary(event))" ) @@ -4998,7 +4877,7 @@ struct ContentView: View { } let handled = onHandleKeyEvent?(event, self) == true #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.editor.keyDown handled=\(handled ? 1 : 0) " + "\(debugCommandPaletteKeyEventSummary(event))" ) @@ -5012,7 +4891,7 @@ struct ContentView: View { override func performKeyEquivalent(with event: NSEvent) -> Bool { if hasMarkedText() { #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.editor.performKeyEquivalent markedText=1 " + "\(debugCommandPaletteKeyEventSummary(event))" ) @@ -5021,7 +4900,7 @@ struct ContentView: View { } let handled = onHandleKeyEvent?(event, self) == true #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.editor.performKeyEquivalent handled=\(handled ? 1 : 0) " + "\(debugCommandPaletteKeyEventSummary(event))" ) @@ -5031,7 +4910,7 @@ struct ContentView: View { } let result = super.performKeyEquivalent(with: event) #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.editor.performKeyEquivalent superResult=\(result ? 1 : 0) " + "\(debugCommandPaletteKeyEventSummary(event))" ) @@ -5041,7 +4920,7 @@ struct ContentView: View { override func doCommand(by commandSelector: Selector) { #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.editor.doCommand selector=\(NSStringFromSelector(commandSelector)) " + "len=\((string as NSString).length) " + "sel=\(selectedRange().location):\(selectedRange().length)" @@ -5052,7 +4931,7 @@ struct ContentView: View { override func insertNewline(_ sender: Any?) { #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.editor.insertNewline " + "len=\((string as NSString).length) " + "sel=\(selectedRange().location):\(selectedRange().length)" @@ -5063,7 +4942,7 @@ struct ContentView: View { override func insertLineBreak(_ sender: Any?) { #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.editor.insertLineBreak " + "len=\((string as NSString).length) " + "sel=\(selectedRange().location):\(selectedRange().length)" @@ -5074,7 +4953,7 @@ struct ContentView: View { override func insertNewlineIgnoringFieldEditor(_ sender: Any?) { #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.editor.insertNewlineIgnoringFieldEditor " + "len=\((string as NSString).length) " + "sel=\(selectedRange().location):\(selectedRange().length)" @@ -5196,20 +5075,20 @@ struct ContentView: View { func focusIfNeeded() { guard let window else { #if DEBUG - cmuxDebugLog("palette.wsDescription.editor.focusIfNeeded window=nil") + dlog("palette.wsDescription.editor.focusIfNeeded window=nil") #endif return } guard window.firstResponder !== textView else { #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.editor.focusIfNeeded alreadyFocused window={\(debugCommandPaletteWindowSummary(window))}" ) #endif return } #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.editor.focusIfNeeded attempt window={\(debugCommandPaletteWindowSummary(window))} " + "frBefore=\(debugCommandPaletteResponderSummary(window.firstResponder))" ) @@ -5218,7 +5097,7 @@ struct ContentView: View { let length = (textView.string as NSString).length textView.setSelectedRange(NSRange(location: length, length: 0)) #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.editor.focusIfNeeded result didFocus=\(didFocus ? 1 : 0) " + "window={\(debugCommandPaletteWindowSummary(window))} " + "frAfter=\(debugCommandPaletteResponderSummary(window.firstResponder))" @@ -5277,7 +5156,7 @@ struct ContentView: View { let newlineCount = textView.string.reduce(into: 0) { count, character in if character == "\n" { count += 1 } } - cmuxDebugLog( + dlog( "palette.wsDescription.editor.textDidChange len=\((textView.string as NSString).length) " + "newlines=\(newlineCount)" ) @@ -5313,7 +5192,7 @@ struct ContentView: View { func textDidBeginEditing(_ notification: Notification) { #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.editor.beginEditing focus=\(parent.isFocused ? 1 : 0) " + "responder=\(debugCommandPaletteResponderSummary(notification.object as? NSResponder))" ) @@ -5333,7 +5212,7 @@ struct ContentView: View { func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.editor.command selector=\(NSStringFromSelector(commandSelector)) " + "len=\((textView.string as NSString).length) " + "sel=\(textView.selectedRange().location):\(textView.selectedRange().length)" @@ -5344,7 +5223,7 @@ struct ContentView: View { func handleDidBecomeFirstResponder() { #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.editor.didBecomeFirstResponder focus=\(parent.isFocused ? 1 : 0)" ) #endif @@ -5368,7 +5247,7 @@ struct ContentView: View { .subtracting([.numericPad, .function, .capsLock]) #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.editor.handleKeyEvent " + "\(debugCommandPaletteKeyEventSummary(event)) " + "normalized=\(debugCommandPaletteModifierFlagsSummary(normalizedFlags))" @@ -5379,8 +5258,8 @@ struct ContentView: View { if normalizedFlags.isEmpty { let currentText = editor?.string ?? parent.text #if DEBUG - cmuxDebugLog("palette.wsDescription.editor.handleKeyEvent action=submit") - cmuxDebugLog( + dlog("palette.wsDescription.editor.handleKeyEvent action=submit") + dlog( "palette.wsDescription.editor.handleKeyEvent submitText " + "len=\((currentText as NSString).length) " + "text=\"\(debugCommandPaletteTextPreview(currentText))\"" @@ -5394,7 +5273,7 @@ struct ContentView: View { } if normalizedFlags == [.shift] { #if DEBUG - cmuxDebugLog("palette.wsDescription.editor.handleKeyEvent action=allowShiftReturn") + dlog("palette.wsDescription.editor.handleKeyEvent action=allowShiftReturn") #endif return false } @@ -5402,14 +5281,14 @@ struct ContentView: View { if event.keyCode == 53, normalizedFlags.isEmpty { #if DEBUG - cmuxDebugLog("palette.wsDescription.editor.handleKeyEvent action=escape") + dlog("palette.wsDescription.editor.handleKeyEvent action=escape") #endif parent.onEscape() return true } #if DEBUG - cmuxDebugLog("palette.wsDescription.editor.handleKeyEvent action=passThrough") + dlog("palette.wsDescription.editor.handleKeyEvent action=passThrough") #endif return false } @@ -5439,7 +5318,7 @@ struct ContentView: View { } view.refreshMetrics() #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.editor.make focus=\(isFocused ? 1 : 0) " + "textLen=\((text as NSString).length) " + "height=\(String(format: "%.1f", measuredHeight))" @@ -5469,7 +5348,7 @@ struct ContentView: View { guard let window = nsView.window else { #if DEBUG if isFocused { - cmuxDebugLog( + dlog( "palette.wsDescription.editor.update waitingForWindow focus=1 " + "pending=\(context.coordinator.pendingFocusRequest ? 1 : 0)" ) @@ -5480,7 +5359,7 @@ struct ContentView: View { let isFirstResponder = window.firstResponder === nsView.textView #if DEBUG if isFocused || context.coordinator.pendingFocusRequest { - cmuxDebugLog( + dlog( "palette.wsDescription.editor.update focus=\(isFocused ? 1 : 0) " + "isFirstResponder=\(isFirstResponder ? 1 : 0) " + "pending=\(context.coordinator.pendingFocusRequest ? 1 : 0) " + @@ -5492,7 +5371,7 @@ struct ContentView: View { if isFocused, !isFirstResponder, !context.coordinator.pendingFocusRequest { context.coordinator.pendingFocusRequest = true #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.editor.update scheduleFocus window={\(debugCommandPaletteWindowSummary(window))} " + "fr=\(debugCommandPaletteResponderSummary(window.firstResponder))" ) @@ -6440,9 +6319,10 @@ struct ContentView: View { for workspace: Workspace, panelId: UUID ) -> CommandPaletteSwitcherSearchMetadata { - let directories = [workspace.panelDirectories[panelId]].compactMap { $0 } - let branches = [workspace.panelGitBranches[panelId]?.branch].compactMap { $0 } - let ports = workspace.surfaceListeningPorts[panelId] ?? [] + let surfaceState = workspace.surfaceStateSnapshot(panelId: panelId) + let directories = [surfaceState.directory].compactMap { $0 } + let branches = [surfaceState.gitBranch?.branch].compactMap { $0 } + let ports = surfaceState.listeningPorts return CommandPaletteSwitcherSearchMetadata( directories: directories, branches: branches, @@ -6512,11 +6392,6 @@ struct ContentView: View { var nextRank = 0 for contribution in contributions { - let configuredPaletteAction = commandPaletteConfigActionID(for: contribution.commandId) - .flatMap { cmuxConfigStore.resolvedAction(id: $0) } - if let configuredPaletteAction, !configuredPaletteAction.palette { - continue - } guard contribution.when(context), contribution.enablement(context) else { continue } guard let action = handlerRegistry.handler(for: contribution.commandId) else { assertionFailure("No command palette handler registered for \(contribution.commandId)") @@ -6526,13 +6401,11 @@ struct ContentView: View { CommandPaletteCommand( id: contribution.commandId, rank: nextRank, - title: configuredPaletteAction?.title ?? contribution.title(context), - subtitle: configuredPaletteAction?.subtitle ?? contribution.subtitle(context), + title: contribution.title(context), + subtitle: contribution.subtitle(context), shortcutHint: commandPaletteShortcutHint(for: contribution, context: context), kindLabel: nil, - keywords: configuredPaletteAction?.keywords.isEmpty == false - ? configuredPaletteAction?.keywords ?? contribution.keywords - : contribution.keywords, + keywords: contribution.keywords, dismissOnRun: contribution.dismissOnRun, action: action ) @@ -6543,21 +6416,6 @@ struct ContentView: View { return commands } - private func commandPaletteConfigActionID(for commandId: String) -> String? { - switch commandId { - case "palette.newTerminalTab": - return CmuxSurfaceTabBarBuiltInAction.newTerminal.configID - case "palette.newBrowserTab": - return CmuxSurfaceTabBarBuiltInAction.newBrowser.configID - case "palette.terminalSplitRight": - return CmuxSurfaceTabBarBuiltInAction.splitRight.configID - case "palette.terminalSplitDown": - return CmuxSurfaceTabBarBuiltInAction.splitDown.configID - default: - return nil - } - } - private func commandPaletteShortcutHint( for contribution: CommandPaletteCommandContribution, context: CommandPaletteContextSnapshot @@ -6567,13 +6425,6 @@ struct ContentView: View { context.bool(CommandPaletteContextKeys.panelIsBrowser) { return nil } - if let configuredShortcut = cmuxConfigStore.resolvedAction(id: contribution.commandId)?.shortcut { - return configuredShortcut.displayString - } - if let configuredPaletteAction = commandPaletteConfigActionID(for: contribution.commandId), - let configuredShortcut = cmuxConfigStore.resolvedAction(id: configuredPaletteAction)?.shortcut { - return configuredShortcut.displayString - } if let action = commandPaletteShortcutAction(for: contribution.commandId) { return KeyboardShortcutSettings.shortcut(for: action).displayString } @@ -6591,8 +6442,6 @@ struct ContentView: View { return .newWindow case "palette.openFolder": return .openFolder - case "palette.reopenPreviousSession": - return .reopenPreviousSession case "palette.newTerminalTab": return .newSurface case "palette.newBrowserTab": @@ -6710,7 +6559,7 @@ struct ContentView: View { ) snapshot.setBool( CommandPaletteContextKeys.workspaceHasSplits, - workspace.bonsplitController.allPaneIds.count > 1 + workspace.paneCount > 1 ) let workspaceIndex = tabManager.tabs.firstIndex { $0.id == workspace.id } snapshot.setBool(CommandPaletteContextKeys.workspaceHasPeers, tabManager.tabs.count > 1) @@ -6740,9 +6589,9 @@ struct ContentView: View { ) snapshot.setBool(CommandPaletteContextKeys.panelIsBrowser, panelContext.panel.panelType == .browser) snapshot.setBool(CommandPaletteContextKeys.panelIsTerminal, panelIsTerminal) - snapshot.setBool(CommandPaletteContextKeys.panelHasCustomName, workspace.panelCustomTitles[panelId] != nil) + snapshot.setBool(CommandPaletteContextKeys.panelHasCustomName, workspace.hasPanelCustomTitle(panelId: panelId)) snapshot.setBool(CommandPaletteContextKeys.panelShouldPin, !workspace.isPanelPinned(panelId)) - let hasUnread = workspace.manualUnreadPanelIds.contains(panelId) + let hasUnread = workspace.isPanelManuallyUnread(panelId) || notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panelId) snapshot.setBool(CommandPaletteContextKeys.panelHasUnread, hasUnread) @@ -6852,14 +6701,6 @@ struct ContentView: View { when: { _ in TerminalDirectoryOpenTarget.vscodeInline.isAvailable() } ) ) - contributions.append( - CommandPaletteCommandContribution( - commandId: "palette.reopenPreviousSession", - title: constant(String(localized: "command.reopenPreviousSession.title", defaultValue: "Reopen Previous Session")), - subtitle: constant(String(localized: "command.reopenPreviousSession.subtitle", defaultValue: "Session")), - keywords: ["reopen", "restore", "previous", "session", "resume"] - ) - ) contributions.append( CommandPaletteCommandContribution( commandId: "palette.newTerminalTab", @@ -7539,29 +7380,19 @@ struct ContentView: View { ) ) - let cmuxConfigDefaultSubtitle = String(localized: "command.cmuxConfig.subtitle", defaultValue: "cmux.json") - for issue in cmuxConfigStore.configurationIssues { - contributions.append( - CommandPaletteCommandContribution( - commandId: commandPaletteCmuxConfigIssueCommandID(issue), - title: constant(commandPaletteCmuxConfigIssueTitle(issue)), - subtitle: constant(commandPaletteCmuxConfigIssueSubtitle(issue)), - keywords: ["cmux", "config", "json", "schema", "error", "warning"] - ) - ) - } - for action in cmuxConfigStore.paletteCustomActions() { - let actionTitle = sanitizeCmuxConfigPaletteText(action.title) - let subtitleText = action.subtitle + let cmuxConfigDefaultSubtitle = constant(String(localized: "command.cmuxConfig.subtitle", defaultValue: "cmux.json")) + for command in cmuxConfigStore.loadedCommands { + let commandName = sanitizeCmuxConfigPaletteText(command.name) + let subtitle = command.description .map { sanitizeCmuxConfigPaletteText($0) } - .flatMap { $0.isEmpty ? nil : $0 } + .flatMap { $0.isEmpty ? nil : constant($0) } ?? cmuxConfigDefaultSubtitle contributions.append( CommandPaletteCommandContribution( - commandId: action.id, - title: constant(actionTitle), - subtitle: constant(subtitleText), - keywords: action.keywords + commandId: command.id, + title: constant(String(localized: "command.cmuxConfig.customTitle", defaultValue: "Custom: \(commandName)")), + subtitle: subtitle, + keywords: command.keywords ?? [] ) ) } @@ -7580,83 +7411,9 @@ struct ContentView: View { return filtered.trimmingCharacters(in: .whitespacesAndNewlines) } - private func commandPaletteCmuxConfigIssueCommandID(_ issue: CmuxConfigIssue) -> String { - var hash: UInt64 = 1_469_598_103_934_665_603 - for byte in issue.id.utf8 { - hash ^= UInt64(byte) - hash &*= 1_099_511_628_211 - } - return "palette.cmuxConfig.issue.\(String(hash, radix: 16))" - } - - private func commandPaletteCmuxConfigIssueTitle(_ issue: CmuxConfigIssue) -> String { - switch issue.kind { - case .schemaError: - return String( - localized: "command.cmuxConfig.issue.schemaError.title", - defaultValue: "cmux.json Schema Error" - ) - default: - return String( - localized: "command.cmuxConfig.issue.warning.title", - defaultValue: "cmux.json Configuration Warning" - ) - } - } - - private func commandPaletteCmuxConfigIssueSubtitle(_ issue: CmuxConfigIssue) -> String { - let rawPath = issue.sourcePath.map { - NSString(string: $0).abbreviatingWithTildeInPath - } ?? issue.settingName - let path = sanitizeCmuxConfigPaletteText(rawPath) - let detail = sanitizeCmuxConfigPaletteText(commandPaletteCmuxConfigIssueDetail(issue)) - guard !detail.isEmpty else { return path } - let format = String( - localized: "command.cmuxConfig.issue.subtitle", - defaultValue: "%@: %@" - ) - return String(format: format, path, detail) - } - - private func commandPaletteCmuxConfigIssueDetail(_ issue: CmuxConfigIssue) -> String { - switch issue.kind { - case .schemaError: - let format = String( - localized: "command.cmuxConfig.issue.schemaError.detail", - defaultValue: "%@" - ) - let fallback = String( - localized: "command.cmuxConfig.issue.schemaError.fallback", - defaultValue: "Invalid cmux.json" - ) - return String(format: format, issue.message ?? fallback) - case .newWorkspaceActionRequiresWorkspaceCommand: - let format = String( - localized: "command.cmuxConfig.issue.newWorkspaceActionRequiresWorkspaceCommand.detail", - defaultValue: "%@ must reference a workspace command action" - ) - return String(format: format, issue.settingName) - case .newWorkspaceCommandNotFound: - let format = String( - localized: "command.cmuxConfig.issue.newWorkspaceCommandNotFound.detail", - defaultValue: "%@ references missing command '%@'" - ) - return String(format: format, issue.settingName, issue.commandName ?? "") - case .newWorkspaceCommandRequiresWorkspace: - let format = String( - localized: "command.cmuxConfig.issue.newWorkspaceCommandRequiresWorkspace.detail", - defaultValue: "%@ '%@' must reference a workspace command" - ) - return String(format: format, issue.settingName, issue.commandName ?? "") - } - } - private func registerCommandPaletteHandlers(_ registry: inout CommandPaletteHandlerRegistry) { registry.register(commandId: "palette.newWorkspace") { - AppDelegate.shared?.performNewWorkspaceAction( - tabManager: tabManager, - debugSource: "palette.newWorkspace" - ) + tabManager.addWorkspace() } registry.register(commandId: "palette.openFolder") { // Defer so the command palette dismisses before the modal sheet appears. @@ -7677,11 +7434,6 @@ struct ContentView: View { AppDelegate.shared?.showOpenFolderInInlineVSCodePanel(tabManager: tabManager) } } - registry.register(commandId: "palette.reopenPreviousSession") { - if AppDelegate.shared?.reopenPreviousSession() != true { - NSSound.beep() - } - } registry.register(commandId: "palette.newWindow") { guard let appDelegate = AppDelegate.shared else { return } appDelegate.openNewMainWindow(preferredWindow: appDelegate.mainWindow(for: windowId)) @@ -7693,14 +7445,9 @@ struct ContentView: View { AppDelegate.shared?.uninstallCmuxCLIInPath(nil) } registry.register(commandId: "palette.newTerminalTab") { - if !executeConfiguredAction(id: CmuxSurfaceTabBarBuiltInAction.newTerminal.configID) { - tabManager.newSurface() - } + tabManager.newSurface() } registry.register(commandId: "palette.newBrowserTab") { - if executeConfiguredAction(id: CmuxSurfaceTabBarBuiltInAction.newBrowser.configID) { - return - } // Let command-palette dismissal complete first so omnibar focus // is not blocked by the palette visibility guard. DispatchQueue.main.async { @@ -7757,13 +7504,13 @@ struct ContentView: View { } registry.register(commandId: "palette.openSettings") { #if DEBUG - cmuxDebugLog("palette.openSettings.invoke") + dlog("palette.openSettings.invoke") #endif if let appDelegate = AppDelegate.shared { appDelegate.openPreferencesWindow(debugSource: "palette.openSettings") } else { #if DEBUG - cmuxDebugLog("palette.openSettings.missingAppDelegate fallback=1") + dlog("palette.openSettings.missingAppDelegate fallback=1") #endif AppDelegate.presentPreferencesWindow() } @@ -7877,7 +7624,7 @@ struct ContentView: View { NSSound.beep() return } - let hasUnread = panelContext.workspace.manualUnreadPanelIds.contains(panelContext.panelId) + let hasUnread = panelContext.workspace.isPanelManuallyUnread(panelContext.panelId) || notificationStore.hasUnreadNotification(forTabId: panelContext.workspace.id, surfaceId: panelContext.panelId) if hasUnread { panelContext.workspace.markPanelRead(panelContext.panelId) @@ -7993,14 +7740,10 @@ struct ContentView: View { tabManager.searchSelection() } registry.register(commandId: "palette.terminalSplitRight") { - if !executeConfiguredAction(id: CmuxSurfaceTabBarBuiltInAction.splitRight.configID) { - tabManager.createSplit(direction: .right) - } + tabManager.createSplit(direction: .right) } registry.register(commandId: "palette.terminalSplitDown") { - if !executeConfiguredAction(id: CmuxSurfaceTabBarBuiltInAction.splitDown.configID) { - tabManager.createSplit(direction: .down) - } + tabManager.createSplit(direction: .down) } registry.register(commandId: "palette.terminalSplitBrowserRight") { _ = tabManager.createBrowserSplit(direction: .right) @@ -8020,68 +7763,24 @@ struct ContentView: View { return } } - - for issue in cmuxConfigStore.configurationIssues { - let captured = issue - registry.register(commandId: commandPaletteCmuxConfigIssueCommandID(issue)) { - openCmuxConfigIssue(captured) - } - } - for action in cmuxConfigStore.paletteCustomActions() { - let captured = action - registry.register(commandId: action.id) { - executeConfiguredAction(captured) - } - } - } - - private func openCmuxConfigIssue(_ issue: CmuxConfigIssue) { - guard let sourcePath = issue.sourcePath, - FileManager.default.fileExists(atPath: sourcePath) else { - NSSound.beep() - return - } - PreferredEditorSettings.open(URL(fileURLWithPath: sourcePath)) - } - - @discardableResult - private func executeConfiguredAction(id: String) -> Bool { - guard let action = cmuxConfigStore.resolvedAction(id: id) else { - return false - } - return executeConfiguredAction(action) - } - - @discardableResult - private func executeConfiguredAction(_ action: CmuxResolvedConfigAction) -> Bool { - let baseCwd = configuredActionBaseCwd() - return CmuxConfigExecutor.execute( - action: action, - commands: cmuxConfigStore.loadedCommands, - commandSourcePaths: cmuxConfigStore.commandSourcePaths, - tabManager: tabManager, - baseCwd: baseCwd, - globalConfigPath: cmuxConfigStore.globalConfigPath - ) - } - - private func configuredActionBaseCwd() -> String { - guard let workspace = tabManager.selectedWorkspace else { - return FileManager.default.homeDirectoryForCurrentUser.path - } - let focusedPanelId = workspace.focusedPanelId - let candidates = [ - focusedPanelId.flatMap { workspace.panelDirectories[$0] }, - focusedPanelId.flatMap { workspace.terminalPanel(for: $0)?.requestedWorkingDirectory }, - workspace.currentDirectory - ] - for candidate in candidates { - let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmed.isEmpty { - return trimmed + + for command in cmuxConfigStore.loadedCommands { + let captured = command + let sourcePath = cmuxConfigStore.commandSourcePaths[command.id] + let globalPath = cmuxConfigStore.globalConfigPath + registry.register(commandId: command.id) { + let rawCwd = tabManager.selectedWorkspace?.currentDirectory + let baseCwd = (rawCwd?.isEmpty == false) ? rawCwd! + : FileManager.default.homeDirectoryForCurrentUser.path + CmuxConfigExecutor.execute( + command: captured, + tabManager: tabManager, + baseCwd: baseCwd, + configSourcePath: sourcePath, + globalConfigPath: globalPath + ) } } - return FileManager.default.homeDirectoryForCurrentUser.path } private var focusedPanelContext: (workspace: Workspace, panelId: UUID, panel: any Panel)? { @@ -8432,7 +8131,7 @@ struct ContentView: View { let newlineCount = commandPaletteWorkspaceDescriptionDraft.reduce(into: 0) { count, character in if character == "\n" { count += 1 } } - cmuxDebugLog( + dlog( "palette.wsDescription.submit.request workspace=\(target.workspaceId.uuidString.prefix(8)) " + "draftLen=\((commandPaletteWorkspaceDescriptionDraft as NSString).length) " + "newlines=\(newlineCount)" @@ -8447,7 +8146,7 @@ struct ContentView: View { private func runCommandPaletteCommand(_ command: CommandPaletteCommand) { #if DEBUG - cmuxDebugLog("palette.run commandId=\(command.id) dismissOnRun=\(command.dismissOnRun ? 1 : 0)") + dlog("palette.run commandId=\(command.id) dismissOnRun=\(command.dismissOnRun ? 1 : 0)") #endif recordCommandPaletteUsage(command.id) command.action() @@ -8504,7 +8203,7 @@ struct ContentView: View { private func openCommandPaletteWorkspaceDescriptionInput() { #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.open begin presented=\(isCommandPalettePresented ? 1 : 0) " + "mode=\(debugCommandPaletteModeLabel(commandPaletteMode)) " + "window={\(debugCommandPaletteWindowSummary(observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow))}" @@ -8515,7 +8214,7 @@ struct ContentView: View { } beginWorkspaceDescriptionFlow() #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.open end presented=\(isCommandPalettePresented ? 1 : 0) " + "mode=\(debugCommandPaletteModeLabel(commandPaletteMode)) " + "focusFlag=\(commandPaletteShouldFocusWorkspaceDescriptionEditor ? 1 : 0)" @@ -8643,7 +8342,7 @@ struct ContentView: View { let newlineCount = commandPaletteWorkspaceDescriptionDraft.reduce(into: 0) { count, character in if character == "\n" { count += 1 } } - cmuxDebugLog( + dlog( "palette.wsDescription.dismiss workspace=\(target.workspaceId.uuidString.prefix(8)) " + "restoreFocus=\(restoreFocus ? 1 : 0) " + "draftLen=\((commandPaletteWorkspaceDescriptionDraft as NSString).length) " + @@ -8699,12 +8398,12 @@ struct ContentView: View { let clickedFocusTarget = commandPaletteBackdropFocusTarget(atContentPoint: contentPoint) #if DEBUG if let clickedFocusTarget { - cmuxDebugLog( + dlog( "palette.dismiss.backdrop focusTarget panel=\(clickedFocusTarget.panelId.uuidString.prefix(5)) " + "workspace=\(clickedFocusTarget.workspaceId.uuidString.prefix(5)) intent=\(debugCommandPaletteFocusIntent(clickedFocusTarget.intent))" ) } else { - cmuxDebugLog("palette.dismiss.backdrop focusTarget=nil") + dlog("palette.dismiss.backdrop focusTarget=nil") } #endif dismissCommandPalette(restoreFocus: true, preferredFocusTarget: clickedFocusTarget) @@ -8907,7 +8606,7 @@ struct ContentView: View { private func resetCommandPaletteWorkspaceDescriptionFocus() { #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.focus.reset schedule presented=\(isCommandPalettePresented ? 1 : 0) " + "mode=\(debugCommandPaletteModeLabel(commandPaletteMode)) " + "focusFlag=\(commandPaletteShouldFocusWorkspaceDescriptionEditor ? 1 : 0)" @@ -8915,7 +8614,7 @@ struct ContentView: View { #endif DispatchQueue.main.async { #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.focus.reset apply.before search=\(isCommandPaletteSearchFocused ? 1 : 0) " + "rename=\(isCommandPaletteRenameFocused ? 1 : 0) " + "editor=\(commandPaletteShouldFocusWorkspaceDescriptionEditor ? 1 : 0) " + @@ -8928,7 +8627,7 @@ struct ContentView: View { commandPaletteShouldFocusWorkspaceDescriptionEditor = true commandPalettePendingTextSelectionBehavior = nil #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.focus.reset apply.after search=\(isCommandPaletteSearchFocused ? 1 : 0) " + "rename=\(isCommandPaletteRenameFocused ? 1 : 0) " + "editor=\(commandPaletteShouldFocusWorkspaceDescriptionEditor ? 1 : 0) " + @@ -9129,13 +8828,6 @@ struct ContentView: View { lastSidebarSelectionIndex = lastIndex tabManager.selectWorkspace(tabs[lastIndex]) sidebarSelectionState.selection = .tabs -#if DEBUG - UITestRecorder.record([ - "sidebarSelectedWorkspaceCount": String(selectedIds.count), - "sidebarSelectedWorkspaceLastIndex": String(lastIndex), - "sidebarWorkspaceCount": String(tabs.count), - ]) -#endif didApplyUITestSidebarSelection = true #endif } @@ -9191,7 +8883,7 @@ struct ContentView: View { private func startWorkspaceDescriptionFlow(_ target: CommandPaletteWorkspaceDescriptionTarget) { #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.flow.start workspace=\(target.workspaceId.uuidString.prefix(8)) " + "descLen=\((target.currentDescription as NSString).length) " + "presented=\(isCommandPalettePresented ? 1 : 0) " + @@ -9204,7 +8896,7 @@ struct ContentView: View { commandPaletteMode = .workspaceDescriptionInput(target) resetCommandPaletteWorkspaceDescriptionFocus() #if DEBUG - cmuxDebugLog( + dlog( "palette.wsDescription.flow.armed workspace=\(target.workspaceId.uuidString.prefix(8)) " + "height=\(String(format: "%.1f", commandPaletteWorkspaceDescriptionHeight)) " + "modeAfter=\(debugCommandPaletteModeLabel(commandPaletteMode))" @@ -9249,7 +8941,7 @@ struct ContentView: View { let newlineCount = proposedDescription.reduce(into: 0) { count, character in if character == "\n" { count += 1 } } - cmuxDebugLog( + dlog( "palette.wsDescription.apply.begin workspace=\(target.workspaceId.uuidString.prefix(8)) " + "proposedLen=\((proposedDescription as NSString).length) " + "newlines=\(newlineCount) " + @@ -9263,7 +8955,7 @@ struct ContentView: View { let persistedNewlineCount = persisted.reduce(into: 0) { count, character in if character == "\n" { count += 1 } } - cmuxDebugLog( + dlog( "palette.wsDescription.apply.end workspace=\(target.workspaceId.uuidString.prefix(8)) " + "persistedLen=\((persisted as NSString).length) " + "persistedNewlines=\(persistedNewlineCount) " + @@ -9361,7 +9053,7 @@ struct ContentView: View { guard let workspace = tabManager.selectedWorkspace else { return nil } let rawDirectory: String = { if let focusedPanelId = workspace.focusedPanelId, - let directory = workspace.panelDirectories[focusedPanelId] { + let directory = workspace.panelDirectory(panelId: focusedPanelId) { return directory } return workspace.currentDirectory @@ -9545,7 +9237,7 @@ struct VerticalTabsSidebar: View { @Binding var selection: SidebarSelection @Binding var selectedTabIds: Set @Binding var lastSidebarSelectionIndex: Int? - @StateObject private var modifierKeyMonitor = WindowScopedShortcutHintModifierMonitor(activation: .commandOnly) + @StateObject private var modifierKeyMonitor = SidebarShortcutHintModifierMonitor() @StateObject private var dragAutoScrollController = SidebarDragAutoScrollController() @StateObject private var dragFailsafeMonitor = SidebarDragFailsafeMonitor() @StateObject private var tabItemSettingsStore = SidebarTabItemSettingsStore() @@ -9565,7 +9257,7 @@ struct VerticalTabsSidebar: View { private let hiddenTitlebarControlsLeadingInset: CGFloat = 72 private var workspaceScrollTopVisibilityInset: CGFloat { - titlebarHeight + 8 + VerticalTabsSidebarLayoutMetrics.workspaceScrollTopVisibilityInset(titlebarHeight: titlebarHeight, isMinimalMode: isMinimalMode) } private var isMinimalMode: Bool { @@ -9600,7 +9292,6 @@ struct VerticalTabsSidebar: View { let rowIds = laidOutWorkspaceRowIds ?? self.laidOutWorkspaceRowIds guard rowIds.contains(selectedWorkspaceId) else { return } - // No anchor means SwiftUI scrolls the minimum needed to reveal the row. proxy.scrollTo(selectedWorkspaceId) pendingSelectedWorkspaceScrollId = nil } @@ -9710,7 +9401,7 @@ struct VerticalTabsSidebar: View { reason: "drag_state_change" ) #if DEBUG - cmuxDebugLog("sidebar.dragState.sidebar tab=\(debugShortSidebarTabId(newDraggedTabId))") + dlog("sidebar.dragState.sidebar tab=\(debugShortSidebarTabId(newDraggedTabId))") #endif if newDraggedTabId != nil { dragFailsafeMonitor.start { @@ -9731,7 +9422,7 @@ struct VerticalTabsSidebar: View { guard draggedTabId != nil else { return } let reason = SidebarDragLifecycleNotification.reason(from: notification) #if DEBUG - cmuxDebugLog("sidebar.dragClear tab=\(debugShortSidebarTabId(draggedTabId)) reason=\(reason)") + dlog("sidebar.dragClear tab=\(debugShortSidebarTabId(draggedTabId)) reason=\(reason)") #endif draggedTabId = nil } @@ -9772,14 +9463,14 @@ struct VerticalTabsSidebar: View { .allowsHitTesting(false) } .overlay(alignment: .top) { - SidebarTopScrim(height: titlebarHeight + 20) + SidebarTopScrim(height: VerticalTabsSidebarLayoutMetrics.sidebarTopScrimHeight(titlebarHeight: titlebarHeight, isMinimalMode: isMinimalMode)) .allowsHitTesting(false) } .overlay(alignment: .top) { // Match native titlebar behavior in the sidebar top strip: // drag-to-move and double-click action (zoom/minimize). WindowDragHandleView() - .frame(height: titlebarHeight) + .frame(height: VerticalTabsSidebarLayoutMetrics.titlebarDragHandleHeight(titlebarHeight: titlebarHeight, isMinimalMode: isMinimalMode)) .background(TitlebarDoubleClickMonitorView()) } .overlay(alignment: .topLeading) { @@ -10009,6 +9700,26 @@ enum ShortcutHintModifierPolicy { } } +enum ShortcutHintModifierActivation { + case commandOrControl + case commandOnly + case controlOnly + + func shouldShowHints( + for modifierFlags: NSEvent.ModifierFlags, + defaults: UserDefaults = .standard + ) -> Bool { + switch self { + case .commandOrControl: + return ShortcutHintModifierPolicy.shouldShowHints(for: modifierFlags, defaults: defaults) + case .commandOnly: + return ShortcutHintModifierPolicy.shouldShowCommandHints(for: modifierFlags, defaults: defaults) + case .controlOnly: + return ShortcutHintModifierPolicy.shouldShowControlHints(for: modifierFlags, defaults: defaults) + } + } +} + enum ShortcutHintDebugSettings { static let sidebarHintXKey = "shortcutHintSidebarXOffset" static let sidebarHintYKey = "shortcutHintSidebarYOffset" @@ -10587,11 +10298,11 @@ private final class SidebarDragFailsafeMonitor: ObservableObject { private func requestClearSoon(reason: String) { guard pendingClearWorkItem == nil else { return } #if DEBUG - cmuxDebugLog("sidebar.dragFailsafe.schedule reason=\(reason)") + dlog("sidebar.dragFailsafe.schedule reason=\(reason)") #endif let workItem = DispatchWorkItem { [weak self] in #if DEBUG - cmuxDebugLog("sidebar.dragFailsafe.fire reason=\(reason)") + dlog("sidebar.dragFailsafe.fire reason=\(reason)") #endif self?.pendingClearWorkItem = nil self?.onRequestClear?(reason) @@ -10638,7 +10349,7 @@ private struct SidebarExternalDropDelegate: DropDelegate { hasSidebarDragPayload: hasSidebarPayload ) #if DEBUG - cmuxDebugLog( + dlog( "sidebar.dropOutside.validate tab=\(debugShortSidebarTabId(draggedTabId)) " + "hasType=\(hasSidebarPayload) allowed=\(shouldReset)" ) @@ -10648,20 +10359,20 @@ private struct SidebarExternalDropDelegate: DropDelegate { func dropEntered(info: DropInfo) { #if DEBUG - cmuxDebugLog("sidebar.dropOutside.entered tab=\(debugShortSidebarTabId(draggedTabId))") + dlog("sidebar.dropOutside.entered tab=\(debugShortSidebarTabId(draggedTabId))") #endif } func dropExited(info: DropInfo) { #if DEBUG - cmuxDebugLog("sidebar.dropOutside.exited tab=\(debugShortSidebarTabId(draggedTabId))") + dlog("sidebar.dropOutside.exited tab=\(debugShortSidebarTabId(draggedTabId))") #endif } func dropUpdated(info: DropInfo) -> DropProposal? { guard validateDrop(info: info) else { return nil } #if DEBUG - cmuxDebugLog("sidebar.dropOutside.updated tab=\(debugShortSidebarTabId(draggedTabId)) op=move") + dlog("sidebar.dropOutside.updated tab=\(debugShortSidebarTabId(draggedTabId)) op=move") #endif // Explicit move proposal avoids AppKit showing a copy (+) cursor. return DropProposal(operation: .move) @@ -10670,7 +10381,7 @@ private struct SidebarExternalDropDelegate: DropDelegate { func performDrop(info: DropInfo) -> Bool { guard validateDrop(info: info) else { return false } #if DEBUG - cmuxDebugLog("sidebar.dropOutside.perform tab=\(debugShortSidebarTabId(draggedTabId))") + dlog("sidebar.dropOutside.perform tab=\(debugShortSidebarTabId(draggedTabId))") #endif SidebarDragLifecycleNotification.postClearRequest(reason: "outside_sidebar_drop") return true @@ -10682,22 +10393,162 @@ private struct SidebarExternalDropDelegate: DropDelegate { } } -enum ShortcutHintModifierActivation { - case commandOrControl - case commandOnly - case controlOnly +@MainActor +private final class SidebarShortcutHintModifierMonitor: ObservableObject { + @Published private(set) var isModifierPressed = false - func shouldShowHints( - for modifierFlags: NSEvent.ModifierFlags, - defaults: UserDefaults = .standard - ) -> Bool { - switch self { - case .commandOrControl: - return ShortcutHintModifierPolicy.shouldShowHints(for: modifierFlags, defaults: defaults) - case .commandOnly: - return ShortcutHintModifierPolicy.shouldShowCommandHints(for: modifierFlags, defaults: defaults) - case .controlOnly: - return ShortcutHintModifierPolicy.shouldShowControlHints(for: modifierFlags, defaults: defaults) + private weak var hostWindow: NSWindow? + private var hostWindowDidBecomeKeyObserver: NSObjectProtocol? + private var hostWindowDidResignKeyObserver: NSObjectProtocol? + private var flagsMonitor: Any? + private var keyDownMonitor: Any? + 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, eventWindow: nil) + return + } + + flagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in + self?.update(from: event.modifierFlags, eventWindow: event.window) + return event + } + + keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + self?.handleKeyDown(event) + return event + } + + appResignObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.cancelPendingHintShow(resetVisible: true) + } + } + + update(from: NSEvent.modifierFlags, eventWindow: nil) + } + + func stop() { + if let flagsMonitor { + NSEvent.removeMonitor(flagsMonitor) + self.flagsMonitor = nil + } + if let keyDownMonitor { + NSEvent.removeMonitor(keyDownMonitor) + self.keyDownMonitor = nil + } + if let appResignObserver { + NotificationCenter.default.removeObserver(appResignObserver) + self.appResignObserver = nil + } + removeHostWindowObservers() + cancelPendingHintShow(resetVisible: true) + } + + private func handleKeyDown(_ event: NSEvent) { + guard isCurrentWindow(eventWindow: event.window) else { return } + cancelPendingHintShow(resetVisible: true) + } + + private func isCurrentWindow(eventWindow: NSWindow?) -> Bool { + ShortcutHintModifierPolicy.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 ShortcutHintModifierPolicy.shouldShowHints( + for: modifierFlags, + hostWindowNumber: hostWindow?.windowNumber, + hostWindowIsKey: hostWindow?.isKeyWindow ?? false, + eventWindowNumber: eventWindow?.windowNumber, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) else { + cancelPendingHintShow(resetVisible: true) + return + } + + queueHintShow() + } + + private func queueHintShow() { + guard !isModifierPressed else { return } + guard pendingShowWorkItem == nil else { return } + + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.pendingShowWorkItem = nil + guard ShortcutHintModifierPolicy.shouldShowHints( + for: NSEvent.modifierFlags, + hostWindowNumber: self.hostWindow?.windowNumber, + hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false, + eventWindowNumber: nil, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) else { return } + self.isModifierPressed = true + } + + pendingShowWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + ShortcutHintModifierPolicy.intentionalHoldDelay, execute: workItem) + } + + private func cancelPendingHintShow(resetVisible: Bool) { + pendingShowWorkItem?.cancel() + pendingShowWorkItem = nil + if resetVisible { + isModifierPressed = false + } + } + + private func removeHostWindowObservers() { + if let hostWindowDidBecomeKeyObserver { + NotificationCenter.default.removeObserver(hostWindowDidBecomeKeyObserver) + self.hostWindowDidBecomeKeyObserver = nil + } + if let hostWindowDidResignKeyObserver { + NotificationCenter.default.removeObserver(hostWindowDidResignKeyObserver) + self.hostWindowDidResignKeyObserver = nil } } } @@ -12312,7 +12163,6 @@ struct SidebarWorkspaceSnapshotBuilder { private final class SidebarTabItemContextMenuState: ObservableObject { var isVisible = false var hasDeferredWorkspaceObservationInvalidation = false - var pendingWorkspaceSnapshot: SidebarWorkspaceSnapshotBuilder.Snapshot? } private struct TabItemView: View, Equatable { @@ -12372,7 +12222,7 @@ private struct TabItemView: View, Equatable { let settings: SidebarTabItemSettingsSnapshot let livePresentation: SidebarTabItemPresentationSnapshot @Binding var frozenPresentation: SidebarTabItemPresentationSnapshot? - @State private var workspaceSnapshotStorage: SidebarWorkspaceSnapshotBuilder.Snapshot? + @State private var workspaceObservationGeneration: UInt64 = 0 @StateObject private var contextMenuState = SidebarTabItemContextMenuState() @State private var isHovering = false @State private var rowHeight: CGFloat = 1 @@ -12413,10 +12263,6 @@ private struct TabItemView: View, Equatable { settings.showsSSH } - private var workspaceSnapshot: SidebarWorkspaceSnapshotBuilder.Snapshot { - workspaceSnapshotStorage ?? makeWorkspaceSnapshot() - } - private var activeTabIndicatorStyle: SidebarActiveTabIndicatorStyle { settings.activeTabIndicatorStyle } @@ -12569,8 +12415,7 @@ private struct TabItemView: View, Equatable { @ViewBuilder private var remoteWorkspaceSection: some View { - let workspaceSnapshot = self.workspaceSnapshot - if sidebarShowSSH, let remoteWorkspaceSidebarText = workspaceSnapshot.remoteWorkspaceSidebarText { + if sidebarShowSSH, let remoteWorkspaceSidebarText { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(remoteWorkspaceSidebarText) @@ -12581,14 +12426,14 @@ private struct TabItemView: View, Equatable { Spacer(minLength: 0) - Text(workspaceSnapshot.remoteConnectionStatusText) + Text(remoteConnectionStatusText) .font(.system(size: 9, weight: .medium)) .foregroundColor(activeSecondaryColor(0.58)) .lineLimit(1) } } .padding(.top, latestNotificationText == nil ? 1 : 2) - .safeHelp(workspaceSnapshot.remoteStateHelpText) + .safeHelp(remoteStateHelpText) } } @@ -12611,13 +12456,13 @@ private struct TabItemView: View, Equatable { } var body: some View { - let workspaceSnapshot = self.workspaceSnapshot + let _ = workspaceObservationGeneration let closeWorkspaceTooltip = String(localized: "sidebar.closeWorkspace.tooltip", defaultValue: "Close Workspace") let protectedWorkspaceTooltip = String( localized: "sidebar.pinnedWorkspaceProtected.tooltip", defaultValue: "Pinned workspace. Closing requires confirmation." ) - let closeButtonTooltip = workspaceSnapshot.isPinned + let closeButtonTooltip = tab.isPinned ? protectedWorkspaceTooltip : KeyboardShortcutSettings.Action.closeWorkspace.tooltip(closeWorkspaceTooltip) let accessibilityHintText = String(localized: "sidebar.workspace.accessibilityHint", defaultValue: "Activate to focus this workspace. Drag to reorder, or use Move Up and Move Down actions.") @@ -12626,6 +12471,43 @@ private struct TabItemView: View, Equatable { let latestNotificationSubtitle = latestNotificationText let effectiveSubtitle = latestNotificationSubtitle let detailVisibility = visibleAuxiliaryDetails + let orderedPanelIds: [UUID]? = (detailVisibility.showsBranchDirectory || detailVisibility.showsPullRequests) + ? tab.sidebarOrderedPanelIds() + : nil + let compactGitBranchSummaryText: String? = { + guard detailVisibility.showsBranchDirectory, + !sidebarBranchVerticalLayout, + sidebarShowGitBranch, + let orderedPanelIds else { + return nil + } + return gitBranchSummaryText(orderedPanelIds: orderedPanelIds) + }() + let compactDirectorySummaryText: String? = { + guard detailVisibility.showsBranchDirectory, + !sidebarBranchVerticalLayout, + let orderedPanelIds else { + return nil + } + return directorySummaryText(orderedPanelIds: orderedPanelIds) + }() + let compactBranchDirectoryRow = branchDirectoryRow( + gitSummary: compactGitBranchSummaryText, + directorySummary: compactDirectorySummaryText + ) + let branchDirectoryLines: [SidebarWorkspaceSnapshotBuilder.VerticalBranchDirectoryLine] = { + guard detailVisibility.showsBranchDirectory, + sidebarBranchVerticalLayout, + let orderedPanelIds else { + return [] + } + return verticalBranchDirectoryLines(orderedPanelIds: orderedPanelIds) + }() + let branchLinesContainBranch = sidebarShowGitBranch && branchDirectoryLines.contains { $0.branch != nil } + let pullRequestRows: [SidebarWorkspaceSnapshotBuilder.PullRequestDisplay] = { + guard detailVisibility.showsPullRequests, let orderedPanelIds else { return [] } + return pullRequestDisplays(orderedPanelIds: orderedPanelIds) + }() VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { @@ -12640,14 +12522,14 @@ private struct TabItemView: View, Equatable { .frame(width: 16, height: 16) } - if workspaceSnapshot.isPinned { + if tab.isPinned { Image(systemName: "pin.fill") .font(.system(size: 9, weight: .semibold)) .foregroundColor(activeSecondaryColor(0.8)) .safeHelp(protectedWorkspaceTooltip) } - Text(workspaceSnapshot.title) + Text(tab.title) .font(.system(size: 12.5, weight: titleFontWeight)) .foregroundColor(activePrimaryTextColor) .lineLimit(1) @@ -12659,7 +12541,7 @@ private struct TabItemView: View, Equatable { ZStack(alignment: .trailing) { Button(action: { #if DEBUG - cmuxDebugLog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=button") + dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=button") #endif tabManager.closeWorkspaceWithConfirmation(tab) }) { @@ -12679,14 +12561,14 @@ private struct TabItemView: View, Equatable { x: ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset), y: ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset) ) - .shortcutHintTransition() + .transition(.opacity) } } - .shortcutHintVisibilityAnimation(value: showsWorkspaceShortcutHint) + .animation(.easeOut(duration: 0.12), value: showsModifierShortcutHints || alwaysShowShortcutHints) .frame(width: trailingAccessoryWidth, height: 16, alignment: .trailing) } - if let description = workspaceSnapshot.customDescription { + if let description = tab.customDescription { SidebarWorkspaceDescriptionText( markdown: description, isActive: usesInvertedActiveForeground @@ -12706,8 +12588,8 @@ private struct TabItemView: View, Equatable { remoteWorkspaceSection if detailVisibility.showsMetadata { - let metadataEntries = workspaceSnapshot.metadataEntries - let metadataBlocks = workspaceSnapshot.metadataBlocks + let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder() + let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder() if !metadataEntries.isEmpty { SidebarMetadataRows( entries: metadataEntries, @@ -12727,7 +12609,7 @@ private struct TabItemView: View, Equatable { } // Latest log entry - if detailVisibility.showsLog, let latestLog = workspaceSnapshot.latestLog { + if detailVisibility.showsLog, let latestLog = tab.logEntries.last { HStack(spacing: 4) { Image(systemName: logLevelIcon(latestLog.level)) .font(.system(size: 8)) @@ -12742,7 +12624,7 @@ private struct TabItemView: View, Equatable { } // Progress bar - if detailVisibility.showsProgress, let progress = workspaceSnapshot.progress { + if detailVisibility.showsProgress, let progress = tab.progress { VStack(alignment: .leading, spacing: 2) { GeometryReader { geo in ZStack(alignment: .leading) { @@ -12768,15 +12650,15 @@ private struct TabItemView: View, Equatable { // Branch + directory row if detailVisibility.showsBranchDirectory { if sidebarBranchVerticalLayout { - if !workspaceSnapshot.branchDirectoryLines.isEmpty { + if !branchDirectoryLines.isEmpty { HStack(alignment: .top, spacing: 3) { - if sidebarShowGitBranchIcon, workspaceSnapshot.branchLinesContainBranch { + if sidebarShowGitBranchIcon, branchLinesContainBranch { Image(systemName: "arrow.triangle.branch") .font(.system(size: 9)) .foregroundColor(activeSecondaryColor(0.6)) } VStack(alignment: .leading, spacing: 1) { - ForEach(Array(workspaceSnapshot.branchDirectoryLines.enumerated()), id: \.offset) { _, line in + ForEach(Array(branchDirectoryLines.enumerated()), id: \.offset) { _, line in HStack(spacing: 3) { if let branch = line.branch { Text(branch) @@ -12803,9 +12685,9 @@ private struct TabItemView: View, Equatable { } } } - } else if let dirRow = workspaceSnapshot.compactBranchDirectoryRow { + } else if let dirRow = compactBranchDirectoryRow { HStack(spacing: 3) { - if sidebarShowGitBranchIcon, workspaceSnapshot.compactGitBranchSummaryText != nil { + if sidebarShowGitBranchIcon, compactGitBranchSummaryText != nil { Image(systemName: "arrow.triangle.branch") .font(.system(size: 9)) .foregroundColor(activeSecondaryColor(0.6)) @@ -12820,9 +12702,9 @@ private struct TabItemView: View, Equatable { } // Pull request rows - if detailVisibility.showsPullRequests, !workspaceSnapshot.pullRequestRows.isEmpty { + if detailVisibility.showsPullRequests, !pullRequestRows.isEmpty { VStack(alignment: .leading, spacing: 1) { - ForEach(workspaceSnapshot.pullRequestRows) { pullRequest in + ForEach(pullRequestRows) { pullRequest in Button(action: { openPullRequestLink(pullRequest.url) }) { @@ -12850,9 +12732,9 @@ private struct TabItemView: View, Equatable { } // Ports row - if detailVisibility.showsPorts, !workspaceSnapshot.listeningPorts.isEmpty { + if detailVisibility.showsPorts, !tab.listeningPorts.isEmpty { HStack(spacing: 4) { - ForEach(workspaceSnapshot.listeningPorts, id: \.self) { port in + ForEach(tab.listeningPorts, id: \.self) { port in Button(action: { openPortLink(port) }) { @@ -12869,9 +12751,9 @@ private struct TabItemView: View, Equatable { .lineLimit(1) } } - .animation(.easeInOut(duration: 0.2), value: workspaceSnapshot.latestLog) - .animation(.easeInOut(duration: 0.2), value: workspaceSnapshot.progress != nil) - .animation(.easeInOut(duration: 0.2), value: workspaceSnapshot.metadataBlocks.count) + .animation(.easeInOut(duration: 0.2), value: tab.logEntries.count) + .animation(.easeInOut(duration: 0.2), value: tab.progress != nil) + .animation(.easeInOut(duration: 0.2), value: tab.metadataBlocks.count) .padding(.horizontal, 10) .padding(.vertical, 8) .background( @@ -12909,7 +12791,7 @@ private struct TabItemView: View, Equatable { .overlay { MiddleClickCapture { #if DEBUG - cmuxDebugLog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=middleClick") + dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=middleClick") #endif tabManager.closeWorkspaceWithConfirmation(tab) } @@ -12923,16 +12805,13 @@ private struct TabItemView: View, Equatable { .offset(y: index == 0 ? 0 : -(rowSpacing / 2)) } } - .onAppear { - refreshWorkspaceSnapshot(force: true) - } .onReceive( tab.sidebarImmediateObservationPublisher .receive(on: RunLoop.main) ) { _ in #if DEBUG let description = tab.customDescription ?? "" - cmuxDebugLog( + dlog( "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + "source=immediate " + "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + @@ -12940,7 +12819,7 @@ private struct TabItemView: View, Equatable { "desc=\"\(debugCommandPaletteTextPreview(description))\"" ) #endif - refreshWorkspaceSnapshot() + scheduleWorkspaceObservationInvalidation() } .onReceive( tab.sidebarObservationPublisher @@ -12952,7 +12831,7 @@ private struct TabItemView: View, Equatable { ) { _ in #if DEBUG let description = tab.customDescription ?? "" - cmuxDebugLog( + dlog( "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + "source=debounced " + "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + @@ -12960,14 +12839,11 @@ private struct TabItemView: View, Equatable { "desc=\"\(debugCommandPaletteTextPreview(description))\"" ) #endif - refreshWorkspaceSnapshot() - } - .onChange(of: settings) { _ in - refreshWorkspaceSnapshot(force: true) + scheduleWorkspaceObservationInvalidation() } .onDrag { #if DEBUG - cmuxDebugLog("sidebar.onDrag tab=\(tab.id.uuidString.prefix(5))") + dlog("sidebar.onDrag tab=\(tab.id.uuidString.prefix(5))") #endif draggedTabId = tab.id dropIndicator = nil @@ -12984,7 +12860,7 @@ private struct TabItemView: View, Equatable { dragAutoScrollController: dragAutoScrollController, dropIndicator: $dropIndicator )) - .onDrop(of: BonsplitTabDragPayload.dropContentTypes, delegate: SidebarBonsplitTabDropDelegate( + .onDrop(of: WorkspaceSplitTabDragPayload.dropContentTypes, delegate: SidebarWorkspaceSplitTabDropDelegate( targetWorkspaceId: tab.id, tabManager: tabManager, selectedTabIds: $selectedTabIds, @@ -13011,7 +12887,6 @@ private struct TabItemView: View, Equatable { .onAppear { contextMenuState.isVisible = true contextMenuState.hasDeferredWorkspaceObservationInvalidation = false - contextMenuState.pendingWorkspaceSnapshot = nil frozenPresentation = livePresentation } .onDisappear { @@ -13025,41 +12900,19 @@ private struct TabItemView: View, Equatable { } } - private func refreshWorkspaceSnapshot(force: Bool = false) { - let nextSnapshot = makeWorkspaceSnapshot() - + private func scheduleWorkspaceObservationInvalidation() { + // Keep the context menu stable while background workspace telemetry keeps arriving. if contextMenuState.isVisible { - let deferredBaseline = contextMenuState.pendingWorkspaceSnapshot ?? workspaceSnapshotStorage - // Color changes are driven by explicit clicks in the Workspace Color - // submenu, and SwiftUI's context-menu content does not reliably fire - // `.onDisappear` after a button tap (issue #3037). Apply color - // changes immediately so the row reflects the user's selection - // instead of waiting on a flush that may never happen. - if deferredBaseline?.customColorHex != nextSnapshot.customColorHex { - workspaceSnapshotStorage = nextSnapshot - contextMenuState.pendingWorkspaceSnapshot = nil - contextMenuState.hasDeferredWorkspaceObservationInvalidation = false - return - } - if force || deferredBaseline != nextSnapshot { - contextMenuState.hasDeferredWorkspaceObservationInvalidation = true - contextMenuState.pendingWorkspaceSnapshot = nextSnapshot - } + contextMenuState.hasDeferredWorkspaceObservationInvalidation = true return } - - if force || workspaceSnapshotStorage != nextSnapshot { - workspaceSnapshotStorage = nextSnapshot - } + workspaceObservationGeneration &+= 1 } private func flushDeferredWorkspaceObservationInvalidation() { guard contextMenuState.hasDeferredWorkspaceObservationInvalidation else { return } contextMenuState.hasDeferredWorkspaceObservationInvalidation = false - if let pendingSnapshot = contextMenuState.pendingWorkspaceSnapshot { - workspaceSnapshotStorage = pendingSnapshot - } - contextMenuState.pendingWorkspaceSnapshot = nil + workspaceObservationGeneration &+= 1 } private func contextMenuLabel(multi: String, single: String, isMulti: Bool) -> String { @@ -13228,7 +13081,7 @@ private struct TabItemView: View, Equatable { } } - if let copyableSidebarSSHError = workspaceSnapshot.copyableSidebarSSHError { + if let copyableSidebarSSHError { Button(String(localized: "contextMenu.copySshError", defaultValue: "Copy SSH Error")) { copyTextToPasteboard(copyableSidebarSSHError) } @@ -13331,17 +13184,28 @@ private struct TabItemView: View, Equatable { .disabled(targetIds.isEmpty) } + private var selectionBackgroundColor: NSColor { + if let hex = sidebarSelectionColorHex, let parsed = NSColor(hex: hex) { + return parsed + } + return cmuxAccentNSColor(for: colorScheme) + } + private var backgroundColor: Color { - let style = sidebarWorkspaceRowBackgroundStyle( - activeTabIndicatorStyle: activeTabIndicatorStyle, - isActive: isActive, - isMultiSelected: isMultiSelected, - customColorHex: workspaceSnapshot.customColorHex, - colorScheme: colorScheme, - sidebarSelectionColorHex: sidebarSelectionColorHex - ) - guard let color = style.color else { return .clear } - return Color(nsColor: color).opacity(style.opacity) + switch activeTabIndicatorStyle { + case .leftRail: + if isActive { return Color(nsColor: selectionBackgroundColor) } + if isMultiSelected { return cmuxAccentColor().opacity(0.25) } + return Color.clear + case .solidFill: + if isActive { return Color(nsColor: selectionBackgroundColor) } + if let custom = resolvedCustomTabColor { + if isMultiSelected { return custom.opacity(0.35) } + return custom.opacity(0.7) + } + if isMultiSelected { return cmuxAccentColor().opacity(0.25) } + return Color.clear + } } private var railColor: Color { @@ -13349,14 +13213,20 @@ private struct TabItemView: View, Equatable { } private var explicitRailColor: Color? { - guard let railColor = sidebarWorkspaceRowExplicitRailNSColor( - activeTabIndicatorStyle: activeTabIndicatorStyle, - customColorHex: workspaceSnapshot.customColorHex, - colorScheme: colorScheme - ) else { + guard activeTabIndicatorStyle == .leftRail, + let custom = resolvedCustomTabColor else { return nil } - return Color(nsColor: railColor).opacity(0.95) + return custom.opacity(0.95) + } + + private var resolvedCustomTabColor: Color? { + guard let hex = tab.customColor else { return nil } + return WorkspaceTabColorSettings.displayColor( + hex: hex, + colorScheme: colorScheme, + forceBright: activeTabIndicatorStyle == .leftRail + ) } private func tabColorSwatchColor(for hex: String) -> NSColor { @@ -13383,7 +13253,7 @@ private struct TabItemView: View, Equatable { } private var accessibilityTitle: String { - String(localized: "accessibility.workspacePosition", defaultValue: "\(workspaceSnapshot.title), workspace \(index + 1) of \(accessibilityWorkspaceCount)") + String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(accessibilityWorkspaceCount)") } private func moveBy(_ delta: Int) { @@ -13404,7 +13274,7 @@ private struct TabItemView: View, Equatable { if mods.contains(.shift) { modStr += "shift " } if mods.contains(.option) { modStr += "opt " } if mods.contains(.control) { modStr += "ctrl " } - cmuxDebugLog("sidebar.select workspace=\(tab.id.uuidString.prefix(5)) modifiers=\(modStr.isEmpty ? "none" : modStr.trimmingCharacters(in: .whitespaces))") + dlog("sidebar.select workspace=\(tab.id.uuidString.prefix(5)) modifiers=\(modStr.isEmpty ? "none" : modStr.trimmingCharacters(in: .whitespaces))") #endif let modifiers = NSEvent.modifierFlags let isCommand = modifiers.contains(.command) @@ -14055,7 +13925,7 @@ private struct SidebarWorkspaceDescriptionText: View { let newlineCount = markdown.reduce(into: 0) { count, character in if character == "\n" { count += 1 } } - cmuxDebugLog( + dlog( "sidebar.description.render workspaceState=appear " + "len=\((markdown as NSString).length) " + "newlines=\(newlineCount) " + @@ -14068,7 +13938,7 @@ private struct SidebarWorkspaceDescriptionText: View { let newlineCount = newValue.reduce(into: 0) { count, character in if character == "\n" { count += 1 } } - cmuxDebugLog( + dlog( "sidebar.description.render workspaceState=change " + "len=\((newValue as NSString).length) " + "newlines=\(newlineCount) " + @@ -14657,7 +14527,7 @@ private enum SidebarTabDragPayload { } } -private enum BonsplitTabDragPayload { +private enum WorkspaceSplitTabDragPayload { static let typeIdentifier = "com.splittabbar.tabtransfer" static let dropContentType = UTType(exportedAs: typeIdentifier) static let dropContentTypes: [UTType] = [dropContentType] @@ -14712,15 +14582,15 @@ private enum BonsplitTabDragPayload { } } -private struct SidebarBonsplitTabDropDelegate: DropDelegate { +private struct SidebarWorkspaceSplitTabDropDelegate: DropDelegate { let targetWorkspaceId: UUID let tabManager: TabManager @Binding var selectedTabIds: Set @Binding var lastSidebarSelectionIndex: Int? func validateDrop(info: DropInfo) -> Bool { - guard info.hasItemsConforming(to: [BonsplitTabDragPayload.typeIdentifier]) else { return false } - return BonsplitTabDragPayload.currentTransfer() != nil + guard info.hasItemsConforming(to: [WorkspaceSplitTabDragPayload.typeIdentifier]) else { return false } + return WorkspaceSplitTabDragPayload.currentTransfer() != nil } func dropUpdated(info: DropInfo) -> DropProposal? { @@ -14730,18 +14600,18 @@ private struct SidebarBonsplitTabDropDelegate: DropDelegate { func performDrop(info: DropInfo) -> Bool { guard validateDrop(info: info), - let transfer = BonsplitTabDragPayload.currentTransfer(), + let transfer = WorkspaceSplitTabDragPayload.currentTransfer(), let app = AppDelegate.shared else { return false } - if let source = app.locateBonsplitSurface(tabId: transfer.tab.id), + if let source = app.locateSplitSurface(tabId: transfer.tab.id), source.workspaceId == targetWorkspaceId { syncSidebarSelection() return true } - guard app.moveBonsplitTab( + guard app.moveSplitTab( tabId: transfer.tab.id, toWorkspace: targetWorkspaceId, focus: true, @@ -14778,14 +14648,14 @@ private struct SidebarTabDropDelegate: DropDelegate { let hasType = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) let hasDrag = draggedTabId != nil #if DEBUG - cmuxDebugLog("sidebar.validateDrop target=\(targetTabId?.uuidString.prefix(5) ?? "end") hasType=\(hasType) hasDrag=\(hasDrag)") + dlog("sidebar.validateDrop target=\(targetTabId?.uuidString.prefix(5) ?? "end") hasType=\(hasType) hasDrag=\(hasDrag)") #endif return hasType && hasDrag } func dropEntered(info: DropInfo) { #if DEBUG - cmuxDebugLog("sidebar.dropEntered target=\(targetTabId?.uuidString.prefix(5) ?? "end")") + dlog("sidebar.dropEntered target=\(targetTabId?.uuidString.prefix(5) ?? "end")") #endif dragAutoScrollController.updateFromDragLocation() updateDropIndicator(for: info) @@ -14793,7 +14663,7 @@ private struct SidebarTabDropDelegate: DropDelegate { func dropExited(info: DropInfo) { #if DEBUG - cmuxDebugLog("sidebar.dropExited target=\(targetTabId?.uuidString.prefix(5) ?? "end")") + dlog("sidebar.dropExited target=\(targetTabId?.uuidString.prefix(5) ?? "end")") #endif if dropIndicator?.tabId == targetTabId { dropIndicator = nil @@ -14804,7 +14674,7 @@ private struct SidebarTabDropDelegate: DropDelegate { dragAutoScrollController.updateFromDragLocation() updateDropIndicator(for: info) #if DEBUG - cmuxDebugLog( + dlog( "sidebar.dropUpdated target=\(targetTabId?.uuidString.prefix(5) ?? "end") " + "indicator=\(debugIndicator(dropIndicator))" ) @@ -14819,17 +14689,17 @@ private struct SidebarTabDropDelegate: DropDelegate { dragAutoScrollController.stop() } #if DEBUG - cmuxDebugLog("sidebar.drop target=\(targetTabId?.uuidString.prefix(5) ?? "end")") + dlog("sidebar.drop target=\(targetTabId?.uuidString.prefix(5) ?? "end")") #endif guard let draggedTabId else { #if DEBUG - cmuxDebugLog("sidebar.drop.abort reason=missingDraggedTab") + dlog("sidebar.drop.abort reason=missingDraggedTab") #endif return false } guard let fromIndex = tabManager.tabs.firstIndex(where: { $0.id == draggedTabId }) else { #if DEBUG - cmuxDebugLog("sidebar.drop.abort reason=draggedTabMissing tab=\(draggedTabId.uuidString.prefix(5))") + dlog("sidebar.drop.abort reason=draggedTabMissing tab=\(draggedTabId.uuidString.prefix(5))") #endif return false } @@ -14842,7 +14712,7 @@ private struct SidebarTabDropDelegate: DropDelegate { pinnedTabIds: Set(tabManager.tabs.filter(\.isPinned).map(\.id)) ) else { #if DEBUG - cmuxDebugLog( + dlog( "sidebar.drop.abort reason=noTargetIndex tab=\(draggedTabId.uuidString.prefix(5)) " + "target=\(targetTabId?.uuidString.prefix(5) ?? "end") indicator=\(debugIndicator(dropIndicator))" ) @@ -14852,14 +14722,14 @@ private struct SidebarTabDropDelegate: DropDelegate { guard fromIndex != targetIndex else { #if DEBUG - cmuxDebugLog("sidebar.drop.noop from=\(fromIndex) to=\(targetIndex)") + dlog("sidebar.drop.noop from=\(fromIndex) to=\(targetIndex)") #endif syncSidebarSelection() return true } #if DEBUG - cmuxDebugLog("sidebar.drop.commit tab=\(draggedTabId.uuidString.prefix(5)) from=\(fromIndex) to=\(targetIndex)") + dlog("sidebar.drop.commit tab=\(draggedTabId.uuidString.prefix(5)) from=\(fromIndex) to=\(targetIndex)") #endif _ = tabManager.reorderWorkspace(tabId: draggedTabId, toIndex: targetIndex) if let selectedId = tabManager.selectedTabId { @@ -15089,7 +14959,7 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { #if DEBUG let nowMovable = window.map { String($0.isMovable) } ?? "nil" let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil" - cmuxDebugLog("folder.dragEnd dir=\(directory) operation=\(operation.rawValue) screen=\(formatPoint(screenPoint)) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)") + dlog("folder.dragEnd dir=\(directory) operation=\(operation.rawValue) screen=\(formatPoint(screenPoint)) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)") #endif } @@ -15101,7 +14971,7 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { let imageHit = (hit === imageView) let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" let nowMovable = window.map { String($0.isMovable) } ?? "nil" - cmuxDebugLog("folder.hitTest point=\(formatPoint(point)) hit=\(hitDesc) imageViewHit=\(imageHit) returning=DraggableFolderNSView wasMovable=\(wasMovable) nowMovable=\(nowMovable)") + dlog("folder.hitTest point=\(formatPoint(point)) hit=\(hitDesc) imageViewHit=\(imageHit) returning=DraggableFolderNSView wasMovable=\(wasMovable) nowMovable=\(nowMovable)") #endif return self } @@ -15115,7 +14985,7 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" let nowMovable = window.map { String($0.isMovable) } ?? "nil" let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil" - cmuxDebugLog("folder.mouseDown dir=\(directory) point=\(formatPoint(localPoint)) firstResponder=\(responderDesc) wasMovable=\(wasMovable) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)") + dlog("folder.mouseDown dir=\(directory) point=\(formatPoint(localPoint)) firstResponder=\(responderDesc) wasMovable=\(wasMovable) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)") #endif let fileURL = URL(fileURLWithPath: directory) let draggingItem = NSDraggingItem(pasteboardWriter: fileURL as NSURL) @@ -15128,7 +14998,7 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { hasActiveDragSession = true #if DEBUG let itemCount = session.draggingPasteboard.pasteboardItems?.count ?? 0 - cmuxDebugLog("folder.dragStart dir=\(directory) pasteboardItems=\(itemCount)") + dlog("folder.dragStart dir=\(directory) pasteboardItems=\(itemCount)") #endif } @@ -15216,7 +15086,7 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { self.didArmWindowDragSuppression = false #if DEBUG let nowMovable = targetWindow.map { String($0.isMovable) } ?? "nil" - cmuxDebugLog("folder.dragSuppression restore depth=\(depthAfter) nowMovable=\(nowMovable)") + dlog("folder.dragSuppression restore depth=\(depthAfter) nowMovable=\(nowMovable)") #endif } @@ -15239,7 +15109,7 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { #if DEBUG let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" let nowMovable = String(currentWindow.isMovable) - cmuxDebugLog( + dlog( "folder.dragSuppression trigger=\(trigger) event=\(eventType) depth=\(suppressionDepth) wasMovable=\(wasMovable) nowMovable=\(nowMovable)" ) #endif @@ -15359,9 +15229,11 @@ private struct TitlebarLeadingInsetReader: NSViewRepresentable { // Start past the traffic lights var leading: CGFloat = 78 // Add width of all left-aligned titlebar accessories - for accessory in window.titlebarAccessoryViewControllers - where accessory.layoutAttribute == .leading || accessory.layoutAttribute == .left { - leading += accessory.view.frame.width + if window.cmuxSupportsTitlebarAccessoryControllers { + for accessory in window.titlebarAccessoryViewControllers + where accessory.layoutAttribute == .leading || accessory.layoutAttribute == .left { + leading += accessory.view.frame.width + } } leading += 0 if leading != inset { diff --git a/Sources/Feed/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index 21b82be275c9..97d60fc9064a 100644 --- a/Sources/Feed/FeedPanelView.swift +++ b/Sources/Feed/FeedPanelView.swift @@ -1,5 +1,4 @@ import AppKit -import Bonsplit import CMUXWorkstream import SwiftUI diff --git a/Sources/FileExplorerView.swift b/Sources/FileExplorerView.swift index 3b7ffff0580d..0b2b3bb4368d 100644 --- a/Sources/FileExplorerView.swift +++ b/Sources/FileExplorerView.swift @@ -1,5 +1,4 @@ import AppKit -import Bonsplit import Combine import SwiftUI diff --git a/Sources/Find/BrowserSearchOverlay.swift b/Sources/Find/BrowserSearchOverlay.swift index 6a25ba987c48..eeb668ed37fc 100644 --- a/Sources/Find/BrowserSearchOverlay.swift +++ b/Sources/Find/BrowserSearchOverlay.swift @@ -1,20 +1,79 @@ import AppKit -import Bonsplit +import ObjectiveC import SwiftUI +private var cmuxBrowserSearchOverlayPanelIdKey: UInt8 = 0 +private var cmuxBrowserSearchOverlayWindowActivePanelIdKey: UInt8 = 0 + +func setBrowserSearchOverlayPanelId(_ panelId: UUID, on view: NSView) { + objc_setAssociatedObject( + view, + &cmuxBrowserSearchOverlayPanelIdKey, + panelId, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) +} + +private func setActiveBrowserSearchOverlayPanelId(_ panelId: UUID?, on window: NSWindow?) { + guard let window else { return } + objc_setAssociatedObject( + window, + &cmuxBrowserSearchOverlayWindowActivePanelIdKey, + panelId, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) +} + +private func activeBrowserSearchOverlayPanelId(on window: NSWindow?) -> UUID? { + guard let window else { return nil } + return objc_getAssociatedObject( + window, + &cmuxBrowserSearchOverlayWindowActivePanelIdKey + ) as? UUID +} + +private func clearActiveBrowserSearchOverlayPanelId(_ panelId: UUID, on window: NSWindow?) { + guard activeBrowserSearchOverlayPanelId(on: window) == panelId else { return } + setActiveBrowserSearchOverlayPanelId(nil, on: window) +} + +private func browserSearchOverlayPanelId(from view: NSView?) -> UUID? { + var current = view + while let view = current { + if let panelId = objc_getAssociatedObject(view, &cmuxBrowserSearchOverlayPanelIdKey) as? UUID { + return panelId + } + current = view.superview + } + return nil +} + +func browserSearchOverlayPanelId(for responder: NSResponder?) -> UUID? { + guard let responder else { return nil } + if let editor = responder as? NSTextView, + editor.isFieldEditor { + return browserSearchOverlayPanelId(from: editor) ?? + browserSearchOverlayPanelId(from: editor.delegate as? NSView) ?? + activeBrowserSearchOverlayPanelId(on: editor.window) + } + return browserSearchOverlayPanelId(from: responder as? NSView) +} + struct BrowserSearchOverlay: View { let panelId: UUID @ObservedObject var searchState: BrowserSearchState - let focusRequestGeneration: UInt64 - let canApplyFocusRequest: (UInt64) -> Bool + let focusRequestId: UUID? + let canApplyFocusRequest: (UUID) -> Bool let onNext: () -> Void let onPrevious: () -> Void let onClose: () -> Void - let onFieldDidFocus: () -> Void + let onFieldMounted: (UUID?) -> Void + let onFieldDidFocus: (UUID?) -> Void + let onFieldDidEndEditing: (UUID?) -> Void + let onDisappear: () -> Void @State private var corner: Corner = .topRight @State private var dragOffset: CGSize = .zero @State private var barSize: CGSize = .zero - @State private var isSearchFieldFocused: Bool = true private let padding: CGFloat = 8 @@ -23,10 +82,10 @@ struct BrowserSearchOverlay: View { HStack(spacing: 4) { BrowserSearchTextFieldRepresentable( text: $searchState.needle, - isFocused: $isSearchFieldFocused, panelId: panelId, - focusRequestGeneration: focusRequestGeneration, + focusRequestId: focusRequestId, canApplyFocusRequest: canApplyFocusRequest, + onFieldMounted: onFieldMounted, onFieldDidFocus: onFieldDidFocus, onEscape: onClose, onReturn: { isShift in @@ -35,7 +94,8 @@ struct BrowserSearchOverlay: View { } else { onNext() } - } + }, + onFieldDidEndEditing: onFieldDidEndEditing ) .frame(width: 180) .padding(.leading, 8) @@ -60,9 +120,6 @@ struct BrowserSearchOverlay: View { } } Button(action: { - #if DEBUG - cmuxDebugLog("browser.findbar.next panel=\(panelId.uuidString.prefix(5))") - #endif onNext() }) { Image(systemName: "chevron.up") @@ -71,9 +128,6 @@ struct BrowserSearchOverlay: View { .safeHelp("Next match (Return)") Button(action: { - #if DEBUG - cmuxDebugLog("browser.findbar.prev panel=\(panelId.uuidString.prefix(5))") - #endif onPrevious() }) { Image(systemName: "chevron.down") @@ -82,9 +136,6 @@ struct BrowserSearchOverlay: View { .safeHelp("Previous match (Shift+Return)") Button(action: { - #if DEBUG - cmuxDebugLog("browser.findbar.close panel=\(panelId.uuidString.prefix(5))") - #endif onClose() }) { Image(systemName: "xmark") @@ -96,12 +147,6 @@ struct BrowserSearchOverlay: View { .background(.background) .clipShape(clipShape) .shadow(radius: 4) - .onAppear { -#if DEBUG - cmuxDebugLog("browser.findbar.appear panel=\(panelId.uuidString.prefix(5))") -#endif - isSearchFieldFocused = true - } .background( GeometryReader { barGeo in Color.clear.onAppear { @@ -131,6 +176,7 @@ struct BrowserSearchOverlay: View { } ) } + .onDisappear(perform: onDisappear) } private var clipShape: some Shape { @@ -181,6 +227,8 @@ struct BrowserSearchOverlay: View { } private final class BrowserSearchNativeTextField: NSTextField { + var onWindowAttachment: ((BrowserSearchNativeTextField) -> Void)? + override init(frame frameRect: NSRect) { super.init(frame: frameRect) isBordered = false @@ -193,45 +241,149 @@ private final class BrowserSearchNativeTextField: NSTextField { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard window != nil else { return } + onWindowAttachment?(self) + } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + let flags = event.modifierFlags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + if flags.contains(.command) { + if AppDelegate.shared?.handlePaneFocusNavigationCommandEvent( + event, + preferredWindow: window, + source: "browserFindTextField" + ) == true { + return true + } + if AppDelegate.shared?.handleBrowserSearchOverlayCommandEvent( + event, + preferredWindow: window + ) == true { + return true + } + if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true { + return true + } + } + return super.performKeyEquivalent(with: event) + } } private struct BrowserSearchTextFieldRepresentable: NSViewRepresentable { @Binding var text: String - @Binding var isFocused: Bool let panelId: UUID - let focusRequestGeneration: UInt64 - let canApplyFocusRequest: (UInt64) -> Bool - let onFieldDidFocus: () -> Void + let focusRequestId: UUID? + let canApplyFocusRequest: (UUID) -> Bool + let onFieldMounted: (UUID?) -> Void + let onFieldDidFocus: (UUID?) -> Void let onEscape: () -> Void let onReturn: (_ isShift: Bool) -> Void + let onFieldDidEndEditing: (UUID?) -> Void final class Coordinator: NSObject, NSTextFieldDelegate { var parent: BrowserSearchTextFieldRepresentable var isProgrammaticMutation = false weak var parentField: BrowserSearchNativeTextField? var pendingFocusRequest: Bool? - var searchFocusObserver: NSObjectProtocol? + var activeFocusRequestId: UUID? init(parent: BrowserSearchTextFieldRepresentable) { self.parent = parent } - deinit { - if let searchFocusObserver { - NotificationCenter.default.removeObserver(searchFocusObserver) - } + private func markFieldEditor(_ editor: NSTextView?) { + guard let editor else { return } + setBrowserSearchOverlayPanelId(parent.panelId, on: editor) + setActiveBrowserSearchOverlayPanelId(parent.panelId, on: editor.window) } - func focusField(_ field: BrowserSearchNativeTextField, in window: NSWindow) { - guard window.makeFirstResponder(field) else { return } + func focusField(_ field: BrowserSearchNativeTextField, in window: NSWindow, requestId: UUID?) { + let before = window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let focused = window.makeFirstResponder(field) +#if DEBUG + dlog( + "browser.focus.find.fieldFocus panel=\(parent.panelId.uuidString.prefix(5)) " + + "request=\((requestId ?? parent.focusRequestId)?.uuidString.prefix(8) ?? "nil") " + + "result=\(focused ? 1 : 0) before=\(before) " + + "after=\(window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" + ) +#endif + guard focused else { return } + activeFocusRequestId = requestId ?? parent.focusRequestId + setBrowserSearchOverlayPanelId(parent.panelId, on: field) + setActiveBrowserSearchOverlayPanelId(parent.panelId, on: window) + parent.onFieldDidFocus(activeFocusRequestId) DispatchQueue.main.async { [weak field] in guard let field, let editor = field.currentEditor() as? NSTextView else { return } + self.markFieldEditor(editor) let end = field.stringValue.utf16.count editor.setSelectedRange(NSRange(location: end, length: 0)) } } + func requestFocusIfNeeded(for field: BrowserSearchNativeTextField) { + guard let requestId = parent.focusRequestId, + parent.canApplyFocusRequest(requestId), + let window = field.window else { +#if DEBUG + dlog( + "browser.focus.find.fieldFocus.skip panel=\(parent.panelId.uuidString.prefix(5)) " + + "request=\(parent.focusRequestId?.uuidString.prefix(8) ?? "nil") " + + "reason=not_applicable" + ) +#endif + return + } + + let fr = window.firstResponder + let isFirstResponder = + fr === field || + field.currentEditor() != nil || + ((fr as? NSTextView)?.delegate as? NSTextField) === field + guard !isFirstResponder, pendingFocusRequest != true else { +#if DEBUG + dlog( + "browser.focus.find.fieldFocus.skip panel=\(parent.panelId.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8)) reason=\(isFirstResponder ? "already_focused" : "pending") " + + "fr=\(fr.map { String(describing: type(of: $0)) } ?? "nil")" + ) +#endif + return + } + + pendingFocusRequest = true + DispatchQueue.main.async { [weak field, weak self] in + guard let self else { return } + self.pendingFocusRequest = nil + guard let requestId = self.parent.focusRequestId, + self.parent.canApplyFocusRequest(requestId), + let field, + let window = field.window else { return } + let fr = window.firstResponder + let alreadyFocused = + fr === field || + field.currentEditor() != nil || + ((fr as? NSTextView)?.delegate as? NSTextField) === field + guard !alreadyFocused else { +#if DEBUG + dlog( + "browser.focus.find.fieldFocus.skip panel=\(self.parent.panelId.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8)) reason=already_focused_async " + + "fr=\(fr.map { String(describing: type(of: $0)) } ?? "nil")" + ) +#endif + return + } + self.focusField(field, in: window, requestId: requestId) + } + } + func controlTextDidChange(_ obj: Notification) { guard !isProgrammaticMutation else { return } guard let field = obj.object as? NSTextField else { return } @@ -239,23 +391,60 @@ private struct BrowserSearchTextFieldRepresentable: NSViewRepresentable { } func controlTextDidBeginEditing(_ obj: Notification) { - parent.onFieldDidFocus() - if !parent.isFocused { - DispatchQueue.main.async { - self.parent.isFocused = true - } + activeFocusRequestId = parent.focusRequestId ?? activeFocusRequestId + if let field = obj.object as? NSTextField { + setActiveBrowserSearchOverlayPanelId(parent.panelId, on: field.window) + markFieldEditor(field.currentEditor() as? NSTextView) } +#if DEBUG + let window = (obj.object as? NSView)?.window + dlog( + "browser.focus.find.fieldBeginEditing panel=\(parent.panelId.uuidString.prefix(5)) " + + "request=\((activeFocusRequestId ?? parent.focusRequestId)?.uuidString.prefix(8) ?? "nil") " + + "fr=\(window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" + ) +#endif + parent.onFieldDidFocus(activeFocusRequestId ?? parent.focusRequestId) } func controlTextDidEndEditing(_ obj: Notification) { - if parent.isFocused { - DispatchQueue.main.async { - self.parent.isFocused = false - } - } + let requestId = activeFocusRequestId ?? parent.focusRequestId +#if DEBUG + let window = (obj.object as? NSView)?.window + dlog( + "browser.focus.find.fieldEndEditing panel=\(parent.panelId.uuidString.prefix(5)) " + + "request=\(requestId?.uuidString.prefix(8) ?? "nil") " + + "fr=\(window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" + ) +#endif + clearActiveBrowserSearchOverlayPanelId(parent.panelId, on: (obj.object as? NSView)?.window) + parent.onFieldDidEndEditing(requestId) + activeFocusRequestId = nil } func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + markFieldEditor(textView) + if let event = NSApp.currentEvent, + event.type == .keyDown, + event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command) { + if AppDelegate.shared?.handlePaneFocusNavigationCommandEvent( + event, + preferredWindow: textView.window, + source: "browserFindFieldEditor" + ) == true { + return true + } + if AppDelegate.shared?.handleBrowserSearchOverlayCommandEvent( + event, + preferredWindow: textView.window + ) == true { + return true + } + if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true { + return true + } + } + switch commandSelector { case #selector(NSResponder.cancelOperation(_:)): if textView.hasMarkedText() { return false } @@ -280,6 +469,7 @@ private struct BrowserSearchTextFieldRepresentable: NSViewRepresentable { let field = BrowserSearchNativeTextField(frame: .zero) field.font = .systemFont(ofSize: NSFont.systemFontSize) field.placeholderString = String(localized: "search.placeholder", defaultValue: "Search") + field.identifier = NSUserInterfaceItemIdentifier("BrowserFindSearchTextField") field.setAccessibilityIdentifier("BrowserFindSearchTextField") field.delegate = context.coordinator field.target = nil @@ -288,32 +478,24 @@ private struct BrowserSearchTextFieldRepresentable: NSViewRepresentable { field.isSelectable = true field.isEnabled = true field.stringValue = text - context.coordinator.parentField = field - context.coordinator.searchFocusObserver = NotificationCenter.default.addObserver( - forName: .browserSearchFocus, - object: nil, - queue: .main - ) { [weak field, weak coordinator = context.coordinator] notification in - guard let field, let coordinator else { return } - guard let notifiedPanelId = notification.object as? UUID, - notifiedPanelId == coordinator.parent.panelId else { return } - guard coordinator.parent.canApplyFocusRequest(coordinator.parent.focusRequestGeneration) else { return } - guard let window = field.window else { return } - let fr = window.firstResponder - let alreadyFocused = fr === field || - field.currentEditor() != nil || - ((fr as? NSTextView)?.delegate as? NSTextField) === field - guard !alreadyFocused else { return } - coordinator.focusField(field, in: window) + field.onWindowAttachment = { [weak coordinator = context.coordinator] attachedField in + guard let coordinator else { return } + coordinator.parent.onFieldMounted(coordinator.parent.focusRequestId) + coordinator.requestFocusIfNeeded(for: attachedField) } + setBrowserSearchOverlayPanelId(panelId, on: field) + context.coordinator.parentField = field return field } func updateNSView(_ nsView: BrowserSearchNativeTextField, context: Context) { context.coordinator.parent = self context.coordinator.parentField = nsView + setBrowserSearchOverlayPanelId(panelId, on: nsView) if let editor = nsView.currentEditor() as? NSTextView { + setActiveBrowserSearchOverlayPanelId(panelId, on: nsView.window) + setBrowserSearchOverlayPanelId(panelId, on: editor) if editor.string != text, !editor.hasMarkedText() { context.coordinator.isProgrammaticMutation = true editor.string = text @@ -324,41 +506,24 @@ private struct BrowserSearchTextFieldRepresentable: NSViewRepresentable { nsView.stringValue = text } - if let window = nsView.window { - let fr = window.firstResponder - let isFirstResponder = - fr === nsView || - nsView.currentEditor() != nil || - ((fr as? NSTextView)?.delegate as? NSTextField) === nsView - - if isFocused, - canApplyFocusRequest(focusRequestGeneration), - !isFirstResponder, - context.coordinator.pendingFocusRequest != true { - context.coordinator.pendingFocusRequest = true - DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in - coordinator?.pendingFocusRequest = nil - guard let coordinator, - coordinator.parent.isFocused, - coordinator.parent.canApplyFocusRequest(coordinator.parent.focusRequestGeneration) else { return } - guard let nsView, let window = nsView.window else { return } - let fr = window.firstResponder - let alreadyFocused = fr === nsView || - nsView.currentEditor() != nil || - ((fr as? NSTextView)?.delegate as? NSTextField) === nsView - guard !alreadyFocused else { return } - coordinator.focusField(nsView, in: window) - } - } - } + context.coordinator.requestFocusIfNeeded(for: nsView) } static func dismantleNSView(_ nsView: BrowserSearchNativeTextField, coordinator: Coordinator) { - if let observer = coordinator.searchFocusObserver { - NotificationCenter.default.removeObserver(observer) - coordinator.searchFocusObserver = nil + let activeRequestId = coordinator.activeFocusRequestId ?? coordinator.parent.focusRequestId +#if DEBUG + dlog( + "browser.focus.find.fieldDismantle panel=\(coordinator.parent.panelId.uuidString.prefix(5)) " + + "request=\(activeRequestId?.uuidString.prefix(8) ?? "nil") " + + "fr=\(nsView.window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" + ) +#endif + if let activeRequestId { + coordinator.parent.onFieldDidEndEditing(activeRequestId) } + clearActiveBrowserSearchOverlayPanelId(coordinator.parent.panelId, on: nsView.window) nsView.delegate = nil + nsView.onWindowAttachment = nil coordinator.parentField = nil } } diff --git a/Sources/Find/SurfaceSearchOverlay.swift b/Sources/Find/SurfaceSearchOverlay.swift index 2e602ef79c63..0f713ff5875e 100644 --- a/Sources/Find/SurfaceSearchOverlay.swift +++ b/Sources/Find/SurfaceSearchOverlay.swift @@ -1,5 +1,4 @@ import AppKit -import Bonsplit import SwiftUI private extension NSView { @@ -42,7 +41,7 @@ struct SurfaceSearchOverlay: View { onFieldDidFocus: onFieldDidFocus, onEscape: { #if DEBUG - cmuxDebugLog("find.nativeField.escape surface=\(surfaceId.uuidString.prefix(5)) needleEmpty=\(searchState.needle.isEmpty)") + dlog("find.nativeField.escape surface=\(surfaceId.uuidString.prefix(5)) needleEmpty=\(searchState.needle.isEmpty)") #endif if searchState.needle.isEmpty { onClose() @@ -83,7 +82,7 @@ struct SurfaceSearchOverlay: View { Button(action: { #if DEBUG - cmuxDebugLog("findbar.next surface=\(surfaceId.uuidString.prefix(5))") + dlog("findbar.next surface=\(surfaceId.uuidString.prefix(5))") #endif onNavigateSearch("navigate_search:next") }) { @@ -94,7 +93,7 @@ struct SurfaceSearchOverlay: View { Button(action: { #if DEBUG - cmuxDebugLog("findbar.prev surface=\(surfaceId.uuidString.prefix(5))") + dlog("findbar.prev surface=\(surfaceId.uuidString.prefix(5))") #endif onNavigateSearch("navigate_search:previous") }) { @@ -105,7 +104,7 @@ struct SurfaceSearchOverlay: View { Button(action: { #if DEBUG - cmuxDebugLog("findbar.close surface=\(surfaceId.uuidString.prefix(5))") + dlog("findbar.close surface=\(surfaceId.uuidString.prefix(5))") #endif onClose() }) { @@ -120,7 +119,7 @@ struct SurfaceSearchOverlay: View { .shadow(radius: 4) .onAppear { #if DEBUG - cmuxDebugLog("find.overlay.appear tab=\(tabId.uuidString.prefix(5)) surface=\(surfaceId.uuidString.prefix(5))") + dlog("find.overlay.appear tab=\(tabId.uuidString.prefix(5)) surface=\(surfaceId.uuidString.prefix(5))") #endif isSearchFieldFocused = true } @@ -259,7 +258,7 @@ private struct SearchTextFieldRepresentable: NSViewRepresentable { func controlTextDidBeginEditing(_ obj: Notification) { #if DEBUG - cmuxDebugLog("find.nativeField.beginEditing surface=\(parent.surfaceId.uuidString.prefix(5))") + dlog("find.nativeField.beginEditing surface=\(parent.surfaceId.uuidString.prefix(5))") #endif parent.onFieldDidFocus() if !parent.isFocused { @@ -271,7 +270,7 @@ private struct SearchTextFieldRepresentable: NSViewRepresentable { func controlTextDidEndEditing(_ obj: Notification) { #if DEBUG - cmuxDebugLog("find.nativeField.endEditing surface=\(parent.surfaceId.uuidString.prefix(5))") + dlog("find.nativeField.endEditing surface=\(parent.surfaceId.uuidString.prefix(5))") #endif if parent.isFocused { DispatchQueue.main.async { @@ -332,7 +331,7 @@ private struct SearchTextFieldRepresentable: NSViewRepresentable { field.currentEditor() != nil || ((fr as? NSTextView)?.delegate as? NSTextField) === field #if DEBUG - cmuxDebugLog( + dlog( "find.nativeField.searchFocusNotification surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) " + "alreadyFocused=\(alreadyFocused) firstResponder=\(String(describing: fr))" ) @@ -340,7 +339,7 @@ private struct SearchTextFieldRepresentable: NSViewRepresentable { guard !alreadyFocused else { return } let result = window.makeFirstResponder(field) #if DEBUG - cmuxDebugLog( + dlog( "find.nativeField.searchFocusApply surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) " + "result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))" ) diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index 40986343fbea..f5974e3da9e6 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -20,8 +20,7 @@ struct GhosttyConfig { var surfaceTabBarFontSize: CGFloat = 11 var theme: String? var workingDirectory: String? - // Ghostty measures scrollback-limit in bytes, not lines. - var scrollbackLimit: Int = 10_000_000 + var scrollbackLimit: Int = 10000 var unfocusedSplitOpacity: Double = 0.7 var unfocusedSplitFill: NSColor? var splitDividerColor: NSColor? @@ -206,45 +205,12 @@ struct GhosttyConfig { "~/Library/Application Support/com.mitchellh.ghostty/config.ghostty", ].map { NSString(string: $0).expandingTildeInPath } + cmuxConfigPaths() - #if DEBUG - let startupPreviewProfile = GhosttyStartupAppearancePreviewState.profile - if startupPreviewProfile.loadsRealUserConfig { - for path in configPaths { - if let contents = readConfigFile(at: path) { - config.parse(contents) - } - } - - if config.theme == nil, - GhosttyApp.shouldApplyManagedDefaultAppearance(configPaths: configPaths) { - config.applyCmuxDefaultAppearance( - environment: ProcessInfo.processInfo.environment, - bundleResourceURL: Bundle.main.resourceURL, - preferredColorScheme: preferredColorScheme - ) - } - } else if let contents = startupPreviewProfile.previewConfigContents( - preferredColorScheme: preferredColorScheme - ) { - config.parse(contents) - } - #else for path in configPaths { if let contents = readConfigFile(at: path) { config.parse(contents) } } - if config.theme == nil, - GhosttyApp.shouldApplyManagedDefaultAppearance(configPaths: configPaths) { - config.applyCmuxDefaultAppearance( - environment: ProcessInfo.processInfo.environment, - bundleResourceURL: Bundle.main.resourceURL, - preferredColorScheme: preferredColorScheme - ) - } - #endif - // Load theme if specified if let themeName = config.theme { config.loadTheme( @@ -403,9 +369,9 @@ struct GhosttyConfig { case "theme": theme = value case "working-directory": - workingDirectory = value + workingDirectory = cmuxNormalizedWorkingDirectory(value) case "scrollback-limit": - if let limit = Self.parseIntegerLiteral(value) { + if let limit = Int(value) { scrollbackLimit = limit } case "background": @@ -469,16 +435,6 @@ struct GhosttyConfig { } } - private static func parseIntegerLiteral(_ value: String) -> Int? { - // Strip digit-group separators (for example 10_000_000). - // Hex and float literals are intentionally unsupported here. - let normalized = value.replacingOccurrences(of: "_", with: "") - guard let parsed = Int(normalized), parsed >= 0 else { - return nil - } - return parsed - } - mutating func loadTheme(_ name: String) { loadTheme( name, diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 4d048ef24094..8ae25168f68b 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -8,7 +8,6 @@ import CoreText import Darwin import Carbon.HIToolbox import Sentry -import Bonsplit import IOSurface import UniformTypeIdentifiers @@ -30,60 +29,30 @@ enum GhosttyStartupAppearancePreviewProfile: String, CaseIterable, Identifiable var displayName: String { switch self { case .realUserConfig: - return String( - localized: "debug.startupAppearance.profile.realUserConfig.title", - defaultValue: "Real User Config" - ) + return String(localized: "debug.startupAppearance.profile.realUserConfig.title", defaultValue: "Real User Config") case .freshInstall: - return String( - localized: "debug.startupAppearance.profile.freshInstall.title", - defaultValue: "Fresh Install" - ) + return String(localized: "debug.startupAppearance.profile.freshInstall.title", defaultValue: "Fresh Install") case .userThemePair: - return String( - localized: "debug.startupAppearance.profile.userThemePair.title", - defaultValue: "User Light/Dark Theme" - ) + return String(localized: "debug.startupAppearance.profile.userThemePair.title", defaultValue: "User Light/Dark Theme") case .userSingleTheme: - return String( - localized: "debug.startupAppearance.profile.userSingleTheme.title", - defaultValue: "User Single Theme" - ) + return String(localized: "debug.startupAppearance.profile.userSingleTheme.title", defaultValue: "User Single Theme") case .userExplicitColors: - return String( - localized: "debug.startupAppearance.profile.userExplicitColors.title", - defaultValue: "User Explicit Colors" - ) + return String(localized: "debug.startupAppearance.profile.userExplicitColors.title", defaultValue: "User Explicit Colors") } } var detail: String { switch self { case .realUserConfig: - return String( - localized: "debug.startupAppearance.profile.realUserConfig.detail", - defaultValue: "Loads your actual Ghostty and cmux config files." - ) + return String(localized: "debug.startupAppearance.profile.realUserConfig.detail", defaultValue: "Loads your actual Ghostty and cmux config files.") case .freshInstall: - return String( - localized: "debug.startupAppearance.profile.freshInstall.detail", - defaultValue: "No user theme or terminal colors, so cmux applies its managed default colors." - ) + return String(localized: "debug.startupAppearance.profile.freshInstall.detail", defaultValue: "No user theme or terminal colors, so cmux applies its managed default colors.") case .userThemePair: - return String( - localized: "debug.startupAppearance.profile.userThemePair.detail", - defaultValue: "Simulates a user with an explicit light/dark Ghostty theme." - ) + return String(localized: "debug.startupAppearance.profile.userThemePair.detail", defaultValue: "Simulates a user with an explicit light/dark Ghostty theme.") case .userSingleTheme: - return String( - localized: "debug.startupAppearance.profile.userSingleTheme.detail", - defaultValue: "Simulates a user with one Ghostty theme applied in both appearances." - ) + return String(localized: "debug.startupAppearance.profile.userSingleTheme.detail", defaultValue: "Simulates a user with one Ghostty theme applied in both appearances.") case .userExplicitColors: - return String( - localized: "debug.startupAppearance.profile.userExplicitColors.detail", - defaultValue: "Simulates a user with direct terminal color settings and no theme." - ) + return String(localized: "debug.startupAppearance.profile.userExplicitColors.detail", defaultValue: "Simulates a user with direct terminal color settings and no theme.") } } @@ -98,9 +67,7 @@ enum GhosttyStartupAppearancePreviewProfile: String, CaseIterable, Identifiable case .realUserConfig: return nil case .freshInstall: - return GhosttyConfig.cmuxDefaultThemeConfigContents( - preferredColorScheme: preferredColorScheme - ) + return GhosttyConfig.cmuxDefaultThemeConfigContents(preferredColorScheme: preferredColorScheme) case .userThemePair: return "theme = light:Catppuccin Latte,dark:Catppuccin Mocha" case .userSingleTheme: @@ -269,6 +236,46 @@ private func cmuxScalarHex(_ value: String?) -> String { .map { String(format: "%04X", $0.value) } .joined(separator: ",") } + +enum CmuxTerminalHostDebugLog { + private static let logURL = URL(fileURLWithPath: "/tmp/cmux-terminal-host-debug.log") + private static let timestampFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + private static let lock = NSLock() + private static let launchUptime = ProcessInfo.processInfo.systemUptime + private static var sequence: UInt64 = 0 + + static func log(_ message: String) { + lock.lock() + defer { lock.unlock() } + sequence &+= 1 + let seq = sequence + + let monoMs = (ProcessInfo.processInfo.systemUptime - launchUptime) * 1000.0 + let line = + "[\(timestampFormatter.string(from: Date()))] " + + "seq=\(seq) mono_ms=\(String(format: "%.2f", monoMs)) " + + "\(message)\n" + + if let handle = FileHandle(forWritingAtPath: logURL.path) { + handle.seekToEndOfFile() + handle.write(Data(line.utf8)) + handle.closeFile() + } else { + FileManager.default.createFile( + atPath: logURL.path, + contents: Data(line.utf8) + ) + } + } + + static func rect(_ rect: CGRect) -> String { + String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.width, rect.height) + } +} #endif enum GhosttyPasteboardHelper { @@ -300,34 +307,36 @@ enum GhosttyPasteboardHelper { } static func stringContents(from pasteboard: NSPasteboard) -> String? { - let types = pasteboard.types ?? [] - - if (types.contains(.fileURL) || types.contains(.URL)), - let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], + if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], !urls.isEmpty { return urls .map { $0.isFileURL ? escapeForShell($0.path) : $0.absoluteString } .joined(separator: " ") } - let hasImagePayload = hasImageData(in: pasteboard) - let hasRTFDAttachmentPayload = types.contains(.rtfd) - if hasImagePayload, + let htmlText = attributedStringContents(from: pasteboard, type: .html, documentType: .html) + let rtfText = attributedStringContents(from: pasteboard, type: .rtf, documentType: .rtf) + let rtfdText = attributedStringContents(from: pasteboard, type: .rtfd, documentType: .rtfd) + + if hasImageData(in: pasteboard), let html = pasteboard.string(forType: .html), htmlHasNoVisibleText(html) { return nil } - // Match upstream Ghostty's fast plain-text path for normal text paste. - // Large clipboard payloads often also advertise HTML/RTF variants, and - // eagerly rendering those rich-text flavors makes Cmd-V much slower than - // vanilla Ghostty before the bytes ever reach the PTY. - if !hasImagePayload && !hasRTFDAttachmentPayload, - let value = plainTextContents(from: pasteboard) { + if hasImageData(in: pasteboard) { + if let htmlText { return htmlText } + if let rtfText { return rtfText } + return rtfdText + } + + if let value = plainTextContents(from: pasteboard) { return value } - return richTextContents(from: pasteboard) + if let htmlText { return htmlText } + if let rtfText { return rtfText } + return rtfdText } static func hasString(for location: ghostty_clipboard_e) -> Bool { @@ -381,16 +390,6 @@ enum GhosttyPasteboardHelper { return sanitized } - private static func richTextContents(from pasteboard: NSPasteboard) -> String? { - if let htmlText = attributedStringContents(from: pasteboard, type: .html, documentType: .html) { - return htmlText - } - if let rtfText = attributedStringContents(from: pasteboard, type: .rtf, documentType: .rtf) { - return rtfText - } - return attributedStringContents(from: pasteboard, type: .rtfd, documentType: .rtfd) - } - private static func plainTextContents(from pasteboard: NSPasteboard) -> String? { let allTypes = pasteboard.types ?? [] @@ -418,7 +417,7 @@ enum GhosttyPasteboardHelper { private static func hasPasteableContents(in pasteboard: NSPasteboard) -> Bool { let types = pasteboard.types ?? [] - if types.contains(.fileURL) || types.contains(.URL) || types.contains(.html) || types.contains(.rtf) || types.contains(.rtfd) { + if types.contains(.fileURL) || types.contains(.html) || types.contains(.rtf) || types.contains(.rtfd) { return true } if types.contains(where: isPlainTextType) { @@ -597,7 +596,7 @@ enum GhosttyPasteboardHelper { let maxClipboardImageSize = 10 * 1024 * 1024 // 10 MB guard imageData.count <= maxClipboardImageSize else { #if DEBUG - cmuxDebugLog("terminal.paste.image.rejected reason=tooLarge bytes=\(imageData.count)") + dlog("terminal.paste.image.rejected reason=tooLarge bytes=\(imageData.count)") #endif return .rejectedImagePayload } @@ -613,7 +612,7 @@ enum GhosttyPasteboardHelper { try imageData.write(to: fileURL) } catch { #if DEBUG - cmuxDebugLog("terminal.paste.image.writeFailed error=\(error.localizedDescription)") + dlog("terminal.paste.image.writeFailed error=\(error.localizedDescription)") #endif return .rejectedImagePayload } @@ -1088,18 +1087,18 @@ final class GhosttyDefaultBackgroundNotificationDispatcher { func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget? { let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) #if DEBUG - cmuxDebugLog("link.resolve input=\(trimmed)") + dlog("link.resolve input=\(trimmed)") #endif guard !trimmed.isEmpty else { #if DEBUG - cmuxDebugLog("link.resolve result=nil (empty)") + dlog("link.resolve result=nil (empty)") #endif return nil } if NSString(string: trimmed).isAbsolutePath { #if DEBUG - cmuxDebugLog("link.resolve result=external(absolutePath) url=\(trimmed)") + dlog("link.resolve result=external(absolutePath) url=\(trimmed)") #endif return .external(URL(fileURLWithPath: trimmed)) } @@ -1109,17 +1108,17 @@ func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget? if scheme == "http" || scheme == "https" { guard BrowserInsecureHTTPSettings.normalizeHost(parsed.host ?? "") != nil else { #if DEBUG - cmuxDebugLog("link.resolve result=external(invalidHost) url=\(parsed)") + dlog("link.resolve result=external(invalidHost) url=\(parsed)") #endif return .external(parsed) } #if DEBUG - cmuxDebugLog("link.resolve result=embeddedBrowser url=\(parsed)") + dlog("link.resolve result=embeddedBrowser url=\(parsed)") #endif return .embeddedBrowser(parsed) } #if DEBUG - cmuxDebugLog("link.resolve result=external(scheme=\(scheme)) url=\(parsed)") + dlog("link.resolve result=external(scheme=\(scheme)) url=\(parsed)") #endif return .external(parsed) } @@ -1127,24 +1126,24 @@ func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget? if let webURL = resolveBrowserNavigableURL(trimmed) { guard BrowserInsecureHTTPSettings.normalizeHost(webURL.host ?? "") != nil else { #if DEBUG - cmuxDebugLog("link.resolve result=external(bareHost-invalidHost) url=\(webURL)") + dlog("link.resolve result=external(bareHost-invalidHost) url=\(webURL)") #endif return .external(webURL) } #if DEBUG - cmuxDebugLog("link.resolve result=embeddedBrowser(bareHost) url=\(webURL)") + dlog("link.resolve result=embeddedBrowser(bareHost) url=\(webURL)") #endif return .embeddedBrowser(webURL) } guard let fallback = URL(string: trimmed) else { #if DEBUG - cmuxDebugLog("link.resolve result=nil (unparseable)") + dlog("link.resolve result=nil (unparseable)") #endif return nil } #if DEBUG - cmuxDebugLog("link.resolve result=external(fallback) url=\(fallback)") + dlog("link.resolve result=external(fallback) url=\(fallback)") #endif return .external(fallback) } @@ -1623,7 +1622,7 @@ class GhosttyApp { } NSSound.beep() #if DEBUG - cmuxDebugLog("terminal.remotePasteUpload.failed surface=\(callbackContext.surfaceId.uuidString.prefix(5))") + dlog("terminal.remotePasteUpload.failed surface=\(callbackContext.surfaceId.uuidString.prefix(5))") #endif completeClipboardRequest(with: "") } @@ -1754,9 +1753,9 @@ class GhosttyApp { let timestamp = ISO8601DateFormatter().string(from: Date()) let line = "[\(timestamp)] \(message)\n" if let handle = FileHandle(forWritingAtPath: initLogPath) { - defer { try? handle.close() } - guard (try? handle.seekToEnd()) != nil else { return } - try? handle.write(contentsOf: Data(line.utf8)) + handle.seekToEndOfFile() + handle.write(Data(line.utf8)) + handle.closeFile() } else { FileManager.default.createFile(atPath: initLogPath, contents: line.data(using: .utf8)) } @@ -1875,8 +1874,8 @@ class GhosttyApp { increments: ["probeCloseSurfaceCbCount": 1] ) #endif - - DispatchQueue.main.async { + @MainActor + func handleCloseSurface() { guard let app = AppDelegate.shared else { return } // Close requests must be resolved by the callback's workspace/surface IDs only. // If the mapping is already gone (duplicate/stale callback), ignore it. @@ -1897,6 +1896,15 @@ class GhosttyApp { } } } + if Thread.isMainThread { + MainActor.assumeIsolated { + handleCloseSurface() + } + } else { + DispatchQueue.main.async { + handleCloseSurface() + } + } } // Create app @@ -1989,21 +1997,23 @@ class GhosttyApp { _ contents: String, into config: ghostty_config_t, prefix: String, - logLabel _: String + logLabel: String ) { let trimmed = contents.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } - let syntheticPath = "/__cmux_inline__/\(prefix).conf" - trimmed.withCString { contents in - syntheticPath.withCString { path in - ghostty_config_load_string( - config, - contents, - UInt(trimmed.lengthOfBytes(using: .utf8)), - path - ) + let tmpURL = FileManager.default.temporaryDirectory + .appendingPathComponent("\(prefix)-\(UUID().uuidString).conf") + do { + try trimmed.write(to: tmpURL, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: tmpURL) } + tmpURL.path.withCString { path in + ghostty_config_load_file(config, path) } + } catch { + #if DEBUG + dlog("ghostty.config.inlineLoad.failed label=\(logLabel) error=\(error.localizedDescription)") + #endif } } @@ -2118,14 +2128,8 @@ class GhosttyApp { private func loadCJKFontFallbackIfNeeded(_ config: ghostty_config_t) { guard let mappings = Self.autoInjectedCJKFontMappings() else { return } - var resolvedFonts: [String: String] = [:] let lines = mappings.map { range, font in - let resolvedFont = resolvedFonts[font] ?? { - let resolved = Self.resolvedInjectedCJKFontName(named: font) - resolvedFonts[font] = resolved - return resolved - }() - return "font-codepoint-map = \(range)=\(resolvedFont)" + "font-codepoint-map = \(range)=\(font)" }.joined(separator: "\n") loadInlineGhosttyConfig( lines, @@ -2303,13 +2307,13 @@ class GhosttyApp { /// a `font-codepoint-map` entry covering CJK ranges. Also checks /// application-support config paths that cmux may load at runtime. static func userConfigContainsCJKCodepointMap( - configPaths: [String] = loadedGhosttyConfigScanPaths() + configPaths: [String] = loadedCJKScanPaths() ) -> Bool { userFontConfigSummary(configPaths: configPaths).containsCodepointMap } static func userConfigHasExplicitFontFamilyFallbackChain( - configPaths: [String] = loadedGhosttyConfigScanPaths() + configPaths: [String] = loadedCJKScanPaths() ) -> Bool { userFontConfigSummary(configPaths: configPaths).hasExplicitFontFamilyFallbackChain } @@ -2332,45 +2336,6 @@ class GhosttyApp { userAppearanceConfigSummary(configPaths: configPaths).shouldApplyDefaultAppearance } - /// Resolve auto-injected CJK families through the regular-weight descriptor - /// path first so locale-sensitive families such as Hiragino Sans don't fall - /// back to ultra-light faces like W0 when Ghostty later matches by name. - static func resolvedInjectedCJKFontName( - named name: String, - size: CGFloat = 12 - ) -> String { - let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return name } - guard let regularWeightFont = discoveredCTFont(named: trimmed, size: size, weightTrait: 0.0) else { - return trimmed - } - - let candidateNames = [ - CTFontCopyName(regularWeightFont, kCTFontFullNameKey) as String?, - CTFontCopyName(regularWeightFont, kCTFontPostScriptNameKey) as String?, - ].compactMap { $0 } - let expectedFullName = CTFontCopyFullName(regularWeightFont) as String - let expectedPostScriptName = CTFontCopyPostScriptName(regularWeightFont) as String - - for candidate in candidateNames { - guard let verifiedFont = discoveredCTFont(named: candidate, size: size) else { continue } - let verifiedNames = [ - CTFontCopyName(verifiedFont, kCTFontFamilyNameKey) as String?, - CTFontCopyName(verifiedFont, kCTFontFullNameKey) as String?, - CTFontCopyName(verifiedFont, kCTFontPostScriptNameKey) as String?, - ].compactMap { $0 } - let matchesRegularWeightFace = verifiedNames.contains { - normalizedFontName($0) == normalizedFontName(expectedFullName) || - normalizedFontName($0) == normalizedFontName(expectedPostScriptName) - } - if matchesRegularWeightFace { - return candidate - } - } - - return trimmed - } - private static func configuredCTFont( named name: String, size: CGFloat = 12 @@ -2393,34 +2358,6 @@ class GhosttyApp { return font } - /// Mirror Ghostty's family-name CoreText discovery path so injected - /// `font-codepoint-map` values are validated against the same lookup mode. - static func discoveredCTFont( - named name: String, - size: CGFloat = 12, - weightTrait: CGFloat? = nil - ) -> CTFont? { - let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - var attributes: [CFString: Any] = [ - kCTFontFamilyNameAttribute: trimmed, - kCTFontSizeAttribute: size, - ] - if let weightTrait { - attributes[kCTFontTraitsAttribute] = [ - kCTFontWeightTrait: weightTrait, - ] as CFDictionary - } - - let descriptor = CTFontDescriptorCreateWithAttributes(attributes as CFDictionary) - let collection = CTFontCollectionCreateWithFontDescriptors([descriptor] as CFArray, nil) - guard let match = (CTFontCollectionCreateMatchingFontDescriptors(collection) as? [CTFontDescriptor])?.first else { - return nil - } - return CTFontCreateWithFontDescriptor(match, size, nil) - } - private static func fontContainsGlyphs( _ font: CTFont, forRange range: String @@ -2458,8 +2395,10 @@ class GhosttyApp { } var loadedRecursivePaths = Set() - while !recursiveConfigPaths.isEmpty { - let path = recursiveConfigPaths.removeFirst() + var index = 0 + while index < recursiveConfigPaths.count { + let path = recursiveConfigPaths[index] + index += 1 let resolved = (path as NSString).standardizingPath guard !loadedRecursivePaths.contains(resolved) else { continue } loadedRecursivePaths.insert(resolved) @@ -2475,7 +2414,7 @@ class GhosttyApp { } private static func userAppearanceConfigSummary( - configPaths: [String] = loadedCJKScanPaths() + configPaths: [String] = loadedGhosttyConfigScanPaths() ) -> UserAppearanceConfigSummary { var summary = UserAppearanceConfigSummary() var recursiveConfigPaths: [String] = [] @@ -2489,8 +2428,10 @@ class GhosttyApp { } var loadedRecursivePaths = Set() - while !recursiveConfigPaths.isEmpty { - let path = recursiveConfigPaths.removeFirst() + var index = 0 + while index < recursiveConfigPaths.count { + let path = recursiveConfigPaths[index] + index += 1 let resolved = (path as NSString).standardizingPath guard !loadedRecursivePaths.contains(resolved) else { continue } loadedRecursivePaths.insert(resolved) @@ -2505,7 +2446,7 @@ class GhosttyApp { return summary } - /// Returns the top-level Ghostty config paths cmux may load before + /// Returns the top-level config paths that cmux will actually load before /// recursive `config-file` processing. static func loadedGhosttyConfigScanPaths( currentBundleIdentifier: String? = Bundle.main.bundleIdentifier, @@ -2513,20 +2454,28 @@ class GhosttyApp { for: .applicationSupportDirectory, in: .userDomainMask ).first + ) -> [String] { + loadedCJKScanPaths( + currentBundleIdentifier: currentBundleIdentifier, + appSupportDirectory: appSupportDirectory + ) + } + + static func loadedCJKScanPaths( + currentBundleIdentifier: String? = Bundle.main.bundleIdentifier, + appSupportDirectory: URL? = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first ) -> [String] { var paths = [ "~/.config/ghostty/config", "~/.config/ghostty/config.ghostty", ] - guard let appSupportDirectory else { return paths } - - let ghosttyDir = appSupportDirectory.appendingPathComponent("com.mitchellh.ghostty", isDirectory: true) - paths.append(ghosttyDir.appendingPathComponent("config", isDirectory: false).path) - paths.append(ghosttyDir.appendingPathComponent("config.ghostty", isDirectory: false).path) - guard let bundleId = currentBundleIdentifier, - !bundleId.isEmpty else { return paths } + !bundleId.isEmpty, + let appSupportDirectory else { return paths } let appSupportConfigURLs = cmuxAppSupportConfigURLs( currentBundleIdentifier: bundleId, @@ -2551,19 +2500,6 @@ class GhosttyApp { return paths } - static func loadedCJKScanPaths( - currentBundleIdentifier: String? = Bundle.main.bundleIdentifier, - appSupportDirectory: URL? = FileManager.default.urls( - for: .applicationSupportDirectory, - in: .userDomainMask - ).first - ) -> [String] { - loadedGhosttyConfigScanPaths( - currentBundleIdentifier: currentBundleIdentifier, - appSupportDirectory: appSupportDirectory - ) - } - private static func configFileSize(at url: URL) -> Int? { guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), let size = attrs[.size] as? NSNumber else { return nil } @@ -2598,7 +2534,6 @@ class GhosttyApp { guard let value = entry.value else { continue } applyConfigFileDirective( value, - valueWasQuoted: entry.valueWasQuoted, parentDir: parentDir, recursiveConfigPaths: &recursiveConfigPaths ) @@ -2636,7 +2571,6 @@ class GhosttyApp { guard let value = entry.value else { continue } applyConfigFileDirective( value, - valueWasQuoted: entry.valueWasQuoted, parentDir: parentDir, recursiveConfigPaths: &recursiveConfigPaths ) @@ -2648,7 +2582,7 @@ class GhosttyApp { private static func parsedConfigEntry( from rawLine: String - ) -> (key: String, value: String?, valueWasQuoted: Bool)? { + ) -> (key: String, value: String?)? { var trimmed = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasPrefix("\u{FEFF}") { trimmed.removeFirst() @@ -2656,25 +2590,23 @@ class GhosttyApp { if trimmed.isEmpty || trimmed.hasPrefix("#") { return nil } guard let separatorIndex = trimmed.firstIndex(of: "=") else { - return (trimmed.trimmingCharacters(in: .whitespacesAndNewlines), nil, false) + return (trimmed.trimmingCharacters(in: .whitespacesAndNewlines), nil) } let key = trimmed[..= 2 && value.hasPrefix("\"") && value.hasSuffix("\"") - if valueWasQuoted { + if value.count >= 2, value.hasPrefix("\""), value.hasSuffix("\"") { value.removeFirst() value.removeLast() } - return (String(key), String(value), valueWasQuoted) + return (String(key), String(value)) } private static func applyConfigFileDirective( _ value: String, - valueWasQuoted: Bool, parentDir: String, recursiveConfigPaths: inout [String] ) { @@ -2684,8 +2616,12 @@ class GhosttyApp { } var includePath = value - if !valueWasQuoted, includePath.hasPrefix("?") { + if includePath.hasPrefix("?") { + includePath.removeFirst() + } + if includePath.count >= 2, includePath.hasPrefix("\""), includePath.hasSuffix("\"") { includePath.removeFirst() + includePath.removeLast() } guard !includePath.isEmpty else { return } @@ -2798,7 +2734,7 @@ class GhosttyApp { } #if DEBUG - cmuxDebugLog( + dlog( "loaded cmux app support ghostty config from: \(urls.map(\.path).joined(separator: ", "))" ) #endif @@ -3216,7 +3152,7 @@ class GhosttyApp { private func focusDirection(from direction: ghostty_action_goto_split_e) -> NavigationDirection? { switch direction { // For previous/next, we use left/right as a reasonable default - // Bonsplit doesn't have cycle-based navigation + // WorkspaceSplit doesn't have cycle-based navigation case GHOSTTY_GOTO_SPLIT_PREVIOUS: return .left case GHOSTTY_GOTO_SPLIT_NEXT: return .right case GHOSTTY_GOTO_SPLIT_UP: return .up @@ -3344,7 +3280,7 @@ class GhosttyApp { // handles this action. For cmux, the correct behavior is to close // the panel immediately (no prompt). #if DEBUG - cmuxDebugLog( + dlog( "surface.action.showChildExited tab=\(callbackTabId?.uuidString.prefix(5) ?? "nil") " + "surface=\(callbackSurfaceId?.uuidString.prefix(5) ?? "nil")" ) @@ -3358,8 +3294,6 @@ class GhosttyApp { increments: ["probeShowChildExitedCount": 1] ) #endif - // Keep host-close async to avoid re-entrant close/deinit while Ghostty is still - // dispatching this action callback. DispatchQueue.main.async { guard let app = AppDelegate.shared else { return } if let callbackTabId, @@ -3370,7 +3304,6 @@ class GhosttyApp { manager.closePanelAfterChildExited(tabId: callbackTabId, surfaceId: callbackSurfaceId) } } - // Always report handled so Ghostty doesn't print the fallback prompt. return true } @@ -3508,6 +3441,18 @@ class GhosttyApp { if let tabId = surfaceView.tabId, let surfaceId = surfaceView.terminalSurface?.id { DispatchQueue.main.async { +#if DEBUG + dlog( + "dbg.title.ghostty.setTitle tab=\(tabId.uuidString.prefix(8)) " + + "panel=\(surfaceId.uuidString.prefix(8)) " + + "title=\"\(title.replacingOccurrences(of: "\n", with: "\\n"))\"" + ) + startupLog( + "rt.title.ghostty.setTitle tab=\(tabId.uuidString.prefix(8)) " + + "panel=\(surfaceId.uuidString.prefix(8)) " + + "title=\"\(title.replacingOccurrences(of: "\n", with: "\\n"))\"" + ) +#endif NotificationCenter.default.post( name: .ghosttyDidSetTitle, object: surfaceView, @@ -3525,6 +3470,18 @@ class GhosttyApp { let surfaceId = surfaceView.terminalSurface?.id else { return true } let pwd = action.action.pwd.pwd.flatMap { String(cString: $0) } ?? "" DispatchQueue.main.async { +#if DEBUG + dlog( + "dbg.title.ghostty.pwd tab=\(tabId.uuidString.prefix(8)) " + + "panel=\(surfaceId.uuidString.prefix(8)) " + + "pwd=\"\((pwd as NSString).standardizingPath.replacingOccurrences(of: "\n", with: "\\n"))\"" + ) + startupLog( + "rt.title.ghostty.pwd tab=\(tabId.uuidString.prefix(8)) " + + "panel=\(surfaceId.uuidString.prefix(8)) " + + "pwd=\"\((pwd as NSString).standardizingPath.replacingOccurrences(of: "\n", with: "\\n"))\"" + ) +#endif AppDelegate.shared?.tabManagerFor(tabId: tabId)?.updateSurfaceDirectory( tabId: tabId, surfaceId: surfaceId, @@ -3637,11 +3594,11 @@ class GhosttyApp { encoding: .utf8 ) ?? "" #if DEBUG - cmuxDebugLog("link.openURL raw=\(urlString)") + dlog("link.openURL raw=\(urlString)") #endif guard let target = resolveTerminalOpenURLTarget(urlString) else { #if DEBUG - cmuxDebugLog("link.openURL resolve failed, returning false") + dlog("link.openURL resolve failed, returning false") #endif return false } @@ -3681,7 +3638,7 @@ class GhosttyApp { if !BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser() { #if DEBUG - cmuxDebugLog("link.openURL cmuxBrowser=disabled, opening externally url=\(target.url)") + dlog("link.openURL cmuxBrowser=disabled, opening externally url=\(target.url)") #endif return performOnMain { NSWorkspace.shared.open(target.url) @@ -3690,7 +3647,7 @@ class GhosttyApp { switch target { case let .external(url): #if DEBUG - cmuxDebugLog("link.openURL target=external, opening externally url=\(url)") + dlog("link.openURL target=external, opening externally url=\(url)") #endif return performOnMain { NSWorkspace.shared.open(url) @@ -3698,7 +3655,7 @@ class GhosttyApp { case let .embeddedBrowser(url): if BrowserLinkOpenSettings.shouldOpenExternally(url) { #if DEBUG - cmuxDebugLog("link.openURL target=embedded but shouldOpenExternally=true url=\(url)") + dlog("link.openURL target=embedded but shouldOpenExternally=true url=\(url)") #endif return performOnMain { NSWorkspace.shared.open(url) @@ -3706,7 +3663,7 @@ class GhosttyApp { } guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { #if DEBUG - cmuxDebugLog("link.openURL target=embedded but normalizeHost=nil host=\(url.host ?? "nil") url=\(url)") + dlog("link.openURL target=embedded but normalizeHost=nil host=\(url.host ?? "nil") url=\(url)") #endif return performOnMain { NSWorkspace.shared.open(url) @@ -3716,7 +3673,7 @@ class GhosttyApp { // If a host whitelist is configured and this host isn't in it, open externally. if !BrowserLinkOpenSettings.hostMatchesWhitelist(host) { #if DEBUG - cmuxDebugLog("link.openURL target=embedded but hostWhitelist miss host=\(host) url=\(url)") + dlog("link.openURL target=embedded but hostWhitelist miss host=\(host) url=\(url)") #endif return performOnMain { NSWorkspace.shared.open(url) @@ -3727,12 +3684,12 @@ class GhosttyApp { guard let sourceWorkspaceId, let sourcePanelId else { #if DEBUG - cmuxDebugLog("link.openURL target=embedded but tabId/surfaceId=nil") + dlog("link.openURL target=embedded but tabId/surfaceId=nil") #endif return false } #if DEBUG - cmuxDebugLog( + dlog( "link.openURL target=embedded, opening in browser pane " + "host=\(host) url=\(url) tabId=\(sourceWorkspaceId) surfaceId=\(sourcePanelId)" ) @@ -3744,7 +3701,7 @@ class GhosttyApp { preferredWorkspaceId: sourceWorkspaceId ) else { #if DEBUG - cmuxDebugLog( + dlog( "link.openURL embedded but workspace lookup failed " + "tabId=\(sourceWorkspaceId) surfaceId=\(sourcePanelId)" ) @@ -3754,7 +3711,7 @@ class GhosttyApp { let workspace = resolved.workspace #if DEBUG if workspace.id != sourceWorkspaceId { - cmuxDebugLog( + dlog( "link.openURL workspace.remap sourceTab=\(sourceWorkspaceId) " + "resolvedTab=\(workspace.id) surfaceId=\(sourcePanelId)" ) @@ -3762,14 +3719,14 @@ class GhosttyApp { #endif if let targetPane = workspace.preferredBrowserTargetPane(fromPanelId: sourcePanelId) { #if DEBUG - cmuxDebugLog("link.openURL opening in existing browser pane=\(targetPane)") + dlog("link.openURL opening in existing browser pane=\(targetPane)") #endif - return workspace.newBrowserSurface(inPane: targetPane, url: url, focus: true) != nil + return workspace.createBrowserPanel(inPane: targetPane, url: url, focus: true) != nil } else { #if DEBUG - cmuxDebugLog("link.openURL opening as new browser split from surface=\(sourcePanelId)") + dlog("link.openURL opening as new browser split from surface=\(sourcePanelId)") #endif - return workspace.newBrowserSplit(from: sourcePanelId, orientation: .horizontal, url: url) != nil + return workspace.splitBrowserPanel(fromPanelId: sourcePanelId, orientation: .horizontal, url: url) != nil } } } @@ -3837,8 +3794,8 @@ class GhosttyApp { } if let handle = try? FileHandle(forWritingTo: backgroundLogURL) { defer { try? handle.close() } - guard (try? handle.seekToEnd()) != nil else { return } - try? handle.write(contentsOf: data) + _ = try? handle.seekToEnd() + _ = try? handle.write(contentsOf: data) } } } @@ -3853,6 +3810,8 @@ final class GhosttyMetalLayer: CAMetalLayer { private let lock = NSLock() private var drawableCount: Int = 0 private var lastDrawableTime: CFTimeInterval = 0 + private var notifyOnNextDrawable = false + var onNextDrawable: (@MainActor @Sendable () -> Void)? func debugStats() -> (count: Int, last: CFTimeInterval) { lock.lock() @@ -3860,12 +3819,31 @@ final class GhosttyMetalLayer: CAMetalLayer { return (drawableCount, lastDrawableTime) } + func requestNextDrawableNotification() { + lock.lock() + notifyOnNextDrawable = true + lock.unlock() + } + override func nextDrawable() -> CAMetalDrawable? { + let drawable = super.nextDrawable() + var callback: (@MainActor @Sendable () -> Void)? lock.lock() - drawableCount += 1 - lastDrawableTime = CACurrentMediaTime() + if drawable != nil { + drawableCount += 1 + lastDrawableTime = CACurrentMediaTime() + if notifyOnNextDrawable { + notifyOnNextDrawable = false + callback = onNextDrawable + } + } lock.unlock() - return super.nextDrawable() + if let callback { + Task { @MainActor in + callback() + } + } + return drawable } } @@ -3984,7 +3962,6 @@ final class TerminalSurface: Identifiable, ObservableObject { private let configTemplate: CmuxSurfaceConfigTemplate? private let workingDirectory: String? private let initialCommand: String? - private let initialInput: String? private let initialEnvironmentOverrides: [String: String] var requestedWorkingDirectory: String? { workingDirectory } private var additionalEnvironment: [String: String] @@ -4002,7 +3979,7 @@ final class TerminalSurface: Identifiable, ObservableObject { private var pendingSocketInputQueue: [PendingSocketInput] = [] private var pendingSocketInputBytes: Int = 0 private let maxPendingSocketInputBytes = 1_048_576 - private var backgroundSurfaceStartQueued = false + private var pendingReadyCallbacks: [UUID: () -> Void] = [:] private var surfaceCallbackContext: Unmanaged? /// The desired focus state for the Ghostty C surface. May be set before the /// C surface exists (e.g. during layout restoration); `createSurface` @@ -4017,8 +3994,6 @@ final class TerminalSurface: Identifiable, ObservableObject { #if DEBUG private var needsConfirmCloseOverrideForTesting: Bool? private var runtimeSurfaceFreedOutOfBandForTesting = false - private let debugForceRefreshCountLock = NSLock() - private var debugForceRefreshCountValue = 0 #endif private enum PortalLifecycleState: String { case live @@ -4040,7 +4015,7 @@ final class TerminalSurface: Identifiable, ObservableObject { if let searchState { hostedView.cancelFocusRequest() #if DEBUG - cmuxDebugLog("find.searchState created tab=\(tabId.uuidString.prefix(5)) surface=\(id.uuidString.prefix(5))") + dlog("find.searchState created tab=\(tabId.uuidString.prefix(5)) surface=\(id.uuidString.prefix(5))") #endif searchNeedleCancellable = searchState.$needle .removeDuplicates() @@ -4056,14 +4031,14 @@ final class TerminalSurface: Identifiable, ObservableObject { .switchToLatest() .sink { [weak self] needle in #if DEBUG - cmuxDebugLog("find.needle updated tab=\(self?.tabId.uuidString.prefix(5) ?? "?") surface=\(self?.id.uuidString.prefix(5) ?? "?") chars=\(needle.count)") + dlog("find.needle updated tab=\(self?.tabId.uuidString.prefix(5) ?? "?") surface=\(self?.id.uuidString.prefix(5) ?? "?") chars=\(needle.count)") #endif _ = self?.performBindingAction("search:\(needle)") } } else if oldValue != nil { searchNeedleCancellable = nil #if DEBUG - cmuxDebugLog("find.searchState cleared tab=\(tabId.uuidString.prefix(5)) surface=\(id.uuidString.prefix(5))") + dlog("find.searchState cleared tab=\(tabId.uuidString.prefix(5)) surface=\(id.uuidString.prefix(5))") #endif _ = performBindingAction("end_search") } @@ -4079,7 +4054,6 @@ final class TerminalSurface: Identifiable, ObservableObject { configTemplate: CmuxSurfaceConfigTemplate?, workingDirectory: String? = nil, initialCommand: String? = nil, - initialInput: String? = nil, initialEnvironmentOverrides: [String: String] = [:], additionalEnvironment: [String: String] = [:] ) { @@ -4087,11 +4061,9 @@ final class TerminalSurface: Identifiable, ObservableObject { self.tabId = tabId self.surfaceContext = context self.configTemplate = configTemplate - self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) + self.workingDirectory = cmuxNormalizedWorkingDirectory(workingDirectory) let trimmedCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines) self.initialCommand = (trimmedCommand?.isEmpty == false) ? trimmedCommand : nil - let trimmedInput = initialInput?.isEmpty == false ? initialInput : nil - self.initialInput = trimmedInput self.initialEnvironmentOverrides = Self.mergedNormalizedEnvironment(base: [:], overrides: initialEnvironmentOverrides) self.additionalEnvironment = Self.mergedNormalizedEnvironment(base: [:], overrides: additionalEnvironment) // Match Ghostty's own SurfaceView: ensure a non-zero initial frame so the backing layer @@ -4244,7 +4216,7 @@ final class TerminalSurface: Identifiable, ObservableObject { markPortalLifecycleClosed(reason: reason) #if DEBUG let registeredOwnerToken = registeredOwnerId.map { String($0.uuidString.prefix(5)) } ?? "nil" - cmuxDebugLog( + dlog( "surface.lifecycle.stale surface=\(id.uuidString.prefix(5)) " + "workspace=\(tabId.uuidString.prefix(5)) reason=\(reason) " + "registryOwner=\(registeredOwnerToken)" @@ -4281,7 +4253,7 @@ final class TerminalSurface: Identifiable, ObservableObject { area: current.area ) #if DEBUG - cmuxDebugLog( + dlog( "terminal.portal.host.rearm surface=\(id.uuidString.prefix(5)) " + "reason=\(reason) host=\(hostId) pane=\(current.paneId.uuidString.prefix(5)) " + "area=\(String(format: "%.1f", current.area))" @@ -4331,7 +4303,7 @@ final class TerminalSurface: Identifiable, ObservableObject { if shouldReplace { #if DEBUG - cmuxDebugLog( + dlog( "terminal.portal.host.claim surface=\(id.uuidString.prefix(5)) " + "reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " + "inWin=\(inWindow ? 1 : 0) " + @@ -4346,7 +4318,7 @@ final class TerminalSurface: Identifiable, ObservableObject { } #if DEBUG - cmuxDebugLog( + dlog( "terminal.portal.host.skip surface=\(id.uuidString.prefix(5)) " + "reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " + "inWin=\(inWindow ? 1 : 0) " + @@ -4361,7 +4333,7 @@ final class TerminalSurface: Identifiable, ObservableObject { activePortalHostLease = next #if DEBUG - cmuxDebugLog( + dlog( "terminal.portal.host.claim surface=\(id.uuidString.prefix(5)) " + "reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " + "inWin=\(inWindow ? 1 : 0) " + @@ -4375,7 +4347,7 @@ final class TerminalSurface: Identifiable, ObservableObject { guard let current = activePortalHostLease, current.hostId == hostId else { return } activePortalHostLease = nil #if DEBUG - cmuxDebugLog( + dlog( "terminal.portal.host.release surface=\(id.uuidString.prefix(5)) " + "reason=\(reason) host=\(hostId) pane=\(current.paneId.uuidString.prefix(5)) " + "inWin=\(current.inWindow ? 1 : 0) " + @@ -4413,7 +4385,7 @@ final class TerminalSurface: Identifiable, ObservableObject { portalLifecycleState = .closing portalLifecycleGeneration &+= 1 #if DEBUG - cmuxDebugLog( + dlog( "surface.lifecycle.close.begin surface=\(id.uuidString.prefix(5)) " + "workspace=\(tabId.uuidString.prefix(5)) reason=\(reason) " + "generation=\(portalLifecycleGeneration)" @@ -4426,7 +4398,7 @@ final class TerminalSurface: Identifiable, ObservableObject { portalLifecycleState = .closed portalLifecycleGeneration &+= 1 #if DEBUG - cmuxDebugLog( + dlog( "surface.lifecycle.close.sealed surface=\(id.uuidString.prefix(5)) " + "workspace=\(tabId.uuidString.prefix(5)) reason=\(reason) " + "generation=\(portalLifecycleGeneration)" @@ -4483,32 +4455,13 @@ final class TerminalSurface: Identifiable, ObservableObject { desiredFocusState } - func debugForceRefreshCount() -> Int { - debugForceRefreshCountLock.lock() - defer { debugForceRefreshCountLock.unlock() } - return debugForceRefreshCountValue - } - - @MainActor - func resetDebugForceRefreshCount() { - debugForceRefreshCountLock.lock() - debugForceRefreshCountValue = 0 - debugForceRefreshCountLock.unlock() - } - - private func recordDebugForceRefresh() { - debugForceRefreshCountLock.lock() - debugForceRefreshCountValue += 1 - debugForceRefreshCountLock.unlock() - } - private static func surfaceLog(_ message: String) { let timestamp = ISO8601DateFormatter().string(from: Date()) let line = "[\(timestamp)] \(message)\n" if let handle = FileHandle(forWritingAtPath: surfaceLogPath) { - defer { try? handle.close() } - guard (try? handle.seekToEnd()) != nil else { return } - try? handle.write(contentsOf: Data(line.utf8)) + handle.seekToEndOfFile() + handle.write(Data(line.utf8)) + handle.closeFile() } else { FileManager.default.createFile(atPath: surfaceLogPath, contents: line.data(using: .utf8)) } @@ -4520,9 +4473,9 @@ final class TerminalSurface: Identifiable, ObservableObject { let timestamp = ISO8601DateFormatter().string(from: Date()) let line = "[\(timestamp)] \(message)\n" if let handle = FileHandle(forWritingAtPath: sizeLogPath) { - defer { try? handle.close() } - guard (try? handle.seekToEnd()) != nil else { return } - try? handle.write(contentsOf: Data(line.utf8)) + handle.seekToEndOfFile() + handle.write(Data(line.utf8)) + handle.closeFile() } else { FileManager.default.createFile(atPath: sizeLogPath, contents: line.data(using: .utf8)) } @@ -4555,9 +4508,9 @@ final class TerminalSurface: Identifiable, ObservableObject { abs(lhs - rhs) <= epsilon } - func attachToView(_ view: GhosttyNSView) { + func bindToView(_ view: GhosttyNSView) { #if DEBUG - cmuxDebugLog( + dlog( "surface.attach surface=\(id.uuidString.prefix(5)) view=\(Unmanaged.passUnretained(view).toOpaque()) " + "attached=\(attachedView != nil ? 1 : 0) hasSurface=\(surface != nil ? 1 : 0) inWindow=\(view.window != nil ? 1 : 0)" ) @@ -4571,22 +4524,17 @@ final class TerminalSurface: Identifiable, ObservableObject { // SwiftUI also re-enters this path for ordinary state propagation (drag hover, active // markers, visibility flags), so avoid forcing a geometry refresh when the attachment // itself is unchanged. - if attachedView === view && surface != nil { + if attachedView === view { #if DEBUG - cmuxDebugLog("surface.attach.reuse surface=\(id.uuidString.prefix(5)) view=\(Unmanaged.passUnretained(view).toOpaque())") + dlog("surface.attach.reuse surface=\(id.uuidString.prefix(5)) view=\(Unmanaged.passUnretained(view).toOpaque())") #endif - if let screen = view.window?.screen ?? NSScreen.main, - let displayID = screen.displayID, - displayID != 0, - let s = surface { - ghostty_surface_set_display_id(s, displayID) - } + updateDisplayIDIfPossible(for: view) return } if let attachedView, attachedView !== view { #if DEBUG - cmuxDebugLog( + dlog( "surface.attach.skip surface=\(id.uuidString.prefix(5)) reason=alreadyAttachedToDifferentView " + "current=\(Unmanaged.passUnretained(attachedView).toOpaque()) new=\(Unmanaged.passUnretained(view).toOpaque())" ) @@ -4595,51 +4543,69 @@ final class TerminalSurface: Identifiable, ObservableObject { } attachedView = view + updateDisplayIDIfPossible(for: view) + } - // If surface doesn't exist yet, create it once the view is in a real window so - // content scale and pixel geometry are derived from the actual backing context. - if surface == nil { - guard allowsRuntimeSurfaceCreation() else { + func realizeRuntimeIfPossible(reason: String) { + guard allowsRuntimeSurfaceCreation() else { #if DEBUG - cmuxDebugLog( - "surface.attach.skip surface=\(id.uuidString.prefix(5)) " + - "reason=lifecycle.\(portalLifecycleState.rawValue)" - ) + dlog( + "surface.realize.skip surface=\(id.uuidString.prefix(5)) " + + "reason=lifecycle.\(portalLifecycleState.rawValue) source=\(reason)" + ) #endif - return - } - guard view.window != nil else { + return + } + + guard let view = attachedView else { #if DEBUG - cmuxDebugLog( - "surface.attach.defer surface=\(id.uuidString.prefix(5)) reason=noWindow " + - "bounds=\(String(format: "%.1fx%.1f", view.bounds.width, view.bounds.height))" - ) + dlog("surface.realize.skip surface=\(id.uuidString.prefix(5)) reason=noAttachedView source=\(reason)") #endif - return - } + return + } + + guard view.window != nil else { #if DEBUG - cmuxDebugLog("surface.attach.create surface=\(id.uuidString.prefix(5))") + dlog( + "surface.realize.defer surface=\(id.uuidString.prefix(5)) reason=noWindow " + + "bounds=\(String(format: "%.1fx%.1f", view.bounds.width, view.bounds.height)) source=\(reason)" + ) #endif - createSurface(for: view) + return + } + + if surface == nil { #if DEBUG - cmuxDebugLog("surface.attach.create.done surface=\(id.uuidString.prefix(5)) hasSurface=\(surface != nil ? 1 : 0)") + dlog("surface.attach.create surface=\(id.uuidString.prefix(5)) source=\(reason)") #endif - } else if let screen = view.window?.screen ?? NSScreen.main, - let displayID = screen.displayID, - displayID != 0, - let s = surface { - // Surface exists but we're (re)attaching after a view hierarchy move; ensure display id. - ghostty_surface_set_display_id(s, displayID) + createSurface(for: view) #if DEBUG - cmuxDebugLog("surface.attach.displayId surface=\(id.uuidString.prefix(5)) display=\(displayID)") + dlog("surface.attach.create.done surface=\(id.uuidString.prefix(5)) hasSurface=\(surface != nil ? 1 : 0) source=\(reason)") #endif + return + } + + updateDisplayIDIfPossible(for: view) + } + + private func updateDisplayIDIfPossible(for view: GhosttyNSView) { + guard let screen = view.window?.screen ?? NSScreen.main, + let displayID = screen.displayID, + displayID != 0, + let s = surface else { + return } + + ghostty_surface_set_display_id(s, displayID) +#if DEBUG + dlog("surface.attach.displayId surface=\(id.uuidString.prefix(5)) display=\(displayID)") +#endif } private func createSurface(for view: GhosttyNSView) { guard allowsRuntimeSurfaceCreation() else { #if DEBUG - cmuxDebugLog( + dlog( "surface.create.skip surface=\(id.uuidString.prefix(5)) " + "reason=lifecycle.\(portalLifecycleState.rawValue)" ) @@ -4683,7 +4649,7 @@ final class TerminalSurface: Identifiable, ObservableObject { surfaceConfig.context = surfaceContext #if DEBUG let templateFontText = String(format: "%.2f", surfaceConfig.font_size) - cmuxDebugLog( + dlog( "zoom.create surface=\(id.uuidString.prefix(5)) context=\(cmuxSurfaceContextName(surfaceContext)) " + "templateFont=\(templateFontText)" ) @@ -4852,7 +4818,10 @@ final class TerminalSurface: Identifiable, ObservableObject { if let workingDirectory, !workingDirectory.isEmpty { return workingDirectory } - return baseConfig.workingDirectory + if let inheritedWorkingDirectory = cmuxNormalizedWorkingDirectory(baseConfig.workingDirectory) { + return inheritedWorkingDirectory + } + return cmuxDefaultWorkingDirectory() }() let resolvedCommand: String? = { if let initialCommand, !initialCommand.isEmpty { @@ -4860,12 +4829,7 @@ final class TerminalSurface: Identifiable, ObservableObject { } return baseConfig.command }() - let resolvedInitialInput: String? = { - if let initialInput, !initialInput.isEmpty { - return initialInput - } - return baseConfig.initialInput - }() + let resolvedInitialInput = baseConfig.initialInput func withOptionalCString(_ value: String?, _ body: (UnsafePointer?) -> T) -> T { guard let value else { return body(nil) @@ -4979,12 +4943,13 @@ final class TerminalSurface: Identifiable, ObservableObject { "workspaceId": tabId ] ) + runPendingReadyCallbacks() #if DEBUG let runtimeFontText = cmuxCurrentSurfaceFontSizePoints(createdSurface).map { String(format: "%.2f", $0) } ?? "nil" - cmuxDebugLog( + dlog( "zoom.create.done surface=\(id.uuidString.prefix(5)) context=\(cmuxSurfaceContextName(surfaceContext)) " + "runtimeFont=\(runtimeFontText)" ) @@ -5016,7 +4981,9 @@ final class TerminalSurface: Identifiable, ObservableObject { Self.sizeLog("updateSize-call surface=\(id.uuidString.prefix(8)) size=\(wpx)x\(hpx) prev=\(lastPixelWidth)x\(lastPixelHeight) changed=\((scaleChanged || sizeChanged) ? 1 : 0)") #endif - guard scaleChanged || sizeChanged else { return false } + guard scaleChanged || sizeChanged else { + return false + } #if DEBUG if sizeChanged { @@ -5054,18 +5021,14 @@ final class TerminalSurface: Identifiable, ObservableObject { viewState = "NO_ATTACHED_VIEW hasSurface=\(hasSurface)" } #if DEBUG - cmuxDebugLog("forceRefresh: \(id) reason=\(reason) \(viewState)") + dlog("forceRefresh: \(id) reason=\(reason) \(viewState)") #endif guard let view = attachedView, - let surface, view.window != nil, view.bounds.width > 0, view.bounds.height > 0 else { return } -#if DEBUG - recordDebugForceRefresh() -#endif guard let currentSurface = self.surface else { return } // Re-read self.surface before each ghostty call to guard against the surface @@ -5233,34 +5196,36 @@ final class TerminalSurface: Identifiable, ObservableObject { } guard allowsRuntimeSurfaceCreation() else { return } - guard surface == nil, attachedView != nil else { return } - guard !backgroundSurfaceStartQueued else { return } - backgroundSurfaceStartQueued = true + guard surface == nil else { return } + hostedView.requestBackgroundRuntimeStartIfNeeded(reason: "surface.backgroundStart") + } - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.backgroundSurfaceStartQueued = false - guard self.allowsRuntimeSurfaceCreation() else { return } - guard self.surface == nil, let view = self.attachedView else { return } - guard view.window != nil else { - #if DEBUG - cmuxDebugLog( - "surface.background_start.defer surface=\(self.id.uuidString.prefix(8)) reason=noWindow" - ) - #endif - return - } - #if DEBUG - let startedAt = ProcessInfo.processInfo.systemUptime - #endif - self.createSurface(for: view) - #if DEBUG - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 - cmuxDebugLog( - "surface.background_start surface=\(self.id.uuidString.prefix(8)) inWindow=\(view.window != nil ? 1 : 0) ready=\(self.surface != nil ? 1 : 0) ms=\(String(format: "%.2f", elapsedMs))" - ) - #endif + @discardableResult + func onRuntimeReady(_ action: @escaping () -> Void) -> UUID? { + dispatchPrecondition(condition: .onQueue(.main)) + + guard surface == nil else { + action() + return nil } + + let token = UUID() + pendingReadyCallbacks[token] = action + return token + } + + func cancelRuntimeReadyCallback(_ token: UUID?) { + dispatchPrecondition(condition: .onQueue(.main)) + guard let token else { return } + pendingReadyCallbacks.removeValue(forKey: token) + } + + private func runPendingReadyCallbacks() { + dispatchPrecondition(condition: .onQueue(.main)) + guard !pendingReadyCallbacks.isEmpty else { return } + let callbacks = Array(pendingReadyCallbacks.values) + pendingReadyCallbacks.removeAll() + callbacks.forEach { $0() } } private func writeTextData(_ data: Data, to surface: ghostty_surface_t) { @@ -5422,7 +5387,7 @@ final class TerminalSurface: Identifiable, ObservableObject { count += 1 } } - cmuxDebugLog( + dlog( "surface.socket_input.queue surface=\(id.uuidString.prefix(8)) items=\(pendingSocketInputQueue.count) " + "keys=\(pendingKeys) bytes=\(pendingSocketInputBytes)" ) @@ -5447,7 +5412,7 @@ final class TerminalSurface: Identifiable, ObservableObject { } } #if DEBUG - cmuxDebugLog( + dlog( "surface.socket_input.flush surface=\(id.uuidString.prefix(8)) items=\(queued.count) " + "keys=\(queuedKeys) bytes=\(queuedBytes)" ) @@ -5551,7 +5516,7 @@ final class TerminalSurface: Identifiable, ObservableObject { guard let surfaceToFree else { #if DEBUG - cmuxDebugLog( + dlog( "surface.lifecycle.deinit.skip surface=\(id.uuidString.prefix(5)) " + "workspace=\(tabId.uuidString.prefix(5)) reason=noRuntimeSurface" ) @@ -5571,7 +5536,7 @@ final class TerminalSurface: Identifiable, ObservableObject { #if DEBUG let surfaceToken = String(id.uuidString.prefix(5)) let workspaceToken = String(tabId.uuidString.prefix(5)) - cmuxDebugLog( + dlog( "surface.lifecycle.deinit.begin surface=\(surfaceToken) " + "workspace=\(workspaceToken) hasAttachedView=\(attachedView != nil ? 1 : 0) " + "hostedInWindow=\(hostedView.window != nil ? 1 : 0)" @@ -5585,7 +5550,7 @@ final class TerminalSurface: Identifiable, ObservableObject { ghostty_surface_free(surfaceToFree) callbackContext?.release() #if DEBUG - cmuxDebugLog( + dlog( "surface.lifecycle.deinit.end surface=\(surfaceToken) " + "workspace=\(workspaceToken) freed=1" ) @@ -5670,6 +5635,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private let _scrollbarLock = NSLock() var cellSize: CGSize = .zero private var lastKnownMousePointInView: NSPoint? + weak var hostedScrollView: GhosttySurfaceScrollView? /// Coalesce high-frequency scrollbar updates into a single main-thread /// dispatch. The action callback (which may fire thousands of times per @@ -5803,7 +5769,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } override func makeBackingLayer() -> CALayer { - let metalLayer = CAMetalLayer() + let metalLayer = GhosttyMetalLayer() metalLayer.pixelFormat = .bgra8Unorm metalLayer.isOpaque = false // framebufferOnly=false lets the macOS compositor read the drawable @@ -5811,12 +5777,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // standalone Ghostty's SurfaceView and is required for background-opacity // and background-blur to render correctly. metalLayer.framebufferOnly = false + metalLayer.onNextDrawable = { [weak self] in + self?.hostedScrollView?.handleSurfaceFirstDrawable() + } return metalLayer } private func setup() { - // Only enable our instrumented CAMetalLayer in targeted debug/test scenarios. - // The lock in GhosttyMetalLayer.nextDrawable() adds overhead we don't want in normal runs. wantsLayer = true layer?.masksToBounds = true installEventMonitor() @@ -5835,7 +5802,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { usesHostLayerBackground: GhosttyApp.shared.usesHostLayerBackground ) let sharesWindowBackdrop = Workspace.usesWindowRootTerminalBackdrop() - let usesBonsplitPaneBackdrop = Workspace.usesBonsplitPaneTerminalBackdrop( + let usesWorkspaceSplitPaneBackdrop = Workspace.usesWorkspaceSplitPaneTerminalBackdrop( renderingMode: renderingMode, sharesWindowBackdrop: sharesWindowBackdrop ) @@ -5844,7 +5811,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // double-composite it against the surrounding chrome. let usesHostLayerFill = renderingMode.usesWindowHostBackdrop && !sharesWindowBackdrop && - !usesBonsplitPaneBackdrop + !usesWorkspaceSplitPaneBackdrop let color = usesHostLayerFill ? effectiveBackgroundColor() : .clear @@ -5859,7 +5826,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } terminalSurface?.hostedView.setBackgroundColor(color) if GhosttyApp.shared.backgroundLogEnabled { - let signature = "\(usesHostLayerFill ? color.hexString() : "transparent-host"):\(String(format: "%.3f", color.alphaComponent)):\(sharesWindowBackdrop ? "shared" : (usesBonsplitPaneBackdrop ? "bonsplit-pane" : "terminal"))" + let signature = "\(usesHostLayerFill ? color.hexString() : "transparent-host"):\(String(format: "%.3f", color.alphaComponent)):\(sharesWindowBackdrop ? "shared" : (usesWorkspaceSplitPaneBackdrop ? "workspace-split-pane" : "terminal"))" if signature != lastLoggedSurfaceBackgroundSignature { lastLoggedSurfaceBackgroundSignature = signature let hasOverride = backgroundColor != nil @@ -5870,10 +5837,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { : ( sharesWindowBackdrop ? "sharedWindowBackdrop" - : (usesBonsplitPaneBackdrop ? "bonsplitPaneBackdrop" : "ghosttyNativeBackground") + : (usesWorkspaceSplitPaneBackdrop ? "workspaceSplitPaneBackdrop" : "ghosttyNativeBackground") ) GhosttyApp.shared.logBackground( - "surface background applied tab=\(tabId?.uuidString ?? "unknown") surface=\(terminalSurface?.id.uuidString ?? "unknown") source=\(source) override=\(overrideHex) default=\(defaultHex) sharedWindowBackdrop=\(sharesWindowBackdrop ? 1 : 0) bonsplitPaneBackdrop=\(usesBonsplitPaneBackdrop ? 1 : 0) color=\(color.hexString()) opacity=\(String(format: "%.3f", color.alphaComponent))" + "surface background applied tab=\(tabId?.uuidString ?? "unknown") surface=\(terminalSurface?.id.uuidString ?? "unknown") source=\(source) override=\(overrideHex) default=\(defaultHex) sharedWindowBackdrop=\(sharesWindowBackdrop ? 1 : 0) workspaceSplitPaneBackdrop=\(usesWorkspaceSplitPaneBackdrop ? 1 : 0) color=\(color.hexString()) opacity=\(String(format: "%.3f", color.alphaComponent))" ) } } @@ -5975,7 +5942,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { terminalSurface = surface tabId = surface.tabId if !isAlreadyAttached { - surface.attachToView(self) + surface.bindToView(self) } surface.setKeyboardCopyModeActive(keyboardCopyModeActive) if !isAlreadyAttached { @@ -5997,7 +5964,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { NSCursor.pop() } #if DEBUG - cmuxDebugLog( + dlog( "surface.view.windowMove surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "inWindow=\(window != nil ? 1 : 0) bounds=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + "pending=\(String(format: "%.1fx%.1f", pendingSurfaceSize?.width ?? 0, pendingSurfaceSize?.height ?? 0))" @@ -6005,8 +5972,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #endif guard let window else { return } - // If the surface creation was deferred while detached, create/attach it now. - terminalSurface?.attachToView(self) if let terminalSurface { NotificationCenter.default.post( name: .terminalSurfaceHostedViewDidMoveToWindow, @@ -6037,6 +6002,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { superview?.layoutSubtreeIfNeeded() layoutSubtreeIfNeeded() updateSurfaceSize() + terminalSurface?.hostedView.refreshPanelBackgroundFill(reason: "surfaceView.viewDidMoveToWindow") applySurfaceBackground() applySurfaceColorScheme(force: true) GhosttyApp.shared.synchronizeThemeWithAppearance( @@ -6075,13 +6041,21 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { layer?.contentsScale = window.backingScaleFactor CATransaction.commit() } - updateSurfaceSize() + if visibleInUI && !isHiddenOrHasHiddenAncestor { + updateSurfaceSize() + } else { + pendingSurfaceSize = resolvedSurfaceSize(preferred: nil) + } invalidateTextInputCoordinates() } override func layout() { super.layout() - updateSurfaceSize() + if visibleInUI && !isHiddenOrHasHiddenAncestor { + updateSurfaceSize() + } else { + pendingSurfaceSize = resolvedSurfaceSize(preferred: nil) + } invalidateTextInputCoordinates() } @@ -6161,7 +6135,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #if DEBUG let signature = "nonPositive-\(Int(size.width))x\(Int(size.height))" if lastSizeSkipSignature != signature { - cmuxDebugLog( + dlog( "surface.size.defer surface=\(terminalSurface.id.uuidString.prefix(5)) " + "reason=nonPositive size=\(String(format: "%.1fx%.1f", size.width, size.height)) " + "inWindow=\(window != nil ? 1 : 0)" @@ -6177,7 +6151,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #if DEBUG let signature = "\(deferralReason)-\(Int(size.width.rounded()))x\(Int(size.height.rounded()))" if lastSizeSkipSignature != signature { - cmuxDebugLog( + dlog( "surface.size.defer surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(deferralReason) " + "size=\(String(format: "%.1fx%.1f", size.width, size.height)) " + "inWindow=\(window != nil ? 1 : 0)" @@ -6192,7 +6166,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #if DEBUG let signature = "noWindow-\(Int(size.width))x\(Int(size.height))" if lastSizeSkipSignature != signature { - cmuxDebugLog( + dlog( "surface.size.defer surface=\(terminalSurface.id.uuidString.prefix(5)) reason=noWindow " + "size=\(String(format: "%.1fx%.1f", size.width, size.height))" ) @@ -6209,7 +6183,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #if DEBUG let signature = "zeroBacking-\(Int(backingSize.width))x\(Int(backingSize.height))" if lastSizeSkipSignature != signature { - cmuxDebugLog( + dlog( "surface.size.defer surface=\(terminalSurface.id.uuidString.prefix(5)) reason=zeroBacking " + "size=\(String(format: "%.1fx%.1f", size.width, size.height)) " + "backing=\(String(format: "%.1fx%.1f", backingSize.width, backingSize.height))" @@ -6221,7 +6195,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } #if DEBUG if lastSizeSkipSignature != nil { - cmuxDebugLog( + dlog( "surface.size.resume surface=\(terminalSurface.id.uuidString.prefix(5)) " + "size=\(String(format: "%.1fx%.1f", size.width, size.height)) " + "backing=\(String(format: "%.1fx%.1f", backingSize.width, backingSize.height))" @@ -6274,6 +6248,21 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { updateSurfaceSize(size: size) } + @discardableResult + func realizeRuntimeIfPossible(reason: String) -> Bool { + let hadRuntime = terminalSurface?.surface != nil + terminalSurface?.realizeRuntimeIfPossible(reason: reason) + let hasRuntime = terminalSurface?.surface != nil + if hasRuntime { + applySurfaceColorScheme(force: true) + } + return !hadRuntime && hasRuntime + } + + func requestNextDrawableNotification() { + (layer as? GhosttyMetalLayer)?.requestNextDrawableNotification() + } + #if DEBUG fileprivate func debugPendingSurfaceSize() -> CGSize? { pendingSurfaceSize @@ -6337,31 +6326,23 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return surface } guard window != nil else { return nil } - terminalSurface?.attachToView(self) - updateSurfaceSize(size: bounds.size) - applySurfaceColorScheme(force: true) + hostedScrollView?.reconcileViewportLifecycle( + reason: "surfaceView.ensureReadyForInput", + force: true + ) return surface } private func requestInputRecoveryAfterSurfaceMiss(reason: String) { terminalSurface?.requestBackgroundSurfaceStartIfNeeded() #if DEBUG - cmuxDebugLog( + dlog( "focus.input_recovery surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "reason=\(reason) inWindow=\(window != nil ? 1 : 0)" ) #endif } - @discardableResult - func prepareSurfaceForPaste(reason: String) -> Bool { - guard ensureSurfaceReadyForInput() != nil else { - requestInputRecoveryAfterSurfaceMiss(reason: reason) - return false - } - return true - } - func performBindingAction(_ action: String) -> Bool { guard let surface = surface else { return false } return action.withCString { cString in @@ -6607,13 +6588,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // MARK: - Clipboard paste @IBAction func paste(_ sender: Any?) { - guard prepareSurfaceForPaste(reason: "paste.missingSurface") else { return } _ = performBindingAction("paste_from_clipboard") } /// Pastes clipboard text as plain text, stripping any rich formatting. @IBAction func pasteAsPlainText(_ sender: Any?) { - guard prepareSurfaceForPaste(reason: "pasteAsPlainText.missingSurface") else { return } _ = performBindingAction("paste_from_clipboard") } @@ -6673,7 +6652,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { guard !content.isEmpty else { return } #if DEBUG - cmuxDebugLog("ime.ax.setValue len=\(content.count)") + dlog("ime.ax.setValue len=\(content.count)") #endif let inject = { @@ -6777,14 +6756,14 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // the old view from stealing focus and creating model/surface divergence. if suppressingReparentFocus { #if DEBUG - cmuxDebugLog("focus.firstResponder SUPPRESSED (reparent) surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")") + dlog("focus.firstResponder SUPPRESSED (reparent) surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")") #endif return result } - // Always notify the host app that this pane became the first responder so bonsplit + // Always notify the host app that this pane became the first responder so WorkspaceSplit // focus/selection can converge. Previously this was gated on `surface != nil`, which - // allowed a mismatch where AppKit focus moved but the UI focus indicator (bonsplit) + // allowed a mismatch where AppKit focus moved but the UI focus indicator (WorkspaceSplit) // stayed behind. let hiddenInHierarchy = isHiddenOrHasHiddenAncestor if isVisibleInUI && hasUsableFocusGeometry && !hiddenInHierarchy { @@ -6792,7 +6771,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { onFocus?() } else if isVisibleInUI && (!hasUsableFocusGeometry || hiddenInHierarchy) { #if DEBUG - cmuxDebugLog( + dlog( "focus.firstResponder SUPPRESSED (hidden_or_tiny) surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) hidden=\(hiddenInHierarchy ? 1 : 0)" ) @@ -6804,7 +6783,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let deltaMs = (now - lastScrollEventTime) * 1000 Self.focusLog("becomeFirstResponder: surface=\(terminalSurface?.id.uuidString ?? "nil") deltaSinceScrollMs=\(String(format: "%.2f", deltaMs))") #if DEBUG - cmuxDebugLog("focus.firstResponder surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")") + dlog("focus.firstResponder surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")") if let terminalSurface { AppDelegate.shared?.recordJumpUnreadFocusIfExpected( tabId: terminalSurface.tabId, @@ -6909,14 +6888,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } override func performKeyEquivalent(with event: NSEvent) -> Bool { - performKeyEquivalent(with: event, shouldRetryMainMenu: true) - } - - func performKeyEquivalentAfterMenuMiss(with event: NSEvent) -> Bool { - performKeyEquivalent(with: event, shouldRetryMainMenu: false) - } - - private func performKeyEquivalent(with event: NSEvent, shouldRetryMainMenu: Bool) -> Bool { #if DEBUG let typingTimingStart = CmuxTypingTiming.start() defer { @@ -6994,7 +6965,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // path so that Edit > Paste handles Cmd+V instead of keyDown double- // firing the clipboard request through both interpretKeyEvents and // ghostty_surface_key. - if shouldRetryMainMenu && isConsumed && !isAll && keySequence.isEmpty && keyTables.isEmpty { + if isConsumed && !isAll && keySequence.isEmpty && keyTables.isEmpty { if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) { return true } @@ -7168,6 +7139,17 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // This avoids intermittent drops after rapid split close/reparent transitions. let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) if flags.contains(.control) && !flags.contains(.command) && !flags.contains(.option) && !hasMarkedText() { +#if DEBUG + if isDebugCtrlD(event) { + latencyLog( + "ctrl_d.terminal.keyDown.begin", + data: [ + "surface": terminalSurface?.id.uuidString.prefix(5).description ?? "nil", + "window": String(window?.windowNumber ?? -1), + ] + ) + } +#endif terminalSurface?.recordExternalFocusState(true) ghostty_surface_set_focus(surface, true) var keyEvent = ghostty_input_key_s() @@ -7214,11 +7196,20 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #endif } #if DEBUG - cmuxDebugLog( + dlog( "key.ctrl path=ghostty surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "handled=\(handled ? 1 : 0) keyCode=\(event.keyCode) chars=\(cmuxScalarHex(event.characters)) " + "ign=\(cmuxScalarHex(event.charactersIgnoringModifiers)) mods=\(event.modifierFlags.rawValue)" ) + if isDebugCtrlD(event) { + latencyLog( + "ctrl_d.terminal.keyDown.sent", + data: [ + "handled": handled ? "1" : "0", + "surface": terminalSurface?.id.uuidString.prefix(5).description ?? "nil", + ] + ) + } #endif // If Ghostty handled the key (action/encoding), we're done. // If not (e.g. `ignore` keybind), fall through to interpretKeyEvents @@ -7897,7 +7888,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private func requestPointerFocusRecovery() { #if DEBUG - cmuxDebugLog("focus.pointerDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")") + dlog("focus.pointerDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")") #endif onFocus?() } @@ -7905,7 +7896,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override func mouseDown(with event: NSEvent) { #if DEBUG let debugPoint = convert(event.locationInWindow, from: nil) - cmuxDebugLog("terminal.mouseDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") mods=[\(debugModifierString(event.modifierFlags))] clickCount=\(event.clickCount) point=(\(String(format: "%.0f", debugPoint.x)),\(String(format: "%.0f", debugPoint.y)))") + dlog("terminal.mouseDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") mods=[\(debugModifierString(event.modifierFlags))] clickCount=\(event.clickCount) point=(\(String(format: "%.0f", debugPoint.x)),\(String(format: "%.0f", debugPoint.y)))") #endif // Split reparent/layout churn can suppress the later `becomeFirstResponder -> onFocus` // callback. Treat pointer-down as explicit focus intent so clicking a ghost pane still @@ -7938,7 +7929,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override func mouseUp(with event: NSEvent) { #if DEBUG - cmuxDebugLog("terminal.mouseUp surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") mods=[\(debugModifierString(event.modifierFlags))]") + dlog("terminal.mouseUp surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") mods=[\(debugModifierString(event.modifierFlags))]") #endif guard let surface = surface else { return } let point = convert(event.locationInWindow, from: nil) @@ -7952,7 +7943,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { guard let resolution = resolveWordUnderCursorPath(at: point) else { return } #if DEBUG - cmuxDebugLog("link.wordFallback resolved=\(resolution.path) source=\(resolution.source.rawValue)") + dlog("link.wordFallback resolved=\(resolution.path) source=\(resolution.source.rawValue)") #endif PreferredEditorSettings.open(URL(fileURLWithPath: resolution.path)) @@ -8134,7 +8125,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { workspace: Workspace, terminalSurface: TerminalSurface ) -> String? { - if let dir = workspace.panelDirectories[terminalSurface.id]?.trimmingCharacters(in: .whitespacesAndNewlines), + if let dir = workspace.panelDirectory(panelId: terminalSurface.id)?.trimmingCharacters(in: .whitespacesAndNewlines), !dir.isEmpty { return dir } @@ -8355,7 +8346,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } #if DEBUG - cmuxDebugLog( + dlog( "link.wordFallback resolved=\(resolution.path) source=\(resolution.source.rawValue) consumed=\(ghosttyConsumed ? 1 : 0)" ) var payload: [String: Any] = [ @@ -8830,7 +8821,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { deinit { // Surface lifecycle is managed by TerminalSurface, not the view #if DEBUG - cmuxDebugLog( + dlog( "surface.view.deinit view=\(Unmanaged.passUnretained(self).toOpaque()) " + "surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "inWindow=\(window != nil ? 1 : 0) hasSuperview=\(superview != nil ? 1 : 0)" @@ -9040,7 +9031,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { DispatchQueue.main.async { NSSound.beep() #if DEBUG - cmuxDebugLog("terminal.remoteDropUpload.failed surface=\(self?.terminalSurface?.id.uuidString.prefix(5) ?? "nil")") + dlog("terminal.remoteDropUpload.failed surface=\(self?.terminalSurface?.id.uuidString.prefix(5) ?? "nil")") #endif } } @@ -9114,14 +9105,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { #if DEBUG let types = sender.draggingPasteboard.types ?? [] - cmuxDebugLog("terminal.draggingEntered surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") types=\(types.map(\.rawValue))") + dlog("terminal.draggingEntered surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") types=\(types.map(\.rawValue))") #endif guard let types = sender.draggingPasteboard.types else { return [] } - // Defer to bonsplit when a tab/session drag is in flight: bonsplit's pane - // drop overlays should win over the terminal's text/file drop handling. - if types.contains(Self.tabTransferPasteboardType) || types.contains(Self.sidebarTabReorderPasteboardType) { - return [] - } if Set(types).isDisjoint(with: Self.dropTypes) { return [] } @@ -9131,12 +9117,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { #if DEBUG let types = sender.draggingPasteboard.types ?? [] - cmuxDebugLog("terminal.draggingUpdated surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") types=\(types.map(\.rawValue))") + dlog("terminal.draggingUpdated surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") types=\(types.map(\.rawValue))") #endif guard let types = sender.draggingPasteboard.types else { return [] } - if types.contains(Self.tabTransferPasteboardType) || types.contains(Self.sidebarTabReorderPasteboardType) { - return [] - } if Set(types).isDisjoint(with: Self.dropTypes) { return [] } @@ -9144,12 +9127,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { - let types = sender.draggingPasteboard.types ?? [] - if types.contains(Self.tabTransferPasteboardType) || types.contains(Self.sidebarTabReorderPasteboardType) { - return false - } #if DEBUG - cmuxDebugLog("terminal.fileDrop surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")") + dlog("terminal.fileDrop surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")") #endif return insertDroppedPasteboard(sender.draggingPasteboard) } @@ -9196,7 +9175,6 @@ extension Notification.Name { static let ghosttySearchFocus = Notification.Name("ghosttySearchFocus") static let ghosttyConfigDidReload = Notification.Name("ghosttyConfigDidReload") static let ghosttyDefaultBackgroundDidChange = Notification.Name("ghosttyDefaultBackgroundDidChange") - static let browserSearchFocus = Notification.Name("browserSearchFocus") } // MARK: - Scroll View Wrapper (Ghostty-style scrollbar) @@ -9262,8 +9240,204 @@ func shouldAllowEnsureFocusWindowActivation( return true } -final class GhosttySurfaceScrollView: NSView { - enum FlashStyle { +@MainActor +private final class GhosttyWindowFocusCoordinator { + private struct Entry { + weak var owner: GhosttySurfaceScrollView? + var generation: UInt64 + } + + static let shared = GhosttyWindowFocusCoordinator() + private var entries: [ObjectIdentifier: Entry] = [:] + + private init() {} + + func claim(window: NSWindow, owner: GhosttySurfaceScrollView) -> UInt64 { + pruneReleasedOwners() + let key = ObjectIdentifier(window) + let nextGeneration = (entries[key]?.generation ?? 0) &+ 1 + entries[key] = Entry(owner: owner, generation: nextGeneration) + return nextGeneration + } + + func owns(window: NSWindow, owner: GhosttySurfaceScrollView, generation: UInt64) -> Bool { + pruneReleasedOwners() + guard let entry = entries[ObjectIdentifier(window)], + entry.generation == generation, + entry.owner === owner else { + return false + } + return true + } + + func release(window: NSWindow?, owner: GhosttySurfaceScrollView) { + guard let window else { return } + let key = ObjectIdentifier(window) + guard let entry = entries[key], entry.owner === owner else { return } + entries.removeValue(forKey: key) + } + + private func pruneReleasedOwners() { + entries = entries.filter { $0.value.owner != nil } + } +} + +enum TerminalViewportRuntimeActivationDemand: Int, Comparable, Sendable { + case none + case background + case visible + + static func < (lhs: TerminalViewportRuntimeActivationDemand, rhs: TerminalViewportRuntimeActivationDemand) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +enum TerminalViewportLifecyclePhase: String, Equatable, Sendable { + case detached + case mountedHidden + case mountedAwaitingWindow + case mountedAwaitingGeometry + case runtimeRealized + case awaitingFirstFrame + case visible + case visibleFocused +} + +struct TerminalViewportLifecycleFacts: Equatable, Sendable { + let isVisibleInUI: Bool + let isWindowed: Bool + let hasUsableGeometry: Bool + let hasRuntime: Bool + let hasPresentedFrame: Bool + let isActive: Bool +} + +enum TerminalViewportLifecycleCommand: Equatable, Sendable { + case realizeRuntime + case synchronizeVisibleGeometry + case requestFirstFrame + case resumeFocus +} + +struct TerminalViewportLifecycleUpdate: Equatable, Sendable { + let phase: TerminalViewportLifecyclePhase + let demand: TerminalViewportRuntimeActivationDemand + let commands: [TerminalViewportLifecycleCommand] +} + +@MainActor +final class TerminalViewportLifecycleController { + private(set) var phase: TerminalViewportLifecyclePhase = .detached + private var backgroundRuntimeRequested = false + private var lastFacts: TerminalViewportLifecycleFacts? + private var lastDemand: TerminalViewportRuntimeActivationDemand = .none + private var issuedFirstFrameRequestForCurrentAwait = false + + func requestBackgroundRuntime() { + backgroundRuntimeRequested = true + } + + func reset() { + phase = .detached + backgroundRuntimeRequested = false + lastFacts = nil + lastDemand = .none + issuedFirstFrameRequestForCurrentAwait = false + } + + private func effectiveDemand(for facts: TerminalViewportLifecycleFacts) -> TerminalViewportRuntimeActivationDemand { + if facts.isVisibleInUI { + return .visible + } + return backgroundRuntimeRequested ? .background : .none + } + + func reconcile(facts: TerminalViewportLifecycleFacts, force: Bool) -> TerminalViewportLifecycleUpdate? { + let previousFacts = lastFacts + let demand = effectiveDemand(for: facts) + let previousPhase = phase + let previousDemand = lastDemand + var commands: [TerminalViewportLifecycleCommand] = [] + let nextPhase: TerminalViewportLifecyclePhase + + switch demand { + case .none: + issuedFirstFrameRequestForCurrentAwait = false + nextPhase = facts.isWindowed ? .mountedHidden : .detached + + case .background: + issuedFirstFrameRequestForCurrentAwait = false + if !facts.isWindowed { + nextPhase = .mountedAwaitingWindow + } else if !facts.hasUsableGeometry { + nextPhase = .mountedHidden + } else if facts.hasRuntime { + nextPhase = .runtimeRealized + backgroundRuntimeRequested = false + } else { + nextPhase = .runtimeRealized + commands.append(.realizeRuntime) + } + + case .visible: + if !facts.isWindowed { + issuedFirstFrameRequestForCurrentAwait = false + nextPhase = .mountedAwaitingWindow + } else if !facts.hasUsableGeometry { + issuedFirstFrameRequestForCurrentAwait = false + nextPhase = .mountedAwaitingGeometry + } else if !facts.hasRuntime { + issuedFirstFrameRequestForCurrentAwait = false + nextPhase = .runtimeRealized + commands.append(.realizeRuntime) + commands.append(.synchronizeVisibleGeometry) + } else if !facts.hasPresentedFrame { + nextPhase = .awaitingFirstFrame + commands.append(.synchronizeVisibleGeometry) + if !issuedFirstFrameRequestForCurrentAwait || previousPhase != .awaitingFirstFrame { + commands.append(.requestFirstFrame) + issuedFirstFrameRequestForCurrentAwait = true + } + } else { + issuedFirstFrameRequestForCurrentAwait = false + nextPhase = facts.isActive ? .visibleFocused : .visible + commands.append(.synchronizeVisibleGeometry) + if facts.isActive { + commands.append(.resumeFocus) + } + } + } + + phase = nextPhase + lastFacts = facts + lastDemand = demand + + if !force, + nextPhase == previousPhase, + demand == previousDemand, + facts == previousFacts, + commands.isEmpty { + return nil + } + + return TerminalViewportLifecycleUpdate( + phase: nextPhase, + demand: demand, + commands: Self.deduplicated(commands) + ) + } + + private static func deduplicated(_ commands: [TerminalViewportLifecycleCommand]) -> [TerminalViewportLifecycleCommand] { + var result: [TerminalViewportLifecycleCommand] = [] + for command in commands where !result.contains(command) { + result.append(command) + } + return result + } +} + +final class GhosttySurfaceScrollView: NSView { + enum FlashStyle { case navigation case notification } @@ -9295,7 +9469,7 @@ final class GhosttySurfaceScrollView: NSView { private let backgroundView: NSView private let scrollView: GhosttyScrollView private let documentView: NSView - private let surfaceView: GhosttyNSView + let surfaceView: GhosttyNSView private let inactiveOverlayView: GhosttyFlashOverlayView private let dropZoneOverlayView: GhosttyFlashOverlayView private let notificationRingOverlayView: GhosttyFlashOverlayView @@ -9303,6 +9477,7 @@ final class GhosttySurfaceScrollView: NSView { private let flashOverlayView: GhosttyFlashOverlayView private let flashLayer: CAShapeLayer private var lastFlashStyle: FlashStyle = .navigation + private var terminalBackgroundColor: NSColor private let keyboardCopyModeBadgeContainerView: GhosttyFlashOverlayView private let keyboardCopyModeBadgeView: GhosttyPassthroughVisualEffectView private let keyboardCopyModeBadgeIconView: NSImageView @@ -9332,16 +9507,21 @@ final class GhosttySurfaceScrollView: NSView { private var userScrolledAwayFromBottom = false private var pendingExplicitWheelScroll = false private var allowExplicitScrollbarSync = false + private var runtimeReadyCallbackToken: UUID? /// Threshold in points from bottom to consider "at bottom" (allows for minor float drift) private static let scrollToBottomThreshold: CGFloat = 5.0 private var isActive = true private var lastFocusRefreshAt: CFTimeInterval = 0 private var lastRequestedPortalOcclusionVisible: Bool? + private let viewportLifecycleController = TerminalViewportLifecycleController() + private var presentationChangeHandler: (() -> Void)? + private var lastReportedPresentationFacts: WorkspaceSurfacePresentationFacts? private var activeDropZone: DropZone? private var pendingDropZone: DropZone? + private var lastPanelBackgroundFallbackVisible: Bool? private var dropZoneOverlayAnimationGeneration: UInt64 = 0 - private var pendingAutomaticFirstResponderApply = false - // Intentionally no focus retry loops: rely on AppKit first-responder and bonsplit selection. + private var automaticFirstResponderTask: Task? + private var pendingAutomaticFirstResponderGeneration: UInt64 = 0 /// Tracks whether keyboard focus should go to the search field or the terminal /// when the window becomes key while the find bar is open. @@ -9351,11 +9531,25 @@ final class GhosttySurfaceScrollView: NSView { } private(set) var searchFocusTarget: SearchFocusTarget = .searchField + private static func panelBackgroundFillColor( + for terminalBackgroundColor: NSColor, + showingFallback: Bool + ) -> NSColor { + guard terminalBackgroundColor.alphaComponent < 0.999 else { + return terminalBackgroundColor + } + + // The Ghostty renderer already draws translucent terminal backgrounds. Keep the hosted + // fill clear once the Metal surface is live, but retain the real terminal background while + // the surface is still starting or geometry is catching up so the pane host does not flash through. + return showingFallback ? terminalBackgroundColor : .clear + } #if DEBUG private var lastDropZoneOverlayLogSignature: String? private var lastDragGeometryLogSignature: String? private var dragLayoutLogSequence: UInt64 = 0 + private var liveResizeLogSequence: UInt64 = 0 private static let tabTransferPasteboardType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") private static let sidebarTabReorderPasteboardType = NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder") private static var flashCounts: [UUID: Int] = [:] @@ -9449,7 +9643,7 @@ final class GhosttySurfaceScrollView: NSView { if useDeferredPath { pendingDropZone = .left - synchronizeGeometryAndContent() + _ = synchronizeGeometryAndContent() } else { setDropZoneOverlay(zone: .left) } @@ -9463,6 +9657,10 @@ final class GhosttySurfaceScrollView: NSView { surfaceView.terminalSurface?.id } + var debugRuntimeSurfaceReady: Bool { + surfaceView.terminalSurface?.surface != nil + } + var debugCellSize: CGSize { surfaceView.cellSize } @@ -9510,22 +9708,11 @@ final class GhosttySurfaceScrollView: NSView { ) } - func releaseOwnedPortalHost(hostId: ObjectIdentifier, reason: String) { - surfaceView.terminalSurface?.releasePortalHostIfOwned( - hostId: hostId, - reason: reason - ) - } - - func prepareOwnedPortalHostForTransientReattach(hostId: ObjectIdentifier, reason: String) { - surfaceView.terminalSurface?.preparePortalHostReplacementIfOwned( - hostId: hostId, - reason: reason - ) - } - init(surfaceView: GhosttyNSView) { + let initialTerminalBackground = GhosttyApp.shared.defaultBackgroundColor + .withAlphaComponent(GhosttyApp.shared.defaultBackgroundOpacity) self.surfaceView = surfaceView + terminalBackgroundColor = initialTerminalBackground backgroundView = NSView(frame: .zero) scrollView = GhosttyScrollView() inactiveOverlayView = GhosttyFlashOverlayView(frame: .zero) @@ -9559,12 +9746,17 @@ final class GhosttySurfaceScrollView: NSView { documentView.addSubview(surfaceView) super.init(frame: .zero) + surfaceView.hostedScrollView = self wantsLayer = true layer?.masksToBounds = true backgroundView.wantsLayer = true - backgroundView.layer?.backgroundColor = NSColor.clear.cgColor - backgroundView.layer?.isOpaque = false + let initialPanelFill = Self.panelBackgroundFillColor( + for: initialTerminalBackground, + showingFallback: true + ) + backgroundView.layer?.backgroundColor = initialPanelFill.cgColor + backgroundView.layer?.isOpaque = initialPanelFill.alphaComponent >= 1.0 addSubview(backgroundView) addSubview(scrollView) synchronizeScrollbarAppearance() @@ -9778,24 +9970,6 @@ final class GhosttySurfaceScrollView: NSView { self?.handleScrollbarUpdate(notification) }) - observers.append(NotificationCenter.default.addObserver( - forName: .terminalSurfaceDidBecomeReady, - object: nil, - queue: .main - ) { [weak self] notification in - guard let self, - let readySurfaceId = notification.userInfo?["surfaceId"] as? UUID, - readySurfaceId == self.surfaceView.terminalSurface?.id else { - return - } - // Session restore can request focus before the runtime surface exists. - // Re-run the normal first-responder/focus path once the surface is live. - guard self.isActive || self.surfaceView.desiredFocus || self.isSurfaceViewFirstResponder() else { - return - } - self.scheduleAutomaticFirstResponderApply(reason: "surfaceDidBecomeReady") - }) - observers.append(NotificationCenter.default.addObserver( forName: .ghosttyDidReceiveWheelScroll, object: surfaceView, @@ -9852,12 +10026,13 @@ final class GhosttySurfaceScrollView: NSView { deinit { #if DEBUG - cmuxDebugLog( + dlog( "surface.hosted.deinit surface=\(debugSurfaceId?.uuidString.prefix(5) ?? "nil") " + "inWindow=\(window != nil ? 1 : 0) hasSuperview=\(superview != nil ? 1 : 0) " + "hidden=\(isHidden ? 1 : 0) frame=\(String(format: "%.1fx%.1f", frame.width, frame.height))" ) #endif + surfaceView.terminalSurface?.cancelRuntimeReadyCallback(runtimeReadyCallbackToken) observers.forEach { NotificationCenter.default.removeObserver($0) } if let workspaceTerminalScrollBarObserver { NotificationCenter.default.removeObserver(workspaceTerminalScrollBarObserver) @@ -9907,7 +10082,14 @@ final class GhosttySurfaceScrollView: NSView { override func layout() { super.layout() - synchronizeGeometryAndContent() + guard surfaceView.isVisibleInUI || activeDropZone != nil || pendingDropZone != nil else { + return + } + let requiresOverlayGeometry = activeDropZone != nil || pendingDropZone != nil + reconcileViewportLifecycle(reason: "hostedView.layout", force: false) + if requiresOverlayGeometry && !surfaceView.isVisibleInUI { + _ = synchronizeGeometryAndContent() + } } override func viewDidMoveToSuperview() { @@ -9947,12 +10129,111 @@ final class GhosttySurfaceScrollView: NSView { surfaceView.terminalSurface?.forceRefresh(reason: reason) } - @discardableResult + private func targetSurfaceFrame() -> CGRect { + CGRect(origin: surfaceView.frame.origin, size: scrollView.bounds.size) + } + + private func expectedDrawableSize() -> CGSize { + let targetSize = scrollView.bounds.size + guard targetSize.width > 0, targetSize.height > 0 else { return .zero } + let backingSize = surfaceView.convertToBacking(NSRect(origin: .zero, size: targetSize)).size + return CGSize( + width: floor(max(0, backingSize.width)), + height: floor(max(0, backingSize.height)) + ) + } + + private func surfaceHasPresentedFrame() -> Bool { + let layer = surfaceView.layer?.presentation() ?? surfaceView.layer + return layer?.contents != nil + } + + private func shouldShowPanelBackgroundFallback() -> Bool { + guard terminalBackgroundColor.alphaComponent < 0.999 else { return true } + + let runtimeReady = surfaceView.terminalSurface?.surface != nil + let hasPresentedFrame = surfaceHasPresentedFrame() + let surfaceFrameMatches = Self.rectApproximatelyEqual(surfaceView.frame, targetSurfaceFrame()) + let drawableMatches: Bool = { + guard let metalLayer = surfaceView.layer as? CAMetalLayer else { return surfaceFrameMatches } + let expected = expectedDrawableSize() + guard expected.width > 0, expected.height > 0 else { return surfaceFrameMatches } + return sizeApproximatelyEqual(metalLayer.drawableSize, expected, epsilon: 0.5) + }() + let liveResizeActive = inLiveResize || window?.inLiveResize == true + + return !runtimeReady || !hasPresentedFrame || !surfaceFrameMatches || !drawableMatches || liveResizeActive + } + + func refreshPanelBackgroundFill(reason: String = "unspecified") { + let showingFallback = shouldShowPanelBackgroundFallback() + let fillColor = Self.panelBackgroundFillColor( + for: terminalBackgroundColor, + showingFallback: showingFallback + ) + + guard let layer = backgroundView.layer else { return } + CATransaction.begin() + CATransaction.setDisableActions(true) + layer.backgroundColor = fillColor.cgColor + layer.isOpaque = fillColor.alphaComponent >= 1.0 + CATransaction.commit() + +#if DEBUG + if lastPanelBackgroundFallbackVisible != showingFallback { + lastPanelBackgroundFallbackVisible = showingFallback + CmuxTerminalHostDebugLog.log( + "h3.panelFill reason=\(reason) surface=\(debugSurfaceId?.uuidString.prefix(5) ?? "nil") " + + "fallback=\(showingFallback ? 1 : 0) runtime=\(debugRuntimeSurfaceReady ? 1 : 0) " + + "hasContents=\(surfaceHasPresentedFrame() ? 1 : 0) " + + "frame=\(CmuxTerminalHostDebugLog.rect(surfaceView.frame)) " + + "target=\(CmuxTerminalHostDebugLog.rect(targetSurfaceFrame()))" + ) + } +#endif + } + + private struct GeometrySyncPreparation { + let liveResizeActive: Bool + let totalStartedAt: CFTimeInterval + let previousSurfaceSize: CGSize + let targetSize: CGSize + let didScrollbarAppearanceChange: Bool + } + + private struct GeometrySyncResult { + let didCoreSurfaceChange: Bool + let layoutMs: Double + let scrollSyncMs: Double + let surfaceSyncMs: Double + let coreSyncMs: Double + let fillMs: Double + } + private func synchronizeGeometryAndContent() -> Bool { + let liveResizeActive = inLiveResize || window?.inLiveResize == true + let totalStartedAt = liveResizeActive ? CACurrentMediaTime() : 0 CATransaction.begin() CATransaction.setDisableActions(true) - defer { CATransaction.commit() } + let preparation = prepareGeometrySync( + liveResizeActive: liveResizeActive, + totalStartedAt: totalStartedAt + ) + let result = performGeometrySync(preparation) + let sizeChanged = !sizeApproximatelyEqual( + preparation.previousSurfaceSize, + preparation.targetSize + ) + logGeometrySync(preparation, result: result, sizeChanged: sizeChanged) + CATransaction.commit() + return sizeChanged || result.didCoreSurfaceChange + } + + private func prepareGeometrySync( + liveResizeActive: Bool, + totalStartedAt: CFTimeInterval + ) -> GeometrySyncPreparation { let didScrollbarAppearanceChange = synchronizeScrollbarAppearance() let previousSurfaceSize = surfaceView.frame.size _ = setFrameIfNeeded(backgroundView, to: bounds) @@ -9961,13 +10242,17 @@ final class GhosttySurfaceScrollView: NSView { #if DEBUG logLayoutDuringActiveDrag(targetSize: targetSize) #endif - let targetSurfaceFrame = CGRect(origin: surfaceView.frame.origin, size: targetSize) - _ = setFrameIfNeeded(surfaceView, to: targetSurfaceFrame) - let targetDocumentFrame = CGRect( - origin: documentView.frame.origin, - size: CGSize(width: scrollView.bounds.width, height: documentView.frame.height) + _ = setFrameIfNeeded( + surfaceView, + to: CGRect(origin: surfaceView.frame.origin, size: targetSize) + ) + _ = setFrameIfNeeded( + documentView, + to: CGRect( + origin: documentView.frame.origin, + size: CGSize(width: scrollView.bounds.width, height: documentView.frame.height) + ) ) - _ = setFrameIfNeeded(documentView, to: targetDocumentFrame) _ = setFrameIfNeeded(inactiveOverlayView, to: bounds) if let zone = activeDropZone { attachDropZoneOverlayIfNeeded() @@ -9984,8 +10269,6 @@ final class GhosttySurfaceScrollView: NSView { let frame = dropZoneOverlayFrame(for: pending, in: bounds.size) logDropZoneOverlay(event: "flushPending", zone: pending, frame: frame) #endif - // Reuse the normal show/update path so deferred overlays get the - // same initial animation as direct drop-zone activation. setDropZoneOverlay(zone: pending) } _ = setFrameIfNeeded(notificationRingOverlayView, to: bounds) @@ -9993,19 +10276,72 @@ final class GhosttySurfaceScrollView: NSView { if let overlay = searchOverlayHostingView { _ = setFrameIfNeeded(overlay, to: bounds) } - // NSScrollView can defer clip-view/content-size updates until its own layout pass, - // which makes interactive width changes arrive a queue turn late on Sequoia. - if didScrollbarAppearanceChange { + return GeometrySyncPreparation( + liveResizeActive: liveResizeActive, + totalStartedAt: totalStartedAt, + previousSurfaceSize: previousSurfaceSize, + targetSize: targetSize, + didScrollbarAppearanceChange: didScrollbarAppearanceChange + ) + } + + private func performGeometrySync(_ preparation: GeometrySyncPreparation) -> GeometrySyncResult { + if preparation.didScrollbarAppearanceChange { + // NSScrollView can defer clip-view/content-size updates until its own layout pass, + // which makes interactive width changes arrive a queue turn late on Sequoia. scrollView.tile() } - scrollView.layoutSubtreeIfNeeded() + let layoutMs = measureGeometrySync(preparation.liveResizeActive) { + scrollView.layoutSubtreeIfNeeded() + } updateNotificationRingPath() updateFlashPath(style: lastFlashStyle) updateFlashAppearance(style: lastFlashStyle) - synchronizeScrollView() - synchronizeSurfaceView() - let didCoreSurfaceChange = synchronizeCoreSurface() - return !sizeApproximatelyEqual(previousSurfaceSize, targetSize) || didCoreSurfaceChange + let scrollSyncMs = measureGeometrySync(preparation.liveResizeActive) { + synchronizeScrollView() + } + let surfaceSyncMs = measureGeometrySync(preparation.liveResizeActive) { + synchronizeSurfaceView() + } + var didCoreSurfaceChange = false + let coreSyncMs = measureGeometrySync(preparation.liveResizeActive) { + didCoreSurfaceChange = synchronizeCoreSurface() + } + let fillMs = measureGeometrySync(preparation.liveResizeActive) { + refreshPanelBackgroundFill(reason: "synchronizeGeometryAndContent") + } + return GeometrySyncResult( + didCoreSurfaceChange: didCoreSurfaceChange, + layoutMs: layoutMs, + scrollSyncMs: scrollSyncMs, + surfaceSyncMs: surfaceSyncMs, + coreSyncMs: coreSyncMs, + fillMs: fillMs + ) + } + + private func measureGeometrySync(_ liveResizeActive: Bool, _ body: () -> Void) -> Double { + let startedAt = liveResizeActive ? CACurrentMediaTime() : 0 + body() + return liveResizeActive ? (CACurrentMediaTime() - startedAt) * 1000 : 0 + } + + private func logGeometrySync( + _ preparation: GeometrySyncPreparation, + result: GeometrySyncResult, + sizeChanged: Bool + ) { +#if DEBUG + if preparation.liveResizeActive { + liveResizeLogSequence &+= 1 + let totalMs = (CACurrentMediaTime() - preparation.totalStartedAt) * 1000 + dlog( + "surface.resize surface=\(debugSurfaceId?.uuidString.prefix(5) ?? "nil") " + + "seq=\(liveResizeLogSequence) sizeChanged=\(sizeChanged ? 1 : 0) " + + "coreChanged=\(result.didCoreSurfaceChange ? 1 : 0) totalMs=\(String(format: "%.2f", totalMs))" + ) + } +#endif } @discardableResult @@ -10089,7 +10425,7 @@ final class GhosttySurfaceScrollView: NSView { "\(String(format: "%.1f,%.1f", new.x, new.y))|\(overlaySuperviewClass)|\(dropZoneOverlayView.isHidden ? 1 : 0)" guard lastDragGeometryLogSignature != signature else { return } lastDragGeometryLogSignature = signature - cmuxDebugLog( + dlog( "terminal.dragGeometry event=\(event) surface=\(surface) " + "old=\(String(format: "%.1f,%.1f", old.x, old.y)) " + "new=\(String(format: "%.1f,%.1f", new.x, new.y)) " + @@ -10116,7 +10452,7 @@ final class GhosttySurfaceScrollView: NSView { let pendingZone = pendingDropZone.map { String(describing: $0) } ?? "none" let event = eventType.map { String(describing: $0) } ?? "nil" let overlaySuperviewClass = dropZoneOverlayView.superview.map { String(describing: type(of: $0)) } ?? "nil" - cmuxDebugLog( + dlog( "terminal.layout.drag surface=\(surface) seq=\(dragLayoutLogSequence) " + "activeZone=\(activeZone) pendingZone=\(pendingZone) " + "hasTabDrag=\(hasTabDrag ? 1 : 0) hasSidebarDrag=\(hasSidebarDrag ? 1 : 0) " + @@ -10134,7 +10470,13 @@ final class GhosttySurfaceScrollView: NSView { super.viewDidMoveToWindow() windowObservers.forEach { NotificationCenter.default.removeObserver($0) } windowObservers.removeAll() - guard let window else { return } + cancelFocusRequest() + refreshPanelBackgroundFill(reason: "hostedView.viewDidMoveToWindow") + guard let window else { + notifyPresentationFactsChanged(force: true) + return + } + reconcileViewportLifecycle(reason: "hostedView.viewDidMoveToWindow", force: true) windowObservers.append(NotificationCenter.default.addObserver( forName: NSWindow.didBecomeKeyNotification, object: window, @@ -10143,7 +10485,7 @@ final class GhosttySurfaceScrollView: NSView { guard let self else { return } let searchActive = self.surfaceView.terminalSurface?.searchState != nil #if DEBUG - cmuxDebugLog("find.window.didBecomeKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) focusTarget=\(self.searchFocusTarget) firstResponder=\(String(describing: self.window?.firstResponder))") + dlog("find.window.didBecomeKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) focusTarget=\(self.searchFocusTarget) firstResponder=\(String(describing: self.window?.firstResponder))") #endif self.scheduleAutomaticFirstResponderApply(reason: "didBecomeKey") }) @@ -10159,12 +10501,12 @@ final class GhosttySurfaceScrollView: NSView { if let fr = window.firstResponder as? NSView, fr === self.surfaceView || fr.isDescendant(of: self.surfaceView) { #if DEBUG - cmuxDebugLog("find.window.didResignKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) resigningFirstResponder") + dlog("find.window.didResignKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) resigningFirstResponder") #endif window.makeFirstResponder(nil) } else { #if DEBUG - cmuxDebugLog("find.window.didResignKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) firstResponder=\(String(describing: window.firstResponder)) (not terminal, skipping)") + dlog("find.window.didResignKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) firstResponder=\(String(describing: window.firstResponder)) (not terminal, skipping)") #endif } }) @@ -10174,14 +10516,95 @@ final class GhosttySurfaceScrollView: NSView { } func attachSurface(_ terminalSurface: TerminalSurface) { + if surfaceView.terminalSurface !== terminalSurface { + surfaceView.terminalSurface?.cancelRuntimeReadyCallback(runtimeReadyCallbackToken) + runtimeReadyCallbackToken = nil + viewportLifecycleController.reset() + lastReportedPresentationFacts = nil + } surfaceView.attachSurface(terminalSurface) let workspace = terminalSurface.owningWorkspace() cachedOwningWorkspace = workspace updateWorkspaceTerminalScrollBarObserver(workspace) - // Preserve the bootstrap 800x600 surface until portal reattach churn - // has produced a real host size instead of a transient 1x1 placeholder. - guard bounds.width > 1, bounds.height > 1 else { return } - _ = synchronizeGeometryAndContent() + installRuntimeReadyCallback(for: terminalSurface) + reconcileViewportLifecycle(reason: "hostedView.attachSurface", force: true) + } + + func requestBackgroundRuntimeStartIfNeeded(reason: String) { + guard let terminalSurface = surfaceView.terminalSurface else { return } + guard terminalSurface.surface == nil else { + return + } + viewportLifecycleController.requestBackgroundRuntime() + reconcileViewportLifecycle(reason: reason, force: true) + } + + func handleSurfaceFirstDrawable() { + reconcileViewportLifecycle(reason: "hostedView.firstDrawable", force: true) + } + + private func currentViewportLifecycleFacts() -> TerminalViewportLifecycleFacts { + TerminalViewportLifecycleFacts( + isVisibleInUI: surfaceView.isVisibleInUI, + isWindowed: window != nil, + hasUsableGeometry: bounds.width > 1 && bounds.height > 1, + hasRuntime: surfaceView.terminalSurface?.surface != nil, + hasPresentedFrame: surfaceHasPresentedFrame(), + isActive: isActive + ) + } + + var presentationFacts: WorkspaceSurfacePresentationFacts { + WorkspaceSurfacePresentationFacts.terminal(currentViewportLifecycleFacts()) + } + + private func notifyPresentationFactsChanged(force: Bool = false) { + let nextFacts = presentationFacts + guard force || nextFacts != lastReportedPresentationFacts else { return } + lastReportedPresentationFacts = nextFacts + presentationChangeHandler?() + } + + private func applyViewportLifecycleUpdate(_ update: TerminalViewportLifecycleUpdate, reason: String) { + for command in update.commands { + switch command { + case .realizeRuntime: + if update.demand == .visible { + superview?.layoutSubtreeIfNeeded() + layoutSubtreeIfNeeded() + } + _ = surfaceView.realizeRuntimeIfPossible(reason: reason) + case .synchronizeVisibleGeometry: + _ = synchronizeGeometryAndContent() + case .requestFirstFrame: + surfaceView.requestNextDrawableNotification() + refreshSurfaceNow(reason: "viewport.awaitingFirstFrame") + case .resumeFocus: + resumeReparentFocusIfSuppressed() + } + } + } + + func reconcileViewportLifecycle(reason: String, force: Bool = false) { + let facts = currentViewportLifecycleFacts() + guard surfaceView.terminalSurface != nil, + let update = viewportLifecycleController.reconcile(facts: facts, force: force) else { + return + } + applyViewportLifecycleUpdate(update, reason: reason) + notifyPresentationFactsChanged(force: force) + } + + private func installRuntimeReadyCallback(for terminalSurface: TerminalSurface) { + terminalSurface.cancelRuntimeReadyCallback(runtimeReadyCallbackToken) + runtimeReadyCallbackToken = terminalSurface.onRuntimeReady { [weak self, weak terminalSurface] in + guard let self, let terminalSurface else { return } + guard terminalSurface === self.surfaceView.terminalSurface else { return } + guard self.isActive || self.surfaceView.desiredFocus || self.isSurfaceViewFirstResponder() else { + return + } + self.scheduleAutomaticFirstResponderApply(reason: "surfaceDidBecomeReady") + } } func setFocusHandler(_ handler: (() -> Void)?) { @@ -10207,13 +10630,18 @@ final class GhosttySurfaceScrollView: NSView { surfaceView.onTriggerFlash = handler } + func setPresentationChangeHandler(_ handler: (() -> Void)?) { + presentationChangeHandler = handler + if handler == nil { + lastReportedPresentationFacts = nil + return + } + notifyPresentationFactsChanged(force: true) + } + func setBackgroundColor(_ color: NSColor) { - guard let layer = backgroundView.layer else { return } - CATransaction.begin() - CATransaction.setDisableActions(true) - layer.backgroundColor = color.cgColor - layer.isOpaque = color.alphaComponent >= 1.0 - CATransaction.commit() + terminalBackgroundColor = color + refreshPanelBackgroundFill(reason: "setBackgroundColor") } func setInactiveOverlay(color: NSColor, opacity: CGFloat, visible: Bool) { @@ -10369,7 +10797,7 @@ final class GhosttySurfaceScrollView: NSView { }, onMoveFocusToTerminal: { [weak self] in self?.searchFocusTarget = .terminal - self?.moveFocus() + self?.moveFocus(respectForeignFirstResponder: false) }, onNavigateSearch: { [weak terminalSurface] action in _ = terminalSurface?.performBindingAction(action) @@ -10380,7 +10808,7 @@ final class GhosttySurfaceScrollView: NSView { }, onClose: { [weak self, weak terminalSurface] in terminalSurface?.searchState = nil - self?.moveFocus() + self?.moveFocus(respectForeignFirstResponder: false) } ) } @@ -10481,7 +10909,7 @@ final class GhosttySurfaceScrollView: NSView { surfaceView.terminalSurface?.setFocus(false) let result = window.makeFirstResponder(field) #if DEBUG - cmuxDebugLog( + dlog( "find.mountedFieldFocus surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "result=\(result ? 1 : 0) attemptsRemaining=\(attemptsRemaining) " + "firstResponder=\(String(describing: window.firstResponder))" @@ -10520,7 +10948,7 @@ final class GhosttySurfaceScrollView: NSView { return } #if DEBUG - cmuxDebugLog("find.setSearchOverlay REMOVE surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") hadOverlay=\(hadOverlay)") + dlog("find.setSearchOverlay REMOVE surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") hadOverlay=\(hadOverlay)") #endif scheduleDeferredSearchOverlayMutation(generation: mutationGeneration) { [weak self] in self?.searchOverlayHostingView?.removeFromSuperview() @@ -10541,7 +10969,7 @@ final class GhosttySurfaceScrollView: NSView { let hadOverlay = searchOverlayHostingView != nil #if DEBUG - cmuxDebugLog("find.setSearchOverlay MOUNT surface=\(terminalSurface.id.uuidString.prefix(5)) existingOverlay=\(hadOverlay ? "yes(update)" : "no(create)")") + dlog("find.setSearchOverlay MOUNT surface=\(terminalSurface.id.uuidString.prefix(5)) existingOverlay=\(hadOverlay ? "yes(update)" : "no(create)")") #endif let rootView = makeSearchOverlayRootView( @@ -10690,10 +11118,9 @@ final class GhosttySurfaceScrollView: NSView { return } - dropZoneOverlayAnimationGeneration &+= 1 - dropZoneOverlayView.layer?.removeAllAnimations() - if dropZoneOverlayView.isHidden { + dropZoneOverlayAnimationGeneration &+= 1 + dropZoneOverlayView.layer?.removeAllAnimations() applyDropZoneOverlayFrame(targetFrame) dropZoneOverlayView.alphaValue = 0 dropZoneOverlayView.isHidden = false @@ -10723,16 +11150,23 @@ final class GhosttySurfaceScrollView: NSView { logDropZoneOverlay(event: "update", zone: zone, frame: targetFrame) } #endif - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.18 - context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) - if needsFrameUpdate { - dropZoneOverlayView.animator().frame = targetFrame - } - if dropZoneOverlayView.alphaValue < 1 { - dropZoneOverlayView.animator().alphaValue = 1 + if needsFrameUpdate { + if zoneChanged { + // Animate discrete drop-zone changes (left/right/top/bottom) so + // the blue bounds transition smoothly between targets. + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.12 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + dropZoneOverlayView.animator().frame = targetFrame + } + } else { + // Keep same-zone geometry churn immediate to avoid drag lag. + applyDropZoneOverlayFrame(targetFrame) } } + if dropZoneOverlayView.alphaValue < 1 { + dropZoneOverlayView.alphaValue = 1 + } } else { guard !dropZoneOverlayView.isHidden else { return } dropZoneOverlayAnimationGeneration &+= 1 @@ -10789,7 +11223,7 @@ final class GhosttySurfaceScrollView: NSView { "\(scrollOriginText)|\(surfaceOriginText)|\(dropZoneOverlayView.isHidden ? 1 : 0)" guard lastDropZoneOverlayLogSignature != signature else { return } lastDropZoneOverlayLogSignature = signature - cmuxDebugLog( + dlog( "terminal.dropOverlay event=\(event) surface=\(surface) zone=\(zoneText) " + "hidden=\(dropZoneOverlayView.isHidden ? 1 : 0) bounds=\(boundsText) frame=\(frameText) " + "overlaySuper=\(overlaySuperviewClass) overlayExternal=\(dropZoneOverlayView.superview === self ? 0 : 1) " + @@ -10831,6 +11265,9 @@ final class GhosttySurfaceScrollView: NSView { let wasVisible = surfaceView.isVisibleInUI surfaceView.setVisibleInUI(visible) isHidden = !visible + if visible, !wasVisible, window != nil { + reconcileViewportLifecycle(reason: "hostedView.setVisibleInUI", force: true) + } if wasVisible != visible, lastRequestedPortalOcclusionVisible != visible { lastRequestedPortalOcclusionVisible = visible surfaceView.terminalSurface?.setOcclusion(visible) @@ -10861,15 +11298,9 @@ final class GhosttySurfaceScrollView: NSView { fr === surfaceView || fr.isDescendant(of: surfaceView) { window.makeFirstResponder(nil) } - } else { - if !wasVisible { - // Workspace/sidebar selection can make an already-sized terminal visible again - // without a portal frame delta or a focus handoff. Reuse the portal refresh - // path so the Metal layer is nudged immediately on plain visibility restores. - refreshSurfaceNow(reason: "setVisibleInUI") - } - scheduleAutomaticFirstResponderApply(reason: "setVisibleInUI") + cancelFocusRequest() } + notifyPresentationFactsChanged(force: true) } var debugPortalVisibleInUI: Bool { @@ -10899,20 +11330,23 @@ final class GhosttySurfaceScrollView: NSView { } #endif if active { - scheduleAutomaticFirstResponderApply(reason: "setActive") + notifyPresentationFactsChanged(force: true) + return } else { + cancelFocusRequest() resignOwnedFirstResponderIfNeeded(reason: "setActive(false)") } + notifyPresentationFactsChanged(force: true) } #if DEBUG private func debugLogWorkspaceSwitchTiming(event: String, suffix: String) { guard let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() else { - cmuxDebugLog("\(event) id=none \(suffix)") + dlog("\(event) id=none \(suffix)") return } let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 - cmuxDebugLog("\(event) id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) \(suffix)") + dlog("\(event) id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) \(suffix)") } private func debugFirstResponderLabel() -> String { @@ -10947,11 +11381,15 @@ final class GhosttySurfaceScrollView: NSView { } #endif - func moveFocus(from previous: GhosttySurfaceScrollView? = nil, delay: TimeInterval? = nil) { + func moveFocus( + from previous: GhosttySurfaceScrollView? = nil, + delay: TimeInterval? = nil, + respectForeignFirstResponder: Bool = true + ) { #if DEBUG let surfaceShort = String(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") let searchActive = self.surfaceView.terminalSurface?.searchState != nil - cmuxDebugLog( + dlog( "find.moveFocus to=\(surfaceShort) " + "from=\(previous?.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "searchState=\(searchActive ? "active" : "nil") " + @@ -10965,12 +11403,22 @@ final class GhosttySurfaceScrollView: NSView { let before = String(describing: window.firstResponder) #endif guard self.canRequestSurfaceFirstResponder(in: window, reason: "moveFocus") else { return } + if respectForeignFirstResponder, + let blockReason = self.foreignTerminalFocusBlockReason(in: window) { +#if DEBUG + dlog( + "find.moveFocus.skip to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "reason=\(blockReason) firstResponder=\(String(describing: window.firstResponder))" + ) +#endif + return + } if let previous, previous !== self { _ = previous.surfaceView.resignFirstResponder() } let result = window.makeFirstResponder(self.surfaceView) #if DEBUG - cmuxDebugLog( + dlog( "find.moveFocus.apply to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "result=\(result ? 1 : 0) before=\(before) after=\(String(describing: window.firstResponder))" ) @@ -11050,7 +11498,7 @@ final class GhosttySurfaceScrollView: NSView { /// Handle file/URL drops, forwarding to the terminal as shell-escaped paths. func handleDroppedURLs(_ urls: [URL]) -> Bool { #if DEBUG - cmuxDebugLog("terminal.swiftUIDrop surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") urls=\(urls.map(\.lastPathComponent))") + dlog("terminal.swiftUIDrop surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") urls=\(urls.map(\.lastPathComponent))") #endif return surfaceView.handleDroppedFileURLs(urls) } @@ -11135,7 +11583,7 @@ final class GhosttySurfaceScrollView: NSView { guard let window else { return } guard surfaceView.isVisibleInUI else { #if DEBUG - cmuxDebugLog( + dlog( "focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "reason=not_visible" ) @@ -11145,7 +11593,7 @@ final class GhosttySurfaceScrollView: NSView { } guard !isHiddenForFocus, hasUsablePortalGeometry else { #if DEBUG - cmuxDebugLog( + dlog( "focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "reason=hidden_or_tiny hidden=\(isHiddenForFocus ? 1 : 0) " + "frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height))" @@ -11164,15 +11612,15 @@ final class GhosttySurfaceScrollView: NSView { guard let tab = tabManager.tabs.first(where: { $0.id == tabId }), let tabIdForSurface = tab.surfaceIdFromPanelId(surfaceId), - let paneId = tab.bonsplitController.allPaneIds.first(where: { paneId in - tab.bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabIdForSurface }) + let paneId = tab.paneIds.first(where: { paneId in + tab.containsSurface(tabIdForSurface.id, inPane: paneId) }) else { scheduleAutomaticFirstResponderApply(reason: "ensureFocus.missingPane") return } - guard tab.bonsplitController.selectedTab(inPane: paneId)?.id == tabIdForSurface, - tab.bonsplitController.focusedPaneId == paneId else { + guard tab.selectedSurfaceId(inPane: paneId) == tabIdForSurface.id, + tab.focusedPaneId == paneId else { scheduleAutomaticFirstResponderApply(reason: "ensureFocus.unfocusedPane") return } @@ -11187,7 +11635,7 @@ final class GhosttySurfaceScrollView: NSView { // Search focus restoration — only after confirming this is the active tab/pane. if surfaceView.terminalSurface?.searchState != nil { #if DEBUG - cmuxDebugLog( + dlog( "focus.ensure.search surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " + "firstResponder=\(String(describing: window.firstResponder))" @@ -11205,8 +11653,6 @@ final class GhosttySurfaceScrollView: NSView { // Layout and visibility reconciliation can ask the active terminal to // reassert focus after a sidebar/text owner has already accepted focus. - // Respect those explicit AppKit first responders unless the terminal - // surface already owns focus. if respectForeignFirstResponder, let firstResponder = window.firstResponder, firstResponder is NSText || AppDelegate.shared?.isRightSidebarFocusResponder(firstResponder, in: window) == true { @@ -11229,9 +11675,9 @@ final class GhosttySurfaceScrollView: NSView { } window.makeKeyAndOrderFront(nil) } - let result = window.makeFirstResponder(surfaceView) + let result = requestSurfaceFirstResponder(in: window, reason: "ensureFocus") #if DEBUG - cmuxDebugLog( + dlog( "focus.ensure.apply surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " + "result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))" @@ -11261,22 +11707,28 @@ final class GhosttySurfaceScrollView: NSView { tabManager.selectedTabId == tabId, let tab = tabManager.tabs.first(where: { $0.id == tabId }), let tabIdForSurface = tab.surfaceIdFromPanelId(surfaceId), - let paneId = tab.bonsplitController.allPaneIds.first(where: { paneId in - tab.bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabIdForSurface }) + let paneId = tab.paneIds.first(where: { paneId in + tab.containsSurface(tabIdForSurface.id, inPane: paneId) }) else { return false } - return tab.bonsplitController.selectedTab(inPane: paneId)?.id == tabIdForSurface && - tab.bonsplitController.focusedPaneId == paneId + return tab.selectedSurfaceId(inPane: paneId) == tabIdForSurface.id && + tab.focusedPaneId == paneId } /// Suppress the surface view's onFocus callback and ghostty_surface_set_focus during - /// SwiftUI reparenting (programmatic splits). Call clearSuppressReparentFocus() after layout settles. + /// SwiftUI reparenting (programmatic splits). The retained host clears this once the + /// view is mounted into its next slot with usable geometry. func suppressReparentFocus() { surfaceView.suppressingReparentFocus = true } + func resumeReparentFocusIfSuppressed() { + guard surfaceView.suppressingReparentFocus else { return } + clearSuppressReparentFocus() + } + func clearSuppressReparentFocus() { surfaceView.suppressingReparentFocus = false let hasUsablePortalGeometry: Bool = { @@ -11289,11 +11741,18 @@ final class GhosttySurfaceScrollView: NSView { guard surfaceView.desiredFocus || surfaceOwnsFirstResponder else { return } guard surfaceView.isVisibleInUI else { return } - surfaceView.terminalSurface?.recordExternalFocusState(true) guard let window, window.isKeyWindow else { return } + guard canRequestSurfaceFirstResponder(in: window, reason: "clearSuppressReparentFocus") else { return } + if let blockReason = foreignTerminalFocusBlockReason(in: window) { +#if DEBUG + dlog("focus.reparent.resume.skip surface=\(surfaceShort) reason=\(blockReason)") +#endif + return + } + surfaceView.terminalSurface?.recordExternalFocusState(true) guard !isHiddenForFocus, hasUsablePortalGeometry else { #if DEBUG - cmuxDebugLog( + dlog( "focus.reparent.resume.defer surface=\(surfaceShort) " + "reason=hidden_or_tiny hidden=\(isHiddenForFocus ? 1 : 0) " + "frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height))" @@ -11304,16 +11763,20 @@ final class GhosttySurfaceScrollView: NSView { } if !surfaceOwnsFirstResponder && !isSurfaceViewFirstResponder() { #if DEBUG - cmuxDebugLog( + dlog( "focus.reparent.resume.restoreFirstResponder surface=\(surfaceShort) " + "firstResponder=\(String(describing: window.firstResponder))" ) #endif - guard requestSurfaceFirstResponder(in: window, reason: "clearSuppressReparentFocus"), + guard requestSurfaceFirstResponder( + in: window, + reason: "clearSuppressReparentFocus", + respectForeignFirstResponder: true + ), isSurfaceViewFirstResponder() else { return } } #if DEBUG - cmuxDebugLog("focus.reparent.resume surface=\(surfaceShort) firstResponder=\(String(describing: window.firstResponder))") + dlog("focus.reparent.resume surface=\(surfaceShort) firstResponder=\(String(describing: window.firstResponder))") #endif reassertTerminalSurfaceFocus(reason: "clearSuppressReparentFocus") } @@ -11326,55 +11789,72 @@ final class GhosttySurfaceScrollView: NSView { return fr === surfaceView || fr.isDescendant(of: surfaceView) } - private func canRequestSurfaceFirstResponder(in window: NSWindow, reason: String) -> Bool { - guard let terminalSurface = surfaceView.terminalSurface else { - return true - } - let allowed = AppDelegate.shared?.allowsTerminalKeyboardFocus( - workspaceId: terminalSurface.tabId, - panelId: terminalSurface.id, - in: window - ) ?? true -#if DEBUG - if !allowed { - dlog( - "focus.apply.skip surface=\(terminalSurface.id.uuidString.prefix(5)) " + - "reason=\(reason).coordinatorRightSidebar" - ) - } -#endif - return allowed + func requestAutomaticFirstResponderApply(reason: String) { + scheduleAutomaticFirstResponderApply(reason: reason) } - @discardableResult - private func requestSurfaceFirstResponder(in window: NSWindow, reason: String) -> Bool { - guard canRequestSurfaceFirstResponder(in: window, reason: reason) else { - return false - } - return window.makeFirstResponder(surfaceView) +#if DEBUG + func debugApplyFirstResponderIfNeededForTesting() { + applyFirstResponderIfNeeded() } +#endif private func scheduleAutomaticFirstResponderApply(reason: String) { - guard !pendingAutomaticFirstResponderApply else { return } - pendingAutomaticFirstResponderApply = true - DispatchQueue.main.async { [weak self] in + automaticFirstResponderTask?.cancel() + + let generation: UInt64 + if let window { + generation = GhosttyWindowFocusCoordinator.shared.claim(window: window, owner: self) + } else { + pendingAutomaticFirstResponderGeneration &+= 1 + generation = pendingAutomaticFirstResponderGeneration + } + pendingAutomaticFirstResponderGeneration = generation + + automaticFirstResponderTask = Task { @MainActor [weak self] in + await Task.yield() guard let self else { return } - self.pendingAutomaticFirstResponderApply = false + guard !Task.isCancelled else { return } + guard self.shouldApplyFirstResponder(generation: generation) else { return } #if DEBUG let surfaceShort = String(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") - cmuxDebugLog("find.applyFirstResponder.defer surface=\(surfaceShort) reason=\(reason)") + dlog("find.applyFirstResponder.defer surface=\(surfaceShort) reason=\(reason)") #endif self.applyFirstResponderIfNeeded() } } + private func shouldApplyFirstResponder(generation: UInt64) -> Bool { + guard generation == pendingAutomaticFirstResponderGeneration else { return false } + guard let window else { return false } + return GhosttyWindowFocusCoordinator.shared.owns( + window: window, + owner: self, + generation: generation + ) + } + private func reassertTerminalSurfaceFocus(reason: String) { guard let terminalSurface = surfaceView.terminalSurface else { return } + if let window, + !canRequestSurfaceFirstResponder(in: window, reason: "\(reason).reassert") { + return + } + if let window, + let blockReason = foreignTerminalFocusBlockReason(in: window) { +#if DEBUG + dlog( + "focus.surface.reassert.skip surface=\(terminalSurface.id.uuidString.prefix(5)) " + + "reason=\(reason).\(blockReason)" + ) +#endif + return + } if terminalSurface.surface == nil { terminalSurface.requestBackgroundSurfaceStartIfNeeded() } #if DEBUG - cmuxDebugLog("focus.surface.reassert surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(reason)") + dlog("focus.surface.reassert surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(reason)") #endif terminalSurface.setFocus(true) refreshSurfaceAfterFocusIfNeeded(reason: reason) @@ -11393,7 +11873,7 @@ final class GhosttySurfaceScrollView: NSView { } lastFocusRefreshAt = now #if DEBUG - cmuxDebugLog("focus.surface.refresh surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(reason)") + dlog("focus.surface.refresh surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(reason)") #endif terminalSurface.forceRefresh(reason: "focus.surface.\(reason)") } @@ -11410,7 +11890,7 @@ final class GhosttySurfaceScrollView: NSView { guard surfaceView.isVisibleInUI else { return } guard !isHiddenForFocus, hasUsablePortalGeometry else { #if DEBUG - cmuxDebugLog( + dlog( "focus.apply.skip surface=\(surfaceShort) " + "reason=hidden_or_tiny hidden=\(isHiddenForFocus ? 1 : 0) frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height))" ) @@ -11422,19 +11902,14 @@ final class GhosttySurfaceScrollView: NSView { let panelId = surfaceView.terminalSurface?.id, matchesCurrentTerminalFocusTarget(tabId: tabId, surfaceId: panelId) else { #if DEBUG - cmuxDebugLog("focus.apply.skip surface=\(surfaceShort) reason=stale_target") -#endif - return - } - if AppDelegate.shared?.allowsTerminalKeyboardFocus(workspaceId: tabId, panelId: panelId, in: window) == false { -#if DEBUG - dlog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=coordinatorRightSidebar") + dlog("focus.apply.skip surface=\(surfaceShort) reason=stale_target") #endif return } + guard canRequestSurfaceFirstResponder(in: window, reason: "applyFirstResponder") else { return } if AppDelegate.shared?.isCommandPaletteEffectivelyVisible(for: window) == true { #if DEBUG - cmuxDebugLog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=commandPaletteVisible") + dlog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=commandPaletteVisible") #endif return } @@ -11451,26 +11926,25 @@ final class GhosttySurfaceScrollView: NSView { // Don't steal focus from a search overlay on another surface in this window. if let fr = window.firstResponder, isSearchOverlayOrDescendant(fr) { #if DEBUG - cmuxDebugLog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=searchOverlayFocused") + dlog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=searchOverlayFocused") #endif return } - // Don't steal focus from active non-terminal input owners. The terminal surface uses its - // own GhosttyNSView for input, so NSText and the feed focus host are always foreign focus - // owners that should survive deferred terminal visibility applies. - if let firstResponder = window.firstResponder, - firstResponder is NSText || AppDelegate.shared?.isRightSidebarFocusResponder(firstResponder, in: window) == true { + if let blockReason = foreignTerminalFocusBlockReason(in: window) { #if DEBUG - let reason = firstResponder is NSText ? "textEditorFocused" : "rightSidebarFocused" - cmuxDebugLog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=\(reason)") + dlog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=\(blockReason)") #endif return } #if DEBUG - cmuxDebugLog("find.applyFirstResponder APPLY surface=\(surfaceShort) prevFirstResponder=\(String(describing: window.firstResponder))") + dlog("find.applyFirstResponder APPLY surface=\(surfaceShort) prevFirstResponder=\(String(describing: window.firstResponder))") #endif - window.makeFirstResponder(surfaceView) - if isSurfaceViewFirstResponder() { + let result = requestSurfaceFirstResponder( + in: window, + reason: "applyFirstResponder", + respectForeignFirstResponder: true + ) + if result, isSurfaceViewFirstResponder() { reassertTerminalSurfaceFocus(reason: "applyFirstResponder.afterMakeFirstResponder") } } @@ -11485,7 +11959,7 @@ final class GhosttySurfaceScrollView: NSView { isCurrentSurfaceSearchFieldResponder(firstResponder) { surfaceView.terminalSurface?.setFocus(false) #if DEBUG - cmuxDebugLog( + dlog( "find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " + "reason=alreadyFocused firstResponder=\(String(describing: firstResponder))" ) @@ -11497,7 +11971,7 @@ final class GhosttySurfaceScrollView: NSView { !isCurrentSurfaceSearchResponder(firstResponder) { surfaceView.terminalSurface?.setFocus(false) #if DEBUG - cmuxDebugLog( + dlog( "find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " + "reason=foreignSearchResponder firstResponder=\(String(describing: firstResponder))" ) @@ -11516,7 +11990,7 @@ final class GhosttySurfaceScrollView: NSView { NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface) } #if DEBUG - cmuxDebugLog( + dlog( "find.restoreSearchFocus surface=\(surfaceShort) target=searchField " + "via=notification firstResponder=\(String(describing: window.firstResponder))" ) @@ -11524,7 +11998,7 @@ final class GhosttySurfaceScrollView: NSView { case .terminal: let result = requestSurfaceFirstResponder(in: window, reason: "restoreSearchFocus.terminal") #if DEBUG - cmuxDebugLog( + dlog( "find.restoreSearchFocus surface=\(surfaceShort) target=terminal " + "result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))" ) @@ -11551,7 +12025,7 @@ final class GhosttySurfaceScrollView: NSView { #if DEBUG if alreadyFocused { - cmuxDebugLog( + dlog( "find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " + "reason=mountedFieldAlreadyFocused firstResponder=\(String(describing: firstResponder))" ) @@ -11563,7 +12037,7 @@ final class GhosttySurfaceScrollView: NSView { let ownsField = mountedSearchFieldOwnsResponder(window.firstResponder, field: field) #if DEBUG - cmuxDebugLog( + dlog( "find.restoreSearchFocus surface=\(surfaceShort) target=searchField " + "via=mountedField result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))" ) @@ -11617,7 +12091,7 @@ final class GhosttySurfaceScrollView: NSView { searchFocusTarget = .searchField } #if DEBUG - cmuxDebugLog( + dlog( "find.preparePanelFocusIntent surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "target=\(intent == .findField ? "searchField" : "terminal")" ) @@ -11646,7 +12120,7 @@ final class GhosttySurfaceScrollView: NSView { NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface) } #if DEBUG - cmuxDebugLog( + dlog( "find.restorePanelFocusIntent surface=\(terminalSurface.id.uuidString.prefix(5)) " + "target=searchField firstResponder=\(String(describing: window?.firstResponder))" ) @@ -11656,7 +12130,9 @@ final class GhosttySurfaceScrollView: NSView { } func ownedPanelFocusIntent(for responder: NSResponder) -> TerminalPanelFocusIntent? { - if isCurrentSurfaceSearchResponder(responder) { + if surfaceView.terminalSurface?.searchState != nil, + isCurrentSurfaceSearchResponder(responder), + isSearchOverlayOrDescendant(responder) { return .findField } @@ -11677,7 +12153,7 @@ final class GhosttySurfaceScrollView: NSView { surfaceView.terminalSurface?.setFocus(false) resignOwnedFirstResponderIfNeeded(reason: "yieldPanelFocusIntent") #if DEBUG - cmuxDebugLog( + dlog( "focus.handoff.yield surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "target=\(intent == .findField ? "searchField" : "terminal")" ) @@ -11697,7 +12173,7 @@ final class GhosttySurfaceScrollView: NSView { guard ownsSurfaceResponder || isCurrentSurfaceSearchResponder(firstResponder) else { return } #if DEBUG - cmuxDebugLog( + dlog( "focus.surface.resign surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "reason=\(reason) firstResponder=\(String(describing: firstResponder))" ) @@ -12005,7 +12481,10 @@ final class GhosttySurfaceScrollView: NSView { #endif func cancelFocusRequest() { - // Intentionally no-op (no retry loops). + pendingAutomaticFirstResponderGeneration &+= 1 + automaticFirstResponderTask?.cancel() + automaticFirstResponderTask = nil + GhosttyWindowFocusCoordinator.shared.release(window: window, owner: self) } private func synchronizeSurfaceView() { @@ -12706,7 +13185,7 @@ extension GhosttyNSView: NSTextInputClient { #if DEBUG if NSApp.currentEvent == nil { - cmuxDebugLog("ime.insertText.noEvent len=\(chars.count)") + dlog("ime.insertText.noEvent len=\(chars.count)") } #endif @@ -12728,7 +13207,7 @@ extension GhosttyNSView: NSTextInputClient { #if DEBUG if sanitizedChars != chars { - cmuxDebugLog( + dlog( "ime.insertText.sanitized originalBytes=\(chars.utf8.count) " + "sanitizedBytes=\(sanitizedChars.utf8.count)" ) @@ -12744,465 +13223,3 @@ extension GhosttyNSView: NSTextInputClient { ) } } - -// MARK: - SwiftUI Wrapper - -struct GhosttyTerminalView: NSViewRepresentable { - @Environment(\.paneDropZone) var paneDropZone - - let terminalSurface: TerminalSurface - let paneId: PaneID - var isActive: Bool = true - var isVisibleInUI: Bool = true - var portalZPriority: Int = 0 - var showsInactiveOverlay: Bool = false - var showsUnreadNotificationRing: Bool = false - var inactiveOverlayColor: NSColor = .clear - var inactiveOverlayOpacity: Double = 0 - var searchState: TerminalSurface.SearchState? = nil - var reattachToken: UInt64 = 0 - var onFocus: ((UUID) -> Void)? = nil - var onTriggerFlash: (() -> Void)? = nil - - private final class HostContainerView: NSView { - private static var nextInstanceSerial: UInt64 = 0 - - var onDidMoveToWindow: (() -> Void)? - var onGeometryChanged: (() -> Void)? - let instanceSerial: UInt64 - private(set) var geometryRevision: UInt64 = 0 - private var lastReportedGeometryState: GeometryState? - - override init(frame frameRect: NSRect) { - Self.nextInstanceSerial &+= 1 - instanceSerial = Self.nextInstanceSerial - super.init(frame: frameRect) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) not implemented") - } - - private struct GeometryState: Equatable { - let frame: CGRect - let bounds: CGRect - let windowNumber: Int? - let superviewID: ObjectIdentifier? - } - - private func currentGeometryState() -> GeometryState { - GeometryState( - frame: frame, - bounds: bounds, - windowNumber: window?.windowNumber, - superviewID: superview.map(ObjectIdentifier.init) - ) - } - - private func notifyGeometryChangedIfNeeded() { - let state = currentGeometryState() - guard state != lastReportedGeometryState else { return } - lastReportedGeometryState = state - geometryRevision &+= 1 - onGeometryChanged?() - } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - onDidMoveToWindow?() - notifyGeometryChangedIfNeeded() - } - - override func viewDidMoveToSuperview() { - super.viewDidMoveToSuperview() - notifyGeometryChangedIfNeeded() - } - - override func layout() { - super.layout() - notifyGeometryChangedIfNeeded() - } - - override func setFrameOrigin(_ newOrigin: NSPoint) { - super.setFrameOrigin(newOrigin) - notifyGeometryChangedIfNeeded() - } - - override func setFrameSize(_ newSize: NSSize) { - super.setFrameSize(newSize) - notifyGeometryChangedIfNeeded() - } - } - - final class Coordinator { - var attachGeneration: Int = 0 - // Track the latest desired state so attach retries can re-apply focus after re-parenting. - var desiredIsActive: Bool = true - var desiredIsVisibleInUI: Bool = true - var desiredShowsUnreadNotificationRing: Bool = false - var desiredPortalZPriority: Int = 0 - var lastBoundHostId: ObjectIdentifier? - var lastPaneDropZone: DropZone? - var lastSynchronizedHostGeometryRevision: UInt64 = 0 - weak var hostedView: GhosttySurfaceScrollView? - } - - func makeCoordinator() -> Coordinator { - Coordinator() - } - - static func shouldApplyImmediateHostedStateUpdate( - hostedViewHasSuperview: Bool, - isBoundToCurrentHost: Bool - ) -> Bool { - // If this update originates from a stale/replaced host while the hosted view is - // already attached elsewhere, do not mutate visibility/active state here. - if isBoundToCurrentHost { return true } - return !hostedViewHasSuperview - } - - static func shouldSynchronizePortalGeometryImmediately( - hostInLiveResize: Bool, - windowInLiveResize: Bool, - interactiveGeometryResizeActive: Bool - ) -> Bool { - hostInLiveResize || windowInLiveResize || interactiveGeometryResizeActive - } - - private static func synchronizePortalGeometry( - for host: HostContainerView, - coordinator: Coordinator - ) { - let geometryRevision = host.geometryRevision - guard coordinator.lastSynchronizedHostGeometryRevision != geometryRevision else { return } - coordinator.lastSynchronizedHostGeometryRevision = geometryRevision - let window = host.window - if shouldSynchronizePortalGeometryImmediately( - hostInLiveResize: host.inLiveResize, - windowInLiveResize: window?.inLiveResize == true, - interactiveGeometryResizeActive: TerminalWindowPortalRegistry.isInteractiveGeometryResizeActive - ) { - TerminalWindowPortalRegistry.synchronizeForAnchor(host) - return - } - // Avoid synchronizing the terminal portal while AppKit is still inside - // the current layout turn. Re-entrant syncs here can wedge window resize - // handling and leave the app spinning on the wait cursor. - guard let window else { return } - TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window) - } - - func makeNSView(context: Context) -> NSView { - let container = HostContainerView(frame: .zero) - container.wantsLayer = false - // The actual terminal surface lives in the AppKit portal layer above SwiftUI. - // This empty placeholder should not be walked by the accessibility subsystem. - container.setAccessibilityRole(.none) - container.setAccessibilityElement(false) - return container - } - - func updateNSView(_ nsView: NSView, context: Context) { - let hostedView = terminalSurface.hostedView - let coordinator = context.coordinator - let previousDesiredIsActive = coordinator.desiredIsActive - let previousDesiredIsVisibleInUI = coordinator.desiredIsVisibleInUI - let previousDesiredPortalZPriority = coordinator.desiredPortalZPriority - let desiredStateChanged = - previousDesiredIsActive != isActive || - previousDesiredIsVisibleInUI != isVisibleInUI || - previousDesiredPortalZPriority != portalZPriority - coordinator.desiredIsActive = isActive - coordinator.desiredIsVisibleInUI = isVisibleInUI - coordinator.desiredShowsUnreadNotificationRing = showsUnreadNotificationRing - coordinator.desiredPortalZPriority = portalZPriority - coordinator.hostedView = hostedView -#if DEBUG - if desiredStateChanged { - if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() { - let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 - cmuxDebugLog( - "ws.swiftui.update id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) " + - "surface=\(terminalSurface.id.uuidString.prefix(5)) visible=\(isVisibleInUI ? 1 : 0) " + - "active=\(isActive ? 1 : 0) z=\(portalZPriority) " + - "hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " + - "hostedSuperview=\(hostedView.superview != nil ? 1 : 0)" - ) - } else { - cmuxDebugLog( - "ws.swiftui.update id=none surface=\(terminalSurface.id.uuidString.prefix(5)) " + - "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0) z=\(portalZPriority) " + - "hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " + - "hostedSuperview=\(hostedView.superview != nil ? 1 : 0)" - ) - } - } -#endif - - let hostContainer = nsView as? HostContainerView - let hostOwnsPortalNow = hostContainer.map { host in - terminalSurface.claimPortalHost( - hostId: ObjectIdentifier(host), - paneId: paneId, - instanceSerial: host.instanceSerial, - inWindow: host.window != nil, - bounds: host.bounds, - reason: "update" - ) - } ?? true - - // Keep the surface lifecycle and handlers updated even if we defer re-parenting. - hostedView.attachSurface(terminalSurface) - hostedView.setFocusHandler { onFocus?(terminalSurface.id) } - hostedView.setTriggerFlashHandler(onTriggerFlash) - if hostOwnsPortalNow { - hostedView.setInactiveOverlay( - color: inactiveOverlayColor, - opacity: CGFloat(inactiveOverlayOpacity), - visible: showsInactiveOverlay - ) - hostedView.setNotificationRing(visible: showsUnreadNotificationRing) - hostedView.setSearchOverlay(searchState: searchState) - hostedView.syncKeyStateIndicator(text: terminalSurface.currentKeyStateIndicatorText) - } - let portalExpectedSurfaceId = terminalSurface.id - let portalExpectedGeneration = terminalSurface.portalBindingGeneration() - func portalBindingStillLive() -> Bool { - terminalSurface.canAcceptPortalBinding( - expectedSurfaceId: portalExpectedSurfaceId, - expectedGeneration: portalExpectedGeneration - ) - } - let forwardedDropZone = isVisibleInUI ? paneDropZone : nil -#if DEBUG - if coordinator.lastPaneDropZone != paneDropZone { - let oldZone = coordinator.lastPaneDropZone.map { String(describing: $0) } ?? "none" - let newZone = paneDropZone.map { String(describing: $0) } ?? "none" - cmuxDebugLog( - "terminal.paneDropZone surface=\(terminalSurface.id.uuidString.prefix(5)) " + - "old=\(oldZone) new=\(newZone) " + - "active=\(isActive ? 1 : 0) visible=\(isVisibleInUI ? 1 : 0) " + - "inWindow=\(hostedView.window != nil ? 1 : 0)" - ) - coordinator.lastPaneDropZone = paneDropZone - } - if paneDropZone != nil, !isVisibleInUI { - cmuxDebugLog( - "terminal.paneDropZone.suppress surface=\(terminalSurface.id.uuidString.prefix(5)) " + - "requested=\(String(describing: paneDropZone!)) visible=0 active=\(isActive ? 1 : 0)" - ) - } -#endif - if hostOwnsPortalNow { - hostedView.setDropZoneOverlay(zone: forwardedDropZone) - } - - coordinator.attachGeneration += 1 - let generation = coordinator.attachGeneration - - if let host = hostContainer { - host.onDidMoveToWindow = { [weak host, weak hostedView, weak coordinator] in - guard let host, let hostedView, let coordinator else { return } - guard coordinator.attachGeneration == generation else { return } - guard terminalSurface.claimPortalHost( - hostId: ObjectIdentifier(host), - paneId: paneId, - instanceSerial: host.instanceSerial, - inWindow: host.window != nil, - bounds: host.bounds, - reason: "didMoveToWindow" - ) else { return } - guard host.window != nil else { return } - guard portalBindingStillLive() else { return } - TerminalWindowPortalRegistry.bind( - hostedView: hostedView, - to: host, - visibleInUI: coordinator.desiredIsVisibleInUI, - zPriority: coordinator.desiredPortalZPriority, - expectedSurfaceId: portalExpectedSurfaceId, - expectedGeneration: portalExpectedGeneration - ) - coordinator.lastBoundHostId = ObjectIdentifier(host) - coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision - hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI) - hostedView.setActive(coordinator.desiredIsActive) - hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) - } - host.onGeometryChanged = { [weak host, weak hostedView, weak coordinator] in - guard let host, let hostedView, let coordinator else { return } - guard coordinator.attachGeneration == generation else { return } - guard terminalSurface.claimPortalHost( - hostId: ObjectIdentifier(host), - paneId: paneId, - instanceSerial: host.instanceSerial, - inWindow: host.window != nil, - bounds: host.bounds, - reason: "geometryChanged" - ) else { return } - guard portalBindingStillLive() else { return } - let hostId = ObjectIdentifier(host) - if host.window != nil, - (coordinator.lastBoundHostId != hostId || - !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host)) { -#if DEBUG - cmuxDebugLog( - "ws.hostState.rebindOnGeometry surface=\(terminalSurface.id.uuidString.prefix(5)) " + - "reason=portalEntryMissing visible=\(coordinator.desiredIsVisibleInUI ? 1 : 0) " + - "active=\(coordinator.desiredIsActive ? 1 : 0) z=\(coordinator.desiredPortalZPriority)" - ) -#endif - TerminalWindowPortalRegistry.bind( - hostedView: hostedView, - to: host, - visibleInUI: coordinator.desiredIsVisibleInUI, - zPriority: coordinator.desiredPortalZPriority, - expectedSurfaceId: portalExpectedSurfaceId, - expectedGeneration: portalExpectedGeneration - ) - coordinator.lastBoundHostId = hostId - hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI) - hostedView.setActive(coordinator.desiredIsActive) - hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) - } - Self.synchronizePortalGeometry( - for: host, - coordinator: coordinator - ) - } - - if host.window != nil, hostOwnsPortalNow { - let portalBindingLive = portalBindingStillLive() - let hostId = ObjectIdentifier(host) - let geometryRevision = host.geometryRevision - let portalEntryMissing = !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) - // Notification rings are hosted inside GhosttySurfaceScrollView and update in place. - // A ring-only state change must not resynchronize the window portal while SwiftUI is - // invalidating notification UI, or the terminal can be hidden until the next tab switch. - let shouldBindNow = - coordinator.lastBoundHostId != hostId || - hostedView.superview == nil || - portalEntryMissing || - previousDesiredIsVisibleInUI != isVisibleInUI || - previousDesiredPortalZPriority != portalZPriority - if portalBindingLive && shouldBindNow { -#if DEBUG - if portalEntryMissing { - cmuxDebugLog( - "ws.hostState.rebindOnUpdate surface=\(terminalSurface.id.uuidString.prefix(5)) " + - "reason=portalEntryMissing visible=\(coordinator.desiredIsVisibleInUI ? 1 : 0) " + - "active=\(coordinator.desiredIsActive ? 1 : 0) z=\(coordinator.desiredPortalZPriority)" - ) - } -#endif - TerminalWindowPortalRegistry.bind( - hostedView: hostedView, - to: host, - visibleInUI: coordinator.desiredIsVisibleInUI, - zPriority: coordinator.desiredPortalZPriority, - expectedSurfaceId: portalExpectedSurfaceId, - expectedGeneration: portalExpectedGeneration - ) - coordinator.lastBoundHostId = hostId - coordinator.lastSynchronizedHostGeometryRevision = geometryRevision - } else if portalBindingLive && coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { - Self.synchronizePortalGeometry( - for: host, - coordinator: coordinator - ) - } - } else if hostOwnsPortalNow, portalBindingStillLive() { - // Bind is deferred until host moves into a window. Update the - // existing portal entry's visibleInUI now so that any portal sync - // that runs before the deferred bind completes won't hide the view. -#if DEBUG - if desiredStateChanged { - cmuxDebugLog( - "ws.hostState.deferBind surface=\(terminalSurface.id.uuidString.prefix(5)) " + - "reason=hostNoWindow visible=\(coordinator.desiredIsVisibleInUI ? 1 : 0) " + - "active=\(coordinator.desiredIsActive ? 1 : 0) z=\(coordinator.desiredPortalZPriority) " + - "hostedWindow=\(hostedView.window != nil ? 1 : 0) hostedSuperview=\(hostedView.superview != nil ? 1 : 0)" - ) - } -#endif - TerminalWindowPortalRegistry.updateEntryVisibility( - for: hostedView, - visibleInUI: coordinator.desiredIsVisibleInUI - ) - } - } - - let hostWindowAttached = hostContainer?.window != nil - let isBoundToCurrentHost = hostContainer.map { host in - TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) - } ?? true - let shouldApplyImmediateHostedState = hostOwnsPortalNow && Self.shouldApplyImmediateHostedStateUpdate( - hostedViewHasSuperview: hostedView.superview != nil, - isBoundToCurrentHost: isBoundToCurrentHost - ) - - if portalBindingStillLive() && shouldApplyImmediateHostedState { - hostedView.setVisibleInUI(isVisibleInUI) - hostedView.setActive(isActive) - } else { - // Preserve portal entry visibility while a stale host is still receiving SwiftUI updates. - // The currently bound host remains authoritative for immediate visible/active state. -#if DEBUG - if desiredStateChanged { - cmuxDebugLog( - "ws.hostState.deferApply surface=\(terminalSurface.id.uuidString.prefix(5)) " + - "reason=\(hostOwnsPortalNow ? "staleHostBinding" : "hostOwnershipRejected") " + - "hostWindow=\(hostWindowAttached ? 1 : 0) " + - "boundToCurrent=\(isBoundToCurrentHost ? 1 : 0) hostedSuperview=\(hostedView.superview != nil ? 1 : 0) " + - "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)" - ) - } -#endif - } - } - - static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { - coordinator.attachGeneration += 1 - coordinator.desiredIsActive = false - coordinator.desiredIsVisibleInUI = false - coordinator.desiredShowsUnreadNotificationRing = false - coordinator.desiredPortalZPriority = 0 - coordinator.lastBoundHostId = nil - let hostedView = coordinator.hostedView -#if DEBUG - if let hostedView { - if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() { - let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 - cmuxDebugLog( - "ws.swiftui.dismantle id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) " + - "surface=\(hostedView.debugSurfaceId?.uuidString.prefix(5) ?? "nil") " + - "inWindow=\(hostedView.window != nil ? 1 : 0)" - ) - } else { - cmuxDebugLog( - "ws.swiftui.dismantle id=none surface=\(hostedView.debugSurfaceId?.uuidString.prefix(5) ?? "nil") " + - "inWindow=\(hostedView.window != nil ? 1 : 0)" - ) - } - } -#endif - - if let host = nsView as? HostContainerView { - host.onDidMoveToWindow = nil - host.onGeometryChanged = nil - hostedView?.prepareOwnedPortalHostForTransientReattach( - hostId: ObjectIdentifier(host), - reason: "dismantle" - ) - } - - // SwiftUI can transiently dismantle/rebuild NSViewRepresentable instances during split - // tree updates. Do not drop the portal lease or force visible/active false here; that - // causes avoidable blackouts when the same hosted view is rebound moments later. - hostedView?.setFocusHandler(nil) - hostedView?.setTriggerFlashHandler(nil) - hostedView?.setDropZoneOverlay(zone: nil) - coordinator.hostedView = nil - - nsView.subviews.forEach { $0.removeFromSuperview() } - } -} diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 994b60ce606b..2c261cc5a415 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -1,5 +1,4 @@ import AppKit -import Bonsplit import Carbon import SwiftUI @@ -13,19 +12,9 @@ enum KeyboardShortcutSettings { notifySettingsFileDidChange() } } - - enum ShortcutRecordingRejection: Equatable { - case bareKeyNotAllowed - case conflictsWithAction(Action) - case reservedBySystem - case numberedShortcutRequiresDigit - case systemWideHotkeyRequiresModifier - } - - enum RecordedShortcutResolution: Equatable { - case accepted(StoredShortcut) - case rejected(ShortcutRecordingRejection) - } +#if DEBUG + private static var testingShortcutOverrides: [Action: StoredShortcut] = [:] +#endif enum Action: String, CaseIterable, Identifiable { // App / window @@ -41,7 +30,6 @@ enum KeyboardShortcutSettings { case toggleSidebar case newTab case openFolder - case reopenPreviousSession case goToWorkspace case commandPalette case sendFeedback @@ -117,7 +105,6 @@ enum KeyboardShortcutSettings { case .toggleSidebar: return String(localized: "shortcut.toggleSidebar.label", defaultValue: "Toggle Sidebar") case .newTab: return String(localized: "shortcut.newWorkspace.label", defaultValue: "New Workspace") case .openFolder: return String(localized: "shortcut.openFolder.label", defaultValue: "Open Folder") - case .reopenPreviousSession: return String(localized: "shortcut.reopenPreviousSession.label", defaultValue: "Reopen Previous Session") case .goToWorkspace: return String(localized: "menu.file.goToWorkspace", defaultValue: "Go to Workspace…") case .commandPalette: return String(localized: "menu.file.commandPalette", defaultValue: "Command Palette…") case .sendFeedback: return String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback") @@ -175,20 +162,6 @@ enum KeyboardShortcutSettings { var defaultsKey: String { "shortcut.\(rawValue)" } - private enum ConflictScope { - case application - case rightSidebarFocus - } - - private var conflictScope: ConflictScope { - switch self { - case .switchRightSidebarToFiles, .switchRightSidebarToFind, .switchRightSidebarToSessions, .switchRightSidebarToFeed: - return .rightSidebarFocus - default: - return .application - } - } - var defaultShortcut: StoredShortcut { switch self { case .openSettings: @@ -215,8 +188,6 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "n", command: true, shift: false, option: false, control: false) case .openFolder: return StoredShortcut(key: "o", command: true, shift: false, option: false, control: false) - case .reopenPreviousSession: - return StoredShortcut(key: "o", command: true, shift: true, option: false, control: false) case .goToWorkspace: return StoredShortcut(key: "p", command: true, shift: false, option: false, control: false) case .commandPalette: @@ -248,7 +219,7 @@ enum KeyboardShortcutSettings { case .renameWorkspace: return StoredShortcut(key: "r", command: true, shift: true, option: false, control: false) case .editWorkspaceDescription: - return StoredShortcut(key: "e", command: true, shift: false, option: true, control: false) + return StoredShortcut(key: "e", command: true, shift: true, option: false, control: false) case .closeTab: return StoredShortcut(key: "w", command: true, shift: false, option: false, control: false) case .closeOtherTabsInPane: @@ -346,43 +317,14 @@ enum KeyboardShortcutSettings { return shortcut.displayString } - func conflicts( - with proposedShortcut: StoredShortcut, - proposedAction: Action, - configuredShortcut: StoredShortcut - ) -> Bool { - guard conflictScope == proposedAction.conflictScope else { - return false - } - return KeyboardShortcutSettings.shortcutsConflict( - proposedShortcut, - proposedUsesNumberedDigitMatching: proposedAction.usesNumberedDigitMatching, - configuredShortcut, - configuredUsesNumberedDigitMatching: usesNumberedDigitMatching - ) - } - - func normalizedRecordedShortcutResult(_ shortcut: StoredShortcut) -> RecordedShortcutResolution { - if let conflictingAction = KeyboardShortcutSettings.conflictingAction( - for: shortcut, - excluding: self - ) { - return .rejected(.conflictsWithAction(conflictingAction)) - } - - return resolvedRecordedShortcutIgnoringConflicts(shortcut) - } - - func resolvedRecordedShortcutIgnoringConflicts( - _ shortcut: StoredShortcut - ) -> RecordedShortcutResolution { + func normalizedRecordedShortcut(_ shortcut: StoredShortcut) -> StoredShortcut? { switch self { case .showHideAllWindows: - return KeyboardShortcutSettings.normalizedSystemWideHotkeyShortcutResult(shortcut) + return KeyboardShortcutSettings.normalizedSystemWideHotkeyShortcut(shortcut) case .selectSurfaceByNumber, .selectWorkspaceByNumber: let digitSource = shortcut.secondStroke ?? shortcut.firstStroke guard let digit = Int(digitSource.key), (1...9).contains(digit) else { - return .rejected(.numberedShortcutRequiresDigit) + return nil } var normalized = shortcut if shortcut.hasChord { @@ -390,39 +332,21 @@ enum KeyboardShortcutSettings { } else { normalized.key = "1" } - return .accepted(normalized) + return normalized default: - return .accepted(shortcut) + return shortcut } } - - func normalizedRecordedShortcut(_ shortcut: StoredShortcut) -> StoredShortcut? { - guard case let .accepted(normalized) = normalizedRecordedShortcutResult(shortcut) else { - return nil - } - return normalized - } - } - - private static func normalizedSystemWideHotkeyShortcutResult(_ shortcut: StoredShortcut) -> RecordedShortcutResolution { - guard !shortcut.hasChord else { - return .rejected(.reservedBySystem) - } - guard shortcut.hasPrimaryModifier else { - return .rejected(.systemWideHotkeyRequiresModifier) - } - guard shortcut.carbonHotKeyRegistration != nil, - !systemWideHotkeyConflicts(with: shortcut) else { - return .rejected(.reservedBySystem) - } - return .accepted(shortcut) } private static func normalizedSystemWideHotkeyShortcut(_ shortcut: StoredShortcut) -> StoredShortcut? { - guard case let .accepted(normalized) = normalizedSystemWideHotkeyShortcutResult(shortcut) else { + guard !shortcut.hasChord, + shortcut.hasPrimaryModifier, + shortcut.carbonHotKeyRegistration != nil, + !systemWideHotkeyConflicts(with: shortcut) else { return nil } - return normalized + return shortcut } private static func systemWideHotkeyConflicts(with shortcut: StoredShortcut) -> Bool { @@ -485,160 +409,12 @@ enum KeyboardShortcutSettings { StoredShortcut(key: ".", command: true, shift: false, option: false, control: false), ] - private static func conflictingAction( - for proposedShortcut: StoredShortcut, - excluding currentAction: Action - ) -> Action? { - for action in Action.allCases where action != currentAction { - let configuredShortcut = shortcut(for: action) - if action.conflicts( - with: proposedShortcut, - proposedAction: currentAction, - configuredShortcut: configuredShortcut - ) { - return action - } - } - return nil - } - - private enum ShortcutConflictMatchMode { - case exact - case numberedDigitFamily - } - - private static func shortcutsConflict( - _ proposedShortcut: StoredShortcut, - proposedUsesNumberedDigitMatching: Bool, - _ configuredShortcut: StoredShortcut, - configuredUsesNumberedDigitMatching: Bool - ) -> Bool { - switch (proposedShortcut.hasChord, configuredShortcut.hasChord) { - case (false, false): - return shortcutStrokeMatchersConflict( - proposedShortcut.firstStroke, - mode: proposedUsesNumberedDigitMatching ? .numberedDigitFamily : .exact, - configuredShortcut.firstStroke, - mode: configuredUsesNumberedDigitMatching ? .numberedDigitFamily : .exact - ) - case (true, true): - guard strokesConflict(proposedShortcut.firstStroke, configuredShortcut.firstStroke), - let proposedSecond = proposedShortcut.secondStroke, - let configuredSecond = configuredShortcut.secondStroke else { - return false - } - return shortcutStrokeMatchersConflict( - proposedSecond, - mode: proposedUsesNumberedDigitMatching ? .numberedDigitFamily : .exact, - configuredSecond, - mode: configuredUsesNumberedDigitMatching ? .numberedDigitFamily : .exact - ) - case (true, false): - return shortcutStrokeMatchersConflict( - proposedShortcut.firstStroke, - mode: .exact, - configuredShortcut.firstStroke, - mode: configuredUsesNumberedDigitMatching ? .numberedDigitFamily : .exact - ) - case (false, true): - return shortcutStrokeMatchersConflict( - proposedShortcut.firstStroke, - mode: proposedUsesNumberedDigitMatching ? .numberedDigitFamily : .exact, - configuredShortcut.firstStroke, - mode: .exact - ) - } - } - - private static func shortcutStrokeMatchersConflict( - _ lhs: ShortcutStroke, - mode lhsMode: ShortcutConflictMatchMode, - _ rhs: ShortcutStroke, - mode rhsMode: ShortcutConflictMatchMode - ) -> Bool { - switch (lhsMode, rhsMode) { - case (.exact, .exact): - return strokesConflict(lhs, rhs) - case (.numberedDigitFamily, .numberedDigitFamily): - return numberedDigitStrokeConflict(lhs, rhs) - case (.numberedDigitFamily, .exact): - return numberedDigitStrokeConflictsWithExactStroke(lhs, rhs) - case (.exact, .numberedDigitFamily): - return numberedDigitStrokeConflictsWithExactStroke(rhs, lhs) - } - } - - private static func numberedDigitStrokeConflictsWithExactStroke( - _ numberedStroke: ShortcutStroke, - _ exactStroke: ShortcutStroke - ) -> Bool { - guard isNumberedDigitStroke(numberedStroke), isNumberedDigitStroke(exactStroke) else { - return false - } - return numberedStroke.command == exactStroke.command && - numberedStroke.shift == exactStroke.shift && - numberedStroke.option == exactStroke.option && - numberedStroke.control == exactStroke.control - } - - private static func numberedDigitStrokeConflict(_ lhs: ShortcutStroke, _ rhs: ShortcutStroke) -> Bool { - guard isNumberedDigitStroke(lhs), isNumberedDigitStroke(rhs) else { return false } - return lhs.command == rhs.command && - lhs.shift == rhs.shift && - lhs.option == rhs.option && - lhs.control == rhs.control - } - - private static func isNumberedDigitStroke(_ stroke: ShortcutStroke) -> Bool { - guard let digit = Int(stroke.key) else { return false } - return (1...9).contains(digit) - } - - private static func strokesConflict(_ lhs: ShortcutStroke, _ rhs: ShortcutStroke) -> Bool { - lhs.key == rhs.key && - lhs.command == rhs.command && - lhs.shift == rhs.shift && - lhs.option == rhs.option && - lhs.control == rhs.control - } - - private static func storedShortcutForPersistence( - _ shortcut: StoredShortcut, - action: Action - ) -> StoredShortcut? { - switch action.resolvedRecordedShortcutIgnoringConflicts(shortcut) { - case let .accepted(normalizedShortcut): - return normalizedShortcut - case .rejected: - if action.usesNumberedDigitMatching || action == .showHideAllWindows { - return nil - } - return shortcut - } - } - - private static func storedShortcutForReplacement( - _ shortcut: StoredShortcut, - action: Action - ) -> StoredShortcut? { - switch action.resolvedRecordedShortcutIgnoringConflicts(shortcut) { - case let .accepted(normalizedShortcut): - return normalizedShortcut - case .rejected: - return nil - } - } - - private static func persistShortcut( - _ shortcut: StoredShortcut, - for action: Action, - defaults: UserDefaults = .standard - ) { - guard let data = try? JSONEncoder().encode(shortcut) else { return } - defaults.set(data, forKey: action.defaultsKey) - } - static func shortcut(for action: Action) -> StoredShortcut { +#if DEBUG + if let testingShortcut = testingShortcutOverrides[action] { + return testingShortcut + } +#endif if let managedShortcut = settingsFileStore.override(for: action) { return managedShortcut } @@ -661,39 +437,42 @@ enum KeyboardShortcutSettings { static func setShortcut(_ shortcut: StoredShortcut, for action: Action) { guard !isManagedBySettingsFile(action) else { return } - guard let storedShortcut = storedShortcutForPersistence(shortcut, action: action) else { + let storedShortcut: StoredShortcut + if let normalizedShortcut = action.normalizedRecordedShortcut(shortcut) { + storedShortcut = normalizedShortcut + } else if action.usesNumberedDigitMatching || action == .showHideAllWindows { return + } else { + storedShortcut = shortcut } - persistShortcut(storedShortcut, for: action) + if let data = try? JSONEncoder().encode(storedShortcut) { + UserDefaults.standard.set(data, forKey: action.defaultsKey) + } postDidChangeNotification(action: action) } - static func swapShortcutConflict( - proposedShortcut: StoredShortcut, - currentAction: Action, - conflictingAction: Action, - previousShortcut: StoredShortcut - ) { - guard !isManagedBySettingsFile(currentAction), - !isManagedBySettingsFile(conflictingAction), - let resolvedCurrentShortcut = storedShortcutForReplacement( - proposedShortcut, - action: currentAction - ), - let resolvedConflictingShortcut = storedShortcutForReplacement( - previousShortcut, - action: conflictingAction - ) else { +#if DEBUG + static func setShortcutOverrideForTesting(_ shortcut: StoredShortcut, for action: Action) { + let storedShortcut: StoredShortcut + if let normalizedShortcut = action.normalizedRecordedShortcut(shortcut) { + storedShortcut = normalizedShortcut + } else if action.usesNumberedDigitMatching || action == .showHideAllWindows { return + } else { + storedShortcut = shortcut } - persistShortcut(resolvedCurrentShortcut, for: currentAction) - persistShortcut(resolvedConflictingShortcut, for: conflictingAction) - postDidChangeNotification(action: currentAction) - postDidChangeNotification(action: conflictingAction) + testingShortcutOverrides[action] = storedShortcut + postDidChangeNotification(action: action) } + static func clearShortcutOverrideForTesting(for action: Action) { + guard testingShortcutOverrides.removeValue(forKey: action) != nil else { return } + postDidChangeNotification(action: action) + } +#endif + static func notifySettingsFileDidChange() { postDidChangeNotification() } @@ -796,12 +575,6 @@ enum SystemWideHotkeySettings { KeyboardShortcutSettings.setShortcut(shortcut, for: action) } - static func normalizedRecordedShortcutResult( - _ shortcut: StoredShortcut - ) -> KeyboardShortcutSettings.RecordedShortcutResolution { - action.normalizedRecordedShortcutResult(shortcut) - } - static func normalizedRecordedShortcut(_ shortcut: StoredShortcut) -> StoredShortcut? { action.normalizedRecordedShortcut(shortcut) } @@ -954,7 +727,7 @@ final class SystemWideHotkeyController { guard status == noErr, let hotKeyRef else { #if DEBUG - cmuxDebugLog( + dlog( "globalHotkey.register failed shortcut=\(normalizedShortcut.displayString) " + "keyCode=\(registration.keyCode) modifiers=\(registration.modifiers) status=\(status)" ) @@ -967,7 +740,7 @@ final class SystemWideHotkeyController { registeredHotKeyRegistration = registration #if DEBUG - cmuxDebugLog( + dlog( "globalHotkey.register success shortcut=\(normalizedShortcut.displayString) " + "keyCode=\(registration.keyCode) modifiers=\(registration.modifiers)" ) @@ -994,7 +767,7 @@ final class SystemWideHotkeyController { #if DEBUG if status != noErr { - cmuxDebugLog("globalHotkey.handlerInstall failed status=\(status)") + dlog("globalHotkey.handlerInstall failed status=\(status)") } #endif } @@ -1037,7 +810,7 @@ final class SystemWideHotkeyController { } #if DEBUG - cmuxDebugLog("globalHotkey.fire shortcut=\(shortcut.displayString) active=\(NSApp.isActive ? 1 : 0)") + dlog("globalHotkey.fire shortcut=\(shortcut.displayString) active=\(NSApp.isActive ? 1 : 0)") #endif DispatchQueue.main.async { [weak self] in @@ -1117,18 +890,7 @@ final class SystemWideHotkeyController { } } -struct ShortcutStroke: Equatable, Hashable { - enum RecordingResult: Equatable { - case accepted(ShortcutStroke) - case rejected(KeyboardShortcutSettings.ShortcutRecordingRejection) - case unsupportedKey - } - - private struct RecordableKey { - let key: String - let keyCode: UInt16? - } - +struct ShortcutStroke: Equatable { var key: String var command: Bool var shift: Bool @@ -1171,26 +933,7 @@ struct ShortcutStroke: Equatable, Hashable { return String(localized: "shortcut.key.tab", defaultValue: "Tab") case "\r": return "↩" - case "media.brightnessDown": - return String(localized: "shortcut.key.mediaBrightnessDown", defaultValue: "Brightness Down") - case "media.brightnessUp": - return String(localized: "shortcut.key.mediaBrightnessUp", defaultValue: "Brightness Up") - case "media.mute": - return String(localized: "shortcut.key.mediaMute", defaultValue: "Mute") - case "media.next": - return String(localized: "shortcut.key.mediaNext", defaultValue: "Next Track") - case "media.playPause": - return String(localized: "shortcut.key.mediaPlayPause", defaultValue: "Play/Pause") - case "media.previous": - return String(localized: "shortcut.key.mediaPrevious", defaultValue: "Previous Track") - case "media.volumeDown": - return String(localized: "shortcut.key.mediaVolumeDown", defaultValue: "Volume Down") - case "media.volumeUp": - return String(localized: "shortcut.key.mediaVolumeUp", defaultValue: "Volume Up") default: - if let functionKeyDisplayString = Self.functionKeyDisplayString(for: key) { - return functionKeyDisplayString - } return key.uppercased() } } @@ -1209,10 +952,6 @@ struct ShortcutStroke: Equatable, Hashable { } var keyEquivalent: KeyEquivalent? { - if Self.usesDirectKeyCodeMatching(key) { - return nil - } - switch key { case "←": return .leftArrow @@ -1251,10 +990,6 @@ struct ShortcutStroke: Equatable, Hashable { } var menuItemKeyEquivalent: String? { - if Self.usesDirectKeyCodeMatching(key) { - return nil - } - switch key { case "←": guard let scalar = UnicodeScalar(NSLeftArrowFunctionKey) else { return nil } @@ -1280,8 +1015,6 @@ struct ShortcutStroke: Equatable, Hashable { } static func isEscapeCancelEvent(_ event: NSEvent) -> Bool { - guard event.type == .keyDown || event.type == .keyUp else { return false } - if event.keyCode == 53 { return true } @@ -1303,38 +1036,25 @@ struct ShortcutStroke: Equatable, Hashable { } static func from(event: NSEvent, requireModifier: Bool = true) -> ShortcutStroke? { - guard case let .accepted(stroke) = recordingResult(from: event, requireModifier: requireModifier) else { - return nil - } - return stroke - } - - static func recordingResult( - from event: NSEvent, - requireModifier: Bool = true - ) -> RecordingResult { guard !isEscapeCancelEvent(event), - let recordableKey = recordableKey(from: event) else { - return .unsupportedKey - } + let key = storedKey(from: event) else { return nil } let flags = normalizedModifierFlags(from: event.modifierFlags) let stroke = ShortcutStroke( - key: recordableKey.key, + key: key, command: flags.contains(.command), shift: flags.contains(.shift), option: flags.contains(.option), control: flags.contains(.control), - keyCode: recordableKey.keyCode + keyCode: event.keyCode ) if requireModifier, - !stroke.command && !stroke.shift && !stroke.option && !stroke.control && - !stroke.isBareShortcutAllowedWithoutModifier { - return .rejected(.bareKeyNotAllowed) + !stroke.command && !stroke.shift && !stroke.option && !stroke.control { + return nil } - return .accepted(stroke) + return stroke } static func normalizedModifierFlags(from flags: NSEvent.ModifierFlags) -> NSEvent.ModifierFlags { @@ -1346,19 +1066,8 @@ struct ShortcutStroke: Equatable, Hashable { event: NSEvent, layoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) ) -> Bool { - let shortcutKey = key.lowercased() - if shortcutKey.hasPrefix("media.") { - guard let eventMediaKey = Self.mediaKey(from: event)?.key.lowercased() else { - return false - } - return eventMediaKey == shortcutKey && - Self.normalizedModifierFlags(from: event.modifierFlags) == modifierFlags - } - - guard event.type == .keyDown else { return false } - - return matches( - keyCode: Self.recordableKey(from: event)?.keyCode ?? event.keyCode, + matches( + keyCode: event.keyCode, modifierFlags: event.modifierFlags, eventCharacter: event.charactersIgnoringModifiers, layoutCharacterProvider: layoutCharacterProvider @@ -1375,13 +1084,6 @@ struct ShortcutStroke: Equatable, Hashable { guard flags == self.modifierFlags else { return false } let shortcutKey = key.lowercased() - if Self.usesDirectKeyCodeMatching(shortcutKey) { - guard let expectedKeyCode = self.keyCode ?? Self.keyCodeForShortcutKey(shortcutKey) else { - return false - } - return keyCode == expectedKeyCode - } - if shortcutKey == "\r" { return keyCode == 36 || keyCode == 76 } @@ -1438,31 +1140,11 @@ struct ShortcutStroke: Equatable, Hashable { return false } - private var isBareShortcutAllowedWithoutModifier: Bool { - Self.usesDirectKeyCodeMatching(key) - } - - private static func recordableKey(from event: NSEvent) -> RecordableKey? { - if event.type == .systemDefined { - return mediaKey(from: event) - } - - guard event.type == .keyDown || event.type == .keyUp else { - return nil - } - - if let specialKey = event.specialKey, - let recordableKey = recordableKey(from: specialKey, eventKeyCode: event.keyCode) { - return recordableKey - } - - guard let storedKey = storedKey( + private static func storedKey(from event: NSEvent) -> String? { + storedKey( keyCode: event.keyCode, charactersIgnoringModifiers: event.charactersIgnoringModifiers - ) else { - return nil - } - return RecordableKey(key: storedKey, keyCode: event.keyCode) + ) } private static func storedKey( @@ -1503,61 +1185,6 @@ struct ShortcutStroke: Equatable, Hashable { return nil } - private static func recordableKey( - from specialKey: NSEvent.SpecialKey, - eventKeyCode: UInt16 - ) -> RecordableKey? { - switch specialKey { - case .f1: return RecordableKey(key: "f1", keyCode: eventKeyCode) - case .f2: return RecordableKey(key: "f2", keyCode: eventKeyCode) - case .f3: return RecordableKey(key: "f3", keyCode: eventKeyCode) - case .f4: return RecordableKey(key: "f4", keyCode: eventKeyCode) - case .f5: return RecordableKey(key: "f5", keyCode: eventKeyCode) - case .f6: return RecordableKey(key: "f6", keyCode: eventKeyCode) - case .f7: return RecordableKey(key: "f7", keyCode: eventKeyCode) - case .f8: return RecordableKey(key: "f8", keyCode: eventKeyCode) - case .f9: return RecordableKey(key: "f9", keyCode: eventKeyCode) - case .f10: return RecordableKey(key: "f10", keyCode: eventKeyCode) - case .f11: return RecordableKey(key: "f11", keyCode: eventKeyCode) - case .f12: return RecordableKey(key: "f12", keyCode: eventKeyCode) - case .f13: return RecordableKey(key: "f13", keyCode: eventKeyCode) - case .f14: return RecordableKey(key: "f14", keyCode: eventKeyCode) - case .f15: return RecordableKey(key: "f15", keyCode: eventKeyCode) - case .f16: return RecordableKey(key: "f16", keyCode: eventKeyCode) - case .f17: return RecordableKey(key: "f17", keyCode: eventKeyCode) - case .f18: return RecordableKey(key: "f18", keyCode: eventKeyCode) - case .f19: return RecordableKey(key: "f19", keyCode: eventKeyCode) - case .f20: return RecordableKey(key: "f20", keyCode: eventKeyCode) - default: - return nil - } - } - - private static func mediaKey(from event: NSEvent) -> RecordableKey? { - guard event.type == .systemDefined, - event.subtype.rawValue == Int16(8) else { - return nil - } - - let data1 = UInt32(truncatingIfNeeded: event.data1) - let keyCode = UInt16((data1 & 0xFFFF0000) >> 16) - let keyState = UInt8((data1 & 0x0000FF00) >> 8) - guard keyState == 0x0A else { return nil } - - switch keyCode { - case 0: return RecordableKey(key: "media.volumeUp", keyCode: keyCode) - case 1: return RecordableKey(key: "media.volumeDown", keyCode: keyCode) - case 2: return RecordableKey(key: "media.brightnessUp", keyCode: keyCode) - case 3: return RecordableKey(key: "media.brightnessDown", keyCode: keyCode) - case 7: return RecordableKey(key: "media.mute", keyCode: keyCode) - case 16: return RecordableKey(key: "media.playPause", keyCode: keyCode) - case 17: return RecordableKey(key: "media.next", keyCode: keyCode) - case 18: return RecordableKey(key: "media.previous", keyCode: keyCode) - default: - return nil - } - } - static func normalizedShortcutEventCharacter( _ eventCharacter: String, applyShiftSymbolNormalization: Bool, @@ -1615,34 +1242,6 @@ struct ShortcutStroke: Equatable, Hashable { private static func keyCodeForShortcutKey(_ key: String) -> UInt16? { switch key { - case "f1": return 122 - case "f2": return 120 - case "f3": return 99 - case "f4": return 118 - case "f5": return 96 - case "f6": return 97 - case "f7": return 98 - case "f8": return 100 - case "f9": return 101 - case "f10": return 109 - case "f11": return 103 - case "f12": return 111 - case "f13": return 105 - case "f14": return 107 - case "f15": return 113 - case "f16": return 106 - case "f17": return 64 - case "f18": return 79 - case "f19": return 80 - case "f20": return 90 - case "media.volumeUp": return 0 - case "media.volumeDown": return 1 - case "media.brightnessUp": return 2 - case "media.brightnessDown": return 3 - case "media.mute": return 7 - case "media.playPause": return 16 - case "media.next": return 17 - case "media.previous": return 18 case "a": return 0 case "s": return 1 case "d": return 2 @@ -1701,19 +1300,6 @@ struct ShortcutStroke: Equatable, Hashable { } } - private static func usesDirectKeyCodeMatching(_ key: String) -> Bool { - functionKeyDisplayString(for: key) != nil || key.hasPrefix("media.") - } - - private static func functionKeyDisplayString(for key: String) -> String? { - guard key.hasPrefix("f"), - let number = Int(key.dropFirst()), - (1...20).contains(number) else { - return nil - } - return "F\(number)" - } - private static func digitForNumberKeyCode(_ keyCode: UInt16) -> Int? { switch keyCode { case 18: return 1 @@ -1779,7 +1365,7 @@ struct ShortcutStroke: Equatable, Hashable { } /// A keyboard shortcut that can be stored in UserDefaults -struct StoredShortcut: Codable, Equatable, Hashable { +struct StoredShortcut: Codable, Equatable { var key: String var command: Bool var shift: Bool @@ -1983,161 +1569,8 @@ struct StoredShortcut: Codable, Equatable, Hashable { } } -extension ShortcutStroke { - static func parseConfig(_ rawValue: String) -> ShortcutStroke? { - let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - let parts = trimmed.split(separator: "+", omittingEmptySubsequences: false) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - guard !parts.isEmpty, let lastPart = parts.last, !lastPart.isEmpty else { - return nil - } - - var command = false - var shift = false - var option = false - var control = false - - for modifier in parts.dropLast() { - switch modifier.lowercased() { - case "cmd", "command", "⌘": - command = true - case "shift", "⇧": - shift = true - case "opt", "option", "alt", "⌥": - option = true - case "ctrl", "control", "ctl", "⌃": - control = true - default: - return nil - } - } - - guard let key = parseConfigKeyToken(lastPart) else { return nil } - return ShortcutStroke( - key: key, - command: command, - shift: shift, - option: option, - control: control - ) - } - - func configString(preserveDigit: Bool = true) -> String { - var parts: [String] = [] - if command { parts.append("cmd") } - if shift { parts.append("shift") } - if option { parts.append("opt") } - if control { parts.append("ctrl") } - parts.append(configKeyString(preserveDigit: preserveDigit)) - return parts.joined(separator: "+") - } - - private func configKeyString(preserveDigit: Bool) -> String { - if preserveDigit { - return key - } - if let digit = Int(key), (1...9).contains(digit) { - return "1" - } - return key - } - - private static func parseConfigKeyToken(_ rawValue: String) -> String? { - let lowered = rawValue.lowercased() - switch lowered { - case "left", "arrowleft", "leftarrow", "←": - return "←" - case "right", "arrowright", "rightarrow", "→": - return "→" - case "up", "arrowup", "uparrow", "↑": - return "↑" - case "down", "arrowdown", "downarrow", "↓": - return "↓" - case "tab": - return "\t" - case "return", "enter", "↩": - return "\r" - case "space": - return " " - case "comma": - return "," - case "period", "dot": - return "." - case "slash": - return "/" - case "backslash": - return "\\" - case "semicolon": - return ";" - case "quote", "apostrophe": - return "'" - case "backtick", "grave": - return "`" - case "minus", "hyphen": - return "-" - case "plus", "equals": - return "=" - case "leftbracket", "openbracket": - return "[" - case "rightbracket", "closebracket": - return "]" - case "volumeup", "mediavolumeup", "media.volumeup": - return "media.volumeUp" - case "volumedown", "mediavolumedown", "media.volumedown": - return "media.volumeDown" - case "brightnessup", "mediabrightnessup", "media.brightnessup": - return "media.brightnessUp" - case "brightnessdown", "mediabrightnessdown", "media.brightnessdown": - return "media.brightnessDown" - case "mute", "mediamute", "media.mute": - return "media.mute" - case "playpause", "mediaplaypause", "media.playpause": - return "media.playPause" - case "nexttrack", "medianext", "media.next", "media.nexttrack": - return "media.next" - case "previoustrack", "mediaprevious", "media.previous", "media.previoustrack": - return "media.previous" - default: - if lowered.hasPrefix("f"), - let number = Int(lowered.dropFirst()), - (1...20).contains(number) { - return "f\(number)" - } - guard lowered.count == 1 else { return nil } - return lowered - } - } -} - -extension StoredShortcut { - static func parseConfig(_ rawValue: String) -> StoredShortcut? { - parseConfig(strokes: [rawValue]) - } - - static func parseConfig(strokes: [String]) -> StoredShortcut? { - guard !strokes.isEmpty, strokes.count <= 2 else { return nil } - let parsedStrokes = strokes.compactMap(ShortcutStroke.parseConfig(_:)) - guard parsedStrokes.count == strokes.count, let firstStroke = parsedStrokes.first else { - return nil - } - guard !firstStroke.modifierFlags.isEmpty else { return nil } - let secondStroke = parsedStrokes.count == 2 ? parsedStrokes[1] : nil - return StoredShortcut(first: firstStroke, second: secondStroke) - } - - var configIdentifier: String { - if let secondStroke { - return "\(firstStroke.configString()) \(secondStroke.configString())" - } - return firstStroke.configString() - } -} - -enum KeyboardShortcutRecorderActivity { +private enum KeyboardShortcutRecorderActivity { static let didChangeNotification = Notification.Name("cmux.keyboardShortcutRecorderActivityDidChange") - static let stopAllNotification = Notification.Name("cmux.keyboardShortcutRecorderActivityStopAll") private static var activeRecorderCount = 0 static var isAnyRecorderActive: Bool { @@ -2160,117 +1593,6 @@ enum KeyboardShortcutRecorderActivity { center.post(name: didChangeNotification, object: nil) } } - - static func stopAllRecording(center: NotificationCenter = .default) { - center.post(name: stopAllNotification, object: nil) - } -} - -struct ShortcutRecorderRejectedAttempt: Equatable { - let reason: KeyboardShortcutSettings.ShortcutRecordingRejection - let proposedShortcut: StoredShortcut? -} - -struct ShortcutRecorderValidationPresentation: Equatable { - let message: String - let swapButtonTitle: String? - let undoButtonTitle: String - let canSwap: Bool - - init?( - attempt: ShortcutRecorderRejectedAttempt?, - action: KeyboardShortcutSettings.Action, - currentShortcut: StoredShortcut, - isManagedBySettingsFile: (KeyboardShortcutSettings.Action) -> Bool = KeyboardShortcutSettings.isManagedBySettingsFile, - shortcutForAction: (KeyboardShortcutSettings.Action) -> StoredShortcut = KeyboardShortcutSettings.shortcut(for:) - ) { - guard let attempt else { return nil } - - let canSwap = Self.canSwapConflict( - attempt: attempt, - action: action, - currentShortcut: currentShortcut, - isManagedBySettingsFile: isManagedBySettingsFile - ) - - self.message = Self.message( - for: attempt.reason, - canSwap: canSwap, - shortcutForAction: shortcutForAction - ) - self.swapButtonTitle = canSwap - ? String(localized: "shortcut.recorder.swap", defaultValue: "Swap") - : nil - self.undoButtonTitle = String(localized: "shortcut.recorder.undo", defaultValue: "Undo") - self.canSwap = canSwap - } - - private static func message( - for reason: KeyboardShortcutSettings.ShortcutRecordingRejection, - canSwap: Bool, - shortcutForAction: (KeyboardShortcutSettings.Action) -> StoredShortcut - ) -> String { - switch reason { - case .bareKeyNotAllowed: - return String( - localized: "shortcut.recorder.error.bareKeyNotAllowed", - defaultValue: "Shortcuts must include ⌘ ⌥ ⌃ or ⇧" - ) - case let .conflictsWithAction(conflictingAction): - let conflictingShortcut = conflictingAction.displayedShortcutString( - for: shortcutForAction(conflictingAction) - ) - let format: String - if canSwap { - format = String( - localized: "shortcut.recorder.error.conflictsWithAction.swap", - defaultValue: "This shortcut conflicts with %@ (%@). Swap shortcuts?" - ) - } else { - format = String( - localized: "shortcut.recorder.error.conflictsWithAction", - defaultValue: "This shortcut conflicts with %@ (%@)." - ) - } - return String.localizedStringWithFormat(format, conflictingAction.label, conflictingShortcut) - case .reservedBySystem: - return String( - localized: "shortcut.recorder.error.reservedBySystem", - defaultValue: "This keystroke is reserved by macOS." - ) - case .numberedShortcutRequiresDigit: - return String( - localized: "shortcut.recorder.error.numberedShortcutRequiresDigit", - defaultValue: "Use a digit from 1 through 9." - ) - case .systemWideHotkeyRequiresModifier: - return String( - localized: "shortcut.recorder.error.systemWideHotkeyRequiresModifier", - defaultValue: "System-wide hotkeys must include Command, Option, or Control." - ) - } - } - - private static func canSwapConflict( - attempt: ShortcutRecorderRejectedAttempt, - action: KeyboardShortcutSettings.Action, - currentShortcut: StoredShortcut, - isManagedBySettingsFile: (KeyboardShortcutSettings.Action) -> Bool - ) -> Bool { - guard case let .conflictsWithAction(conflictingAction) = attempt.reason, - let proposedShortcut = attempt.proposedShortcut, - !isManagedBySettingsFile(action), - !isManagedBySettingsFile(conflictingAction) else { - return false - } - - guard case .accepted = action.resolvedRecordedShortcutIgnoringConflicts(proposedShortcut), - case .accepted = conflictingAction.resolvedRecordedShortcutIgnoringConflicts(currentShortcut) else { - return false - } - - return true - } } /// View for recording a keyboard shortcut @@ -2279,83 +1601,33 @@ struct KeyboardShortcutRecorder: View { var subtitle: String? = nil @Binding var shortcut: StoredShortcut var displayString: (StoredShortcut) -> String = { $0.displayString } - var transformRecordedShortcut: (StoredShortcut) -> KeyboardShortcutSettings.RecordedShortcutResolution = { - .accepted($0) - } - var validationMessage: String? = nil - var validationButtonTitle: String? = nil - var onValidationButtonPressed: (() -> Void)? = nil - var undoButtonTitle: String? = nil - var onUndoButtonPressed: (() -> Void)? = nil - var hasPendingRejection: Bool = false + var transformRecordedShortcut: (StoredShortcut) -> StoredShortcut? = { $0 } var isDisabled: Bool = false var onRecordingChanged: (Bool) -> Void = { _ in } - var onRecorderFeedbackChanged: (ShortcutRecorderRejectedAttempt?) -> Void = { _ in } @State private var isRecording = false var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: subtitle == nil ? .center : .top, spacing: 12) { - VStack(alignment: .leading, spacing: 2) { - Text(label) - if let subtitle { - Text(subtitle) - .font(.caption) - .foregroundStyle(.secondary) - } + HStack(alignment: subtitle == nil ? .center : .top, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(label) + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) } - - Spacer() - - ShortcutRecorderButton( - shortcut: $shortcut, - isRecording: $isRecording, - hasPendingRejection: hasPendingRejection, - displayString: displayString, - transformRecordedShortcut: transformRecordedShortcut, - onRecordingChanged: onRecordingChanged, - onRecorderFeedbackChanged: onRecorderFeedbackChanged - ) - .frame(width: 160) - .disabled(isDisabled) } - if let validationMessage { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.caption) - .foregroundStyle(.red) - - Text(validationMessage) - .font(.caption) - .foregroundStyle(.red) - .fixedSize(horizontal: false, vertical: true) - - if let validationButtonTitle, let onValidationButtonPressed { - Button(validationButtonTitle, action: onValidationButtonPressed) - .buttonStyle(.link) - .font(.caption) - } + Spacer() - if let undoButtonTitle, let onUndoButtonPressed { - Button(undoButtonTitle, action: onUndoButtonPressed) - .buttonStyle(.link) - .font(.caption) - } - } - .padding(.horizontal, 8) - .padding(.vertical, 6) - .frame(maxWidth: .infinity, alignment: .leading) - .background { - RoundedRectangle(cornerRadius: 6) - .fill(Color.red.opacity(0.12)) - } - .overlay { - RoundedRectangle(cornerRadius: 6) - .stroke(Color.red.opacity(0.35), lineWidth: 1) - } - .accessibilityIdentifier("ShortcutRecorderValidationMessage") - } + ShortcutRecorderButton( + shortcut: $shortcut, + isRecording: $isRecording, + displayString: displayString, + transformRecordedShortcut: transformRecordedShortcut, + onRecordingChanged: onRecordingChanged + ) + .frame(width: 160) + .disabled(isDisabled) } } } @@ -2363,11 +1635,9 @@ struct KeyboardShortcutRecorder: View { private struct ShortcutRecorderButton: NSViewRepresentable { @Binding var shortcut: StoredShortcut @Binding var isRecording: Bool - var hasPendingRejection: Bool = false let displayString: (StoredShortcut) -> String - let transformRecordedShortcut: (StoredShortcut) -> KeyboardShortcutSettings.RecordedShortcutResolution + let transformRecordedShortcut: (StoredShortcut) -> StoredShortcut? let onRecordingChanged: (Bool) -> Void - let onRecorderFeedbackChanged: (ShortcutRecorderRejectedAttempt?) -> Void func makeNSView(context: Context) -> ShortcutRecorderNSButton { let button = ShortcutRecorderNSButton() @@ -2377,13 +1647,11 @@ private struct ShortcutRecorderButton: NSViewRepresentable { button.onShortcutRecorded = { newShortcut in shortcut = newShortcut isRecording = false - onRecorderFeedbackChanged(nil) } button.onRecordingChanged = { recording in isRecording = recording onRecordingChanged(recording) } - button.onRecorderFeedbackChanged = onRecorderFeedbackChanged return button } @@ -2395,54 +1663,20 @@ private struct ShortcutRecorderButton: NSViewRepresentable { isRecording = recording onRecordingChanged(recording) } - nsView.onRecorderFeedbackChanged = onRecorderFeedbackChanged - if !hasPendingRejection { - nsView.clearPendingRejection() - } nsView.updateTitle() } } final class ShortcutRecorderNSButton: NSButton { - var shortcut: StoredShortcut = KeyboardShortcutSettings.showNotificationsDefault { - didSet { - if shortcut != oldValue { - hasPendingRejection = false - } - } - } + var shortcut: StoredShortcut = KeyboardShortcutSettings.showNotificationsDefault var displayString: (StoredShortcut) -> String = { $0.displayString } - var transformRecordedShortcut: (StoredShortcut) -> KeyboardShortcutSettings.RecordedShortcutResolution = { - .accepted($0) - } + var transformRecordedShortcut: (StoredShortcut) -> StoredShortcut? = { $0 } var onShortcutRecorded: ((StoredShortcut) -> Void)? var onRecordingChanged: ((Bool) -> Void)? - var onRecorderFeedbackChanged: ((ShortcutRecorderRejectedAttempt?) -> Void)? private var isRecording = false - private var hasPendingRejection = false private var eventMonitor: Any? private var pendingChordStart: ShortcutStroke? private var hasRegisteredRecordingActivity = false - private weak var previousFirstResponder: NSResponder? - - override var acceptsFirstResponder: Bool { - true - } - - override func performKeyEquivalent(with event: NSEvent) -> Bool { - guard isRecording else { - return super.performKeyEquivalent(with: event) - } - return handleRecordingEvent(event) == nil - } - - override func keyDown(with event: NSEvent) { - guard isRecording else { - super.keyDown(with: event) - return - } - _ = handleRecordingEvent(event) - } override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -2459,12 +1693,6 @@ final class ShortcutRecorderNSButton: NSButton { setButtonType(.momentaryPushIn) target = self action = #selector(buttonClicked) - NotificationCenter.default.addObserver( - self, - selector: #selector(stopRecordingFromNotification), - name: KeyboardShortcutRecorderActivity.stopAllNotification, - object: nil - ) updateTitle() } @@ -2476,8 +1704,6 @@ final class ShortcutRecorderNSButton: NSButton { } else { title = String(localized: "shortcut.pressShortcut.prompt", defaultValue: "Press shortcut…") } - } else if hasPendingRejection { - title = String(localized: "shortcut.pressShortcut.prompt", defaultValue: "Press shortcut…") } else { title = displayString(shortcut) } @@ -2487,19 +1713,13 @@ final class ShortcutRecorderNSButton: NSButton { if isRecording { if let pendingChordStart { let storedShortcut = StoredShortcut(first: pendingChordStart) - switch transformRecordedShortcut(storedShortcut) { - case let .accepted(transformedShortcut): - shortcut = transformedShortcut - onShortcutRecorded?(transformedShortcut) - onRecorderFeedbackChanged?(nil) - case let .rejected(reason): - hasPendingRejection = true - onRecorderFeedbackChanged?( - ShortcutRecorderRejectedAttempt(reason: reason, proposedShortcut: storedShortcut) - ) + guard let transformedShortcut = transformRecordedShortcut(storedShortcut) else { + NSSound.beep() stopRecording() return } + shortcut = transformedShortcut + onShortcutRecorded?(transformedShortcut) } stopRecording() } else { @@ -2509,92 +1729,56 @@ final class ShortcutRecorderNSButton: NSButton { private func startRecording() { guard !isRecording else { return } - KeyboardShortcutRecorderActivity.stopAllRecording() isRecording = true - hasPendingRejection = false pendingChordStart = nil - previousFirstResponder = window?.firstResponder - window?.makeFirstResponder(self) registerRecordingActivityIfNeeded() onRecordingChanged?(true) - onRecorderFeedbackChanged?(nil) updateTitle() - eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .systemDefined]) { [weak self] event in - self?.handleRecordingEvent(event) ?? event - } + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self = self else { return event } - // Also stop recording if window loses focus - NotificationCenter.default.addObserver( - self, - selector: #selector(windowResigned), - name: NSWindow.didResignKeyNotification, - object: window - ) - } - - private func handleRecordingEvent(_ event: NSEvent) -> NSEvent? { - if ShortcutStroke.isEscapeCancelEvent(event) { - stopRecording() - return nil - } + if ShortcutStroke.isEscapeCancelEvent(event) { + self.stopRecording() + return nil + } - if pendingChordStart == nil { - switch ShortcutStroke.recordingResult(from: event, requireModifier: true) { - case let .accepted(firstStroke): - let firstShortcut = StoredShortcut(first: firstStroke) - switch transformRecordedShortcut(firstShortcut) { - case let .accepted(transformedShortcut): - shortcut = transformedShortcut - onShortcutRecorded?(transformedShortcut) - onRecorderFeedbackChanged?(nil) - stopRecording() - return nil - case let .rejected(reason): - hasPendingRejection = true - onRecorderFeedbackChanged?( - ShortcutRecorderRejectedAttempt(reason: reason, proposedShortcut: firstShortcut) - ) - stopRecording() + if self.pendingChordStart == nil { + guard let firstStroke = ShortcutStroke.from(event: event, requireModifier: true) else { return nil } - case let .rejected(reason): - hasPendingRejection = true - onRecorderFeedbackChanged?( - ShortcutRecorderRejectedAttempt(reason: reason, proposedShortcut: nil) - ) - stopRecording() - return nil - case .unsupportedKey: + self.pendingChordStart = firstStroke + self.updateTitle() return nil } - } - - guard let pendingChordStart else { - return nil - } - if let secondStroke = ShortcutStroke.from(event: event, requireModifier: false) { - let newShortcut = StoredShortcut(first: pendingChordStart, second: secondStroke) - switch transformRecordedShortcut(newShortcut) { - case let .accepted(transformedShortcut): - shortcut = transformedShortcut - onShortcutRecorded?(transformedShortcut) - onRecorderFeedbackChanged?(nil) - stopRecording() + guard let pendingChordStart = self.pendingChordStart else { return nil - case let .rejected(reason): - hasPendingRejection = true - onRecorderFeedbackChanged?( - ShortcutRecorderRejectedAttempt(reason: reason, proposedShortcut: newShortcut) - ) - stopRecording() + } + + if let secondStroke = ShortcutStroke.from(event: event, requireModifier: false) { + let newShortcut = StoredShortcut(first: pendingChordStart, second: secondStroke) + guard let transformedShortcut = self.transformRecordedShortcut(newShortcut) else { + NSSound.beep() + return nil + } + self.shortcut = transformedShortcut + self.onShortcutRecorded?(transformedShortcut) + self.stopRecording() return nil } + + // Consume unsupported keys while recording to avoid triggering app shortcuts. + return nil } - // Consume unsupported keys while recording to avoid triggering app shortcuts. - return nil + // Also stop recording if window loses focus + NotificationCenter.default.addObserver( + self, + selector: #selector(windowResigned), + name: NSWindow.didResignKeyNotification, + object: window + ) } private func stopRecording() { @@ -2611,27 +1795,12 @@ final class ShortcutRecorderNSButton: NSButton { } NotificationCenter.default.removeObserver(self, name: NSWindow.didResignKeyNotification, object: window) - - if window?.firstResponder === self { - window?.makeFirstResponder(previousFirstResponder) - } - previousFirstResponder = nil } @objc private func windowResigned() { stopRecording() } - @objc private func stopRecordingFromNotification() { - stopRecording() - } - - func clearPendingRejection() { - guard hasPendingRejection else { return } - hasPendingRejection = false - updateTitle() - } - private func registerRecordingActivityIfNeeded() { guard !hasRegisteredRecordingActivity else { return } hasRegisteredRecordingActivity = true @@ -2649,27 +1818,14 @@ final class ShortcutRecorderNSButton: NSButton { isRecording } - var debugHasPendingRejection: Bool { - hasPendingRejection - } - func debugSetPendingChordStart(_ stroke: ShortcutStroke?) { isRecording = true pendingChordStart = stroke updateTitle() } - - func debugHandleRecordingEvent(_ event: NSEvent) -> NSEvent? { - handleRecordingEvent(event) - } #endif deinit { stopRecording() - NotificationCenter.default.removeObserver( - self, - name: KeyboardShortcutRecorderActivity.stopAllNotification, - object: nil - ) } } diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index d72a59ff2ce3..e117023f9be1 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -79,6 +79,7 @@ final class CmuxSettingsFileStore { "automation.geminiIntegration", "automation.portBase", "automation.portRange", + "customCommands.trustedDirectories", "browser.defaultSearchEngine", "browser.showSearchSuggestions", "browser.theme", @@ -95,6 +96,7 @@ final class CmuxSettingsFileStore { private static let releaseBundleIdentifier = "com.cmuxterm.app" private static let backupsDefaultsKey = "cmux.settingsFile.backups.v1" + fileprivate static let trustedDirectoriesBackupIdentifier = "customCommands.trustedDirectories" fileprivate static let socketPasswordBackupIdentifier = "automation.socketPassword" static var defaultPrimaryPath: String { @@ -124,6 +126,7 @@ final class CmuxSettingsFileStore { private var primaryWatcher: ShortcutSettingsFileWatcher? private var fallbackWatcher: ShortcutSettingsFileWatcher? private var defaultsCancellable: AnyCancellable? + private var trustObserver: NSObjectProtocol? private var socketPasswordObserver: NSObjectProtocol? private var shortcutsByAction: [KeyboardShortcutSettings.Action: StoredShortcut] = [:] @@ -165,6 +168,13 @@ final class CmuxSettingsFileStore { .sink { [weak self] _ in self?.reapplyManagedSettingsIfNeeded() } + trustObserver = notificationCenter.addObserver( + forName: CmuxDirectoryTrust.didChangeNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.reapplyManagedSettingsIfNeeded() + } socketPasswordObserver = notificationCenter.addObserver( forName: SocketControlPasswordStore.didChangeNotification, object: nil, @@ -178,6 +188,9 @@ final class CmuxSettingsFileStore { primaryWatcher?.stop() fallbackWatcher?.stop() defaultsCancellable?.cancel() + if let trustObserver { + notificationCenter.removeObserver(trustObserver) + } if let socketPasswordObserver { notificationCenter.removeObserver(socketPasswordObserver) } @@ -358,6 +371,9 @@ final class CmuxSettingsFileStore { if let automationSection = root["automation"] as? [String: Any] { parseAutomationSection(automationSection, sourcePath: sourcePath, snapshot: &snapshot) } + if let customCommandsSection = root["customCommands"] as? [String: Any] { + parseCustomCommandsSection(customCommandsSection, sourcePath: sourcePath, snapshot: &snapshot) + } if let browserSection = root["browser"] as? [String: Any] { parseBrowserSection(browserSection, sourcePath: sourcePath, snapshot: &snapshot) } @@ -728,6 +744,21 @@ final class CmuxSettingsFileStore { } } + private func parseCustomCommandsSection( + _ section: [String: Any], + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + if let values = jsonStringArray(section["trustedDirectories"]) { + let normalized = values + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + snapshot.managedCustomSettings.trustedDirectories = normalized + } else if section.keys.contains("trustedDirectories") { + logInvalid("customCommands.trustedDirectories", sourcePath: sourcePath) + } + } + private func parseBrowserSection( _ section: [String: Any], sourcePath: String, @@ -804,7 +835,6 @@ final class CmuxSettingsFileStore { if let value = jsonBool(section["showModifierHoldHints"]) { snapshot.managedUserDefaults[ShortcutHintDebugSettings.showHintsOnCommandHoldKey] = .bool(value) - snapshot.managedUserDefaults[ShortcutHintDebugSettings.showHintsOnControlHoldKey] = .bool(value) } var bindings = section["bindings"] as? [String: Any] ?? [:] @@ -835,9 +865,9 @@ final class CmuxSettingsFileStore { ) -> StoredShortcut? { let shortcut: StoredShortcut? if let stroke = jsonString(rawValue) { - shortcut = StoredShortcut.parseConfig(stroke) + shortcut = parseStoredShortcut(strokes: [stroke]) } else if let strokes = jsonStringArray(rawValue) { - shortcut = StoredShortcut.parseConfig(strokes: strokes) + shortcut = parseStoredShortcut(strokes: strokes) } else { shortcut = nil } @@ -849,6 +879,102 @@ final class CmuxSettingsFileStore { return action.usesNumberedDigitMatching ? nil : shortcut } + private func parseStoredShortcut(strokes: [String]) -> StoredShortcut? { + guard !strokes.isEmpty, strokes.count <= 2 else { return nil } + let parsedStrokes = strokes.compactMap(parseStroke(_:)) + guard parsedStrokes.count == strokes.count, let firstStroke = parsedStrokes.first else { + return nil + } + guard !firstStroke.modifierFlags.isEmpty else { return nil } + let secondStroke = parsedStrokes.count == 2 ? parsedStrokes[1] : nil + return StoredShortcut(first: firstStroke, second: secondStroke) + } + + private func parseStroke(_ rawValue: String) -> ShortcutStroke? { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let parts = trimmed.split(separator: "+", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard !parts.isEmpty, let lastPart = parts.last, !lastPart.isEmpty else { + return nil + } + + var command = false + var shift = false + var option = false + var control = false + + for modifier in parts.dropLast() { + switch modifier.lowercased() { + case "cmd", "command", "⌘": + command = true + case "shift", "⇧": + shift = true + case "opt", "option", "alt", "⌥": + option = true + case "ctrl", "control", "ctl", "⌃": + control = true + default: + return nil + } + } + + guard let key = parseKeyToken(lastPart) else { return nil } + return ShortcutStroke( + key: key, + command: command, + shift: shift, + option: option, + control: control + ) + } + + private func parseKeyToken(_ rawValue: String) -> String? { + let lowered = rawValue.lowercased() + switch lowered { + case "left", "arrowleft", "leftarrow", "←": + return "←" + case "right", "arrowright", "rightarrow", "→": + return "→" + case "up", "arrowup", "uparrow", "↑": + return "↑" + case "down", "arrowdown", "downarrow", "↓": + return "↓" + case "tab": + return "\t" + case "return", "enter", "↩": + return "\r" + case "space": + return " " + case "comma": + return "," + case "period", "dot": + return "." + case "slash": + return "/" + case "backslash": + return "\\" + case "semicolon": + return ";" + case "quote", "apostrophe": + return "'" + case "backtick", "grave": + return "`" + case "minus", "hyphen": + return "-" + case "plus", "equals": + return "=" + case "leftbracket", "openbracket": + return "[" + case "rightbracket", "closebracket": + return "]" + default: + guard lowered.count == 1 else { return nil } + return lowered + } + } + private func parseNullableHex( _ rawValue: Any?, path: String, @@ -887,6 +1013,10 @@ final class CmuxSettingsFileStore { for (defaultsKey, value) in snapshot.managedUserDefaults where backups[defaultsKey] == nil { backups[defaultsKey] = backupValueForUserDefaultsKey(defaultsKey, managedValue: value) } + if snapshot.managedCustomSettings.trustedDirectories != nil, + backups[Self.trustedDirectoriesBackupIdentifier] == nil { + backups[Self.trustedDirectoriesBackupIdentifier] = .stringArray(CmuxDirectoryTrust.shared.allTrustedPaths) + } if snapshot.managedCustomSettings.socketPassword != nil, backups[Self.socketPasswordBackupIdentifier] == nil { backups[Self.socketPasswordBackupIdentifier] = currentSocketPasswordBackupValue() @@ -910,6 +1040,11 @@ final class CmuxSettingsFileStore { } private func applyManagedCustomSettings(_ settings: ManagedCustomSettings) { + if let trustedDirectories = settings.trustedDirectories, + CmuxDirectoryTrust.shared.allTrustedPaths != trustedDirectories { + CmuxDirectoryTrust.shared.replaceAll(with: trustedDirectories) + } + if let socketPassword = settings.socketPassword { switch socketPassword { case .set(let value): @@ -928,6 +1063,12 @@ final class CmuxSettingsFileStore { private func restoreBackup(_ backup: BackupValue, for identifier: String) { switch identifier { + case Self.trustedDirectoriesBackupIdentifier: + if case .stringArray(let values) = backup { + CmuxDirectoryTrust.shared.replaceAll(with: values) + } else { + CmuxDirectoryTrust.shared.replaceAll(with: []) + } case Self.socketPasswordBackupIdentifier: switch backup { case .string(let value): @@ -1296,6 +1437,11 @@ final class CmuxSettingsFileStore { "portRange": 10, ], ], + [ + "customCommands": [ + "trustedDirectories": [String](), + ], + ], [ "browser": [ "defaultSearchEngine": BrowserSearchSettings.defaultSearchEngine.rawValue, @@ -1312,8 +1458,7 @@ final class CmuxSettingsFileStore { ], [ "shortcuts": [ - "showModifierHoldHints": ShortcutHintDebugSettings.defaultShowHintsOnCommandHold && - ShortcutHintDebugSettings.defaultShowHintsOnControlHold, + "showModifierHoldHints": ShortcutHintDebugSettings.defaultShowHintsOnCommandHold, "bindings": shortcutsBindings, ], ], @@ -1325,16 +1470,33 @@ final class CmuxSettingsFileStore { usesNumberedDigits: Bool ) -> Any { let defaultShortcut = usesNumberedDigits ? (shortcut.secondStroke ?? shortcut.firstStroke) : nil - let rendered = shortcut.firstStroke.configString(preserveDigit: !usesNumberedDigits) + let rendered = renderShortcutStroke(shortcut.firstStroke, preserveDigit: !usesNumberedDigits) if let secondStroke = shortcut.secondStroke { - return [rendered, secondStroke.configString(preserveDigit: true)] + return [rendered, renderShortcutStroke(secondStroke, preserveDigit: true)] } if let defaultShortcut { - return defaultShortcut.configString(preserveDigit: true) + return renderShortcutStroke(defaultShortcut, preserveDigit: true) } return rendered } + private static func renderShortcutStroke(_ stroke: ShortcutStroke, preserveDigit: Bool) -> String { + var parts: [String] = [] + if stroke.command { parts.append("cmd") } + if stroke.shift { parts.append("shift") } + if stroke.option { parts.append("opt") } + if stroke.control { parts.append("ctrl") } + parts.append(renderShortcutKey(stroke.key, preserveDigit: preserveDigit)) + return parts.joined(separator: "+") + } + + private static func renderShortcutKey(_ key: String, preserveDigit: Bool) -> String { + if preserveDigit { + return key + } + return key + } + private static func prettyJSONString(_ value: Any) -> String { guard let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted, .sortedKeys]), let string = String(data: data, encoding: .utf8) else { @@ -1359,14 +1521,18 @@ private enum ManagedStringOverride: Equatable { } private struct ManagedCustomSettings: Equatable { + var trustedDirectories: [String]? var socketPassword: ManagedStringOverride? var isEmpty: Bool { - socketPassword == nil + trustedDirectories == nil && socketPassword == nil } var managedIdentifiers: Set { var identifiers: Set = [] + if trustedDirectories != nil { + identifiers.insert(CmuxSettingsFileStore.trustedDirectoriesBackupIdentifier) + } if socketPassword != nil { identifiers.insert(CmuxSettingsFileStore.socketPasswordBackupIdentifier) } diff --git a/Sources/MainWindowFocusController.swift b/Sources/MainWindowFocusController.swift index c76496aed5ea..0be8427ce33c 100644 --- a/Sources/MainWindowFocusController.swift +++ b/Sources/MainWindowFocusController.swift @@ -50,7 +50,7 @@ final class MainWindowFocusController { private(set) var intent: MainWindowKeyboardFocusIntent? { didSet { - syncBonsplitTabShortcutHintEligibility() + syncWorkspaceSplitTabShortcutHintEligibility() } } private var lastRightSidebarMode: RightSidebarMode? @@ -83,7 +83,7 @@ final class MainWindowFocusController { if lastRightSidebarMode == nil { lastRightSidebarMode = fileExplorerState?.mode } - syncBonsplitTabShortcutHintEligibility() + syncWorkspaceSplitTabShortcutHintEligibility() publishFeedFocusSnapshot() } @@ -146,7 +146,7 @@ final class MainWindowFocusController { } } - func allowsBonsplitTabShortcutHints(workspaceId: UUID) -> Bool { + func allowsWorkspaceSplitTabShortcutHints(workspaceId: UUID) -> Bool { guard tabManager?.selectedTabId == workspaceId else { return false } switch intent { case .rightSidebar: @@ -587,12 +587,12 @@ final class MainWindowFocusController { feedHost?.applyFocusSnapshotFromController(snapshot) } - func syncBonsplitTabShortcutHintEligibility() { + func syncWorkspaceSplitTabShortcutHintEligibility() { guard let tabManager else { return } for workspace in tabManager.tabs { - let enabled = allowsBonsplitTabShortcutHints(workspaceId: workspace.id) - if workspace.bonsplitController.tabShortcutHintsEnabled != enabled { - workspace.bonsplitController.tabShortcutHintsEnabled = enabled + let enabled = allowsWorkspaceSplitTabShortcutHints(workspaceId: workspace.id) + if workspace.splitController.tabShortcutHintsEnabled != enabled { + workspace.splitController.tabShortcutHintsEnabled = enabled } } } diff --git a/Sources/NotificationsPage.swift b/Sources/NotificationsPage.swift index 4ec4cc8843bd..007f26c5890f 100644 --- a/Sources/NotificationsPage.swift +++ b/Sources/NotificationsPage.swift @@ -1,4 +1,3 @@ -import Bonsplit import SwiftUI struct NotificationsPage: View { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index d2b3be6c3f84..c3f700710164 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2,7 +2,6 @@ import Foundation import Combine import WebKit import AppKit -import Bonsplit import Network import CFNetwork import SQLite3 @@ -858,32 +857,6 @@ func browserPreparedNavigationRequest(_ request: URLRequest) -> URLRequest { return preparedRequest } -/// Carries the request and one-shot HTTP bypass needed to seed a retargeted tab. -struct BrowserNewTabNavigationSeed { - let url: URL - let initialRequest: URLRequest - let bypassInsecureHTTPHostOnce: String? -} - -/// Preserves the original request metadata for a retargeted new-tab navigation. -func browserNewTabNavigationSeed( - from request: URLRequest, - bypassInsecureHTTPHostOnce: String? = nil -) -> BrowserNewTabNavigationSeed? { - guard let url = request.url else { return nil } - return BrowserNewTabNavigationSeed( - url: url, - initialRequest: request, - bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce - ) -} - -/// Mirrors the opener's WebKit browsing context for popup windows. -struct BrowserPopupBrowserContext { - let websiteDataStore: WKWebsiteDataStore - let processPool: WKProcessPool -} - func browserReadAccessURL(forLocalFileURL fileURL: URL, fileManager: FileManager = .default) -> URL? { guard fileURL.isFileURL, fileURL.path.hasPrefix("/") else { return nil } let path = fileURL.path @@ -1923,239 +1896,11 @@ final class BrowserPanel: Panel, ObservableObject { /// Incremented whenever we replace the underlying WKWebView after a process crash. @Published private(set) var webViewInstanceID: UUID = UUID() - /// Prevent the omnibar from auto-focusing for a short window after explicit programmatic focus. - /// This avoids races where SwiftUI focus state steals first responder back from WebKit. - private var suppressOmnibarAutofocusUntil: Date? + /// Prevent blank-tab omnibar autofocus while an explicit app-owned WebView focus is active. + /// This is state-based, not time-based, so it does not outlive the WebView first-responder handoff. + private var suppressOmnibarAutofocusWhileWebViewFocused = false - /// Prevent forcing web-view focus when another UI path requested omnibar focus. - /// Used to keep omnibar text-field focus from being immediately stolen by panel focus. - private var suppressWebViewFocusUntil: Date? - private var suppressWebViewFocusForAddressBar: Bool = false - private var addressBarFocusRestoreGeneration: UInt64 = 0 private let blankURLString = "about:blank" - private static let addressBarFocusCaptureScript = """ - (() => { - try { - const syncState = (state) => { - window.__cmuxAddressBarFocusState = state; - try { - if (window.top && window.top !== window) { - window.top.postMessage({ cmuxAddressBarFocusState: state }, "*"); - } else if (window.top) { - window.top.__cmuxAddressBarFocusState = state; - } - } catch (_) {} - }; - - const active = document.activeElement; - if (!active) { - syncState(null); - return "cleared:none"; - } - - const tag = (active.tagName || "").toLowerCase(); - const type = (active.type || "").toLowerCase(); - const isEditable = - !!active.isContentEditable || - tag === "textarea" || - (tag === "input" && type !== "hidden"); - if (!isEditable) { - syncState(null); - return "cleared:noneditable"; - } - - let id = active.getAttribute("data-cmux-addressbar-focus-id"); - if (!id) { - id = "cmux-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8); - active.setAttribute("data-cmux-addressbar-focus-id", id); - } - - const state = { id, selectionStart: null, selectionEnd: null }; - if (typeof active.selectionStart === "number" && typeof active.selectionEnd === "number") { - state.selectionStart = active.selectionStart; - state.selectionEnd = active.selectionEnd; - } - syncState(state); - return "captured:" + id; - } catch (_) { - return "error"; - } - })(); - """ - private static let addressBarFocusTrackingBootstrapScript = """ - (() => { - try { - if (window.__cmuxAddressBarFocusTrackerInstalled) return true; - window.__cmuxAddressBarFocusTrackerInstalled = true; - - const syncState = (state) => { - window.__cmuxAddressBarFocusState = state; - try { - if (window.top && window.top !== window) { - window.top.postMessage({ cmuxAddressBarFocusState: state }, "*"); - } else if (window.top) { - window.top.__cmuxAddressBarFocusState = state; - } - } catch (_) {} - }; - - if (window.top === window && !window.__cmuxAddressBarFocusMessageBridgeInstalled) { - window.__cmuxAddressBarFocusMessageBridgeInstalled = true; - window.addEventListener("message", (ev) => { - try { - const data = ev ? ev.data : null; - if (!data || !Object.prototype.hasOwnProperty.call(data, "cmuxAddressBarFocusState")) return; - window.__cmuxAddressBarFocusState = data.cmuxAddressBarFocusState || null; - } catch (_) {} - }, true); - } - - const isEditable = (el) => { - if (!el) return false; - const tag = (el.tagName || "").toLowerCase(); - const type = (el.type || "").toLowerCase(); - return !!el.isContentEditable || tag === "textarea" || (tag === "input" && type !== "hidden"); - }; - - const ensureFocusId = (el) => { - let id = el.getAttribute("data-cmux-addressbar-focus-id"); - if (!id) { - id = "cmux-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8); - el.setAttribute("data-cmux-addressbar-focus-id", id); - } - return id; - }; - - const snapshot = (el) => { - if (!isEditable(el)) { - syncState(null); - return; - } - const state = { - id: ensureFocusId(el), - selectionStart: null, - selectionEnd: null - }; - if (typeof el.selectionStart === "number" && typeof el.selectionEnd === "number") { - state.selectionStart = el.selectionStart; - state.selectionEnd = el.selectionEnd; - } - syncState(state); - }; - - document.addEventListener("focusin", (ev) => { - snapshot(ev && ev.target ? ev.target : document.activeElement); - }, true); - document.addEventListener("selectionchange", () => { - snapshot(document.activeElement); - }, true); - document.addEventListener("input", () => { - snapshot(document.activeElement); - }, true); - document.addEventListener("mousedown", (ev) => { - const target = ev && ev.target ? ev.target : null; - if (!isEditable(target)) { - syncState(null); - } - }, true); - window.addEventListener("beforeunload", () => { - syncState(null); - }, true); - - snapshot(document.activeElement); - return true; - } catch (_) { - return false; - } - })(); - """ - private static let addressBarFocusRestoreScript = """ - (() => { - try { - const readState = () => { - let state = window.__cmuxAddressBarFocusState; - try { - if ((!state || typeof state.id !== "string" || !state.id) && - window.top && window.top.__cmuxAddressBarFocusState) { - state = window.top.__cmuxAddressBarFocusState; - } - } catch (_) {} - return state; - }; - - const clearState = () => { - window.__cmuxAddressBarFocusState = null; - try { - if (window.top && window.top !== window) { - window.top.postMessage({ cmuxAddressBarFocusState: null }, "*"); - } else if (window.top) { - window.top.__cmuxAddressBarFocusState = null; - } - } catch (_) {} - }; - - const state = readState(); - if (!state || typeof state.id !== "string" || !state.id) { - return "no_state"; - } - - const selector = '[data-cmux-addressbar-focus-id="' + state.id + '"]'; - const findTarget = (doc) => { - if (!doc) return null; - const direct = doc.querySelector(selector); - if (direct && direct.isConnected) return direct; - const frames = doc.querySelectorAll("iframe,frame"); - for (let i = 0; i < frames.length; i += 1) { - const frame = frames[i]; - try { - const childDoc = frame.contentDocument; - if (!childDoc) continue; - const nested = findTarget(childDoc); - if (nested) return nested; - } catch (_) {} - } - return null; - }; - - const target = findTarget(document); - if (!target) { - clearState(); - return "missing_target"; - } - - try { - target.focus({ preventScroll: true }); - } catch (_) { - try { target.focus(); } catch (_) {} - } - - let focused = false; - try { - focused = - target === target.ownerDocument.activeElement || - (typeof target.matches === "function" && target.matches(":focus")); - } catch (_) {} - if (!focused) { - return "not_focused"; - } - - if ( - typeof state.selectionStart === "number" && - typeof state.selectionEnd === "number" && - typeof target.setSelectionRange === "function" - ) { - try { - target.setSelectionRange(state.selectionStart, state.selectionEnd); - } catch (_) {} - } - clearState(); - return "restored"; - } catch (_) { - return "error"; - } - })(); - """ - /// Published URL being displayed @Published private(set) var currentURL: URL? @@ -2199,21 +1944,542 @@ final class BrowserPanel: Panel, ObservableObject { /// Increment to request a UI-only flash highlight (e.g. from a keyboard shortcut). @Published private(set) var focusFlashToken: Int = 0 - /// Sticky omnibar-focus intent. This survives view mount timing races and is - /// cleared only after BrowserPanelView acknowledges handling it. - @Published private(set) var pendingAddressBarFocusRequestId: UUID? + private enum BrowserAddressBarSubfocusState: Equatable { + case pending(UUID) + case active + + var pendingRequestId: UUID? { + guard case .pending(let requestId) = self else { return nil } + return requestId + } + } + + private enum BrowserFindFieldSubfocusState: Equatable { + case pending(UUID) + case active + + var pendingRequestId: UUID? { + guard case .pending(let requestId) = self else { return nil } + return requestId + } + } + + private enum BrowserSubfocusState: Equatable { + case webView + case addressBar(BrowserAddressBarSubfocusState) + case findField(BrowserFindFieldSubfocusState) + + var intent: BrowserPanelFocusIntent { + switch self { + case .webView: + return .webView + case .addressBar: + return .addressBar + case .findField: + return .findField + } + } + + var pendingAddressBarFocusRequestId: UUID? { + guard case .addressBar(let state) = self else { return nil } + return state.pendingRequestId + } + + var pendingFindFieldFocusRequestId: UUID? { + guard case .findField(let state) = self else { return nil } + return state.pendingRequestId + } + } + + enum BrowserWebViewFocusSource { + case explicit + case passiveObservation + } + + private struct BrowserFindFocusCoordinator: Equatable { + struct FindTransaction: Equatable { + let requestId: UUID + let reason: String + let webViewInstanceID: UUID + var fieldMounted = false + var fieldFocused = false + var fieldEditingEnded = false + } + + struct WebContentRestoreTransaction: Equatable { + let requestId: UUID + let reason: String + let webViewInstanceID: UUID + let sourceFindRequestId: UUID? + let requiresFindOverlayTeardown: Bool + let requiresFindFieldEndEditing: Bool + var findOverlayTeardownObserved: Bool + var findFieldEndEditingObserved: Bool + } + + enum Phase: Equatable { + case idle + case focusing(FindTransaction) + case focused(FindTransaction) + case waitingForWebViewFocus(WebContentRestoreTransaction) + case restoring(WebContentRestoreTransaction) + } + + private(set) var phase: Phase = .idle + + var pendingFindFieldFocusRequestId: UUID? { + guard case .focusing(let transaction) = phase else { return nil } + return transaction.requestId + } + + var pendingWebContentRestoreRequestId: UUID? { + switch phase { + case .waitingForWebViewFocus(let transaction), .restoring(let transaction): + return transaction.requestId + case .idle, .focusing, .focused: + return nil + } + } + + var pendingWebContentRestore: WebContentRestoreTransaction? { + guard case .waitingForWebViewFocus(let transaction) = phase else { return nil } + return transaction + } + + @discardableResult + mutating func beginFindFocus(reason: String, webViewInstanceID: UUID) -> UUID { + let transaction = FindTransaction( + requestId: UUID(), + reason: reason, + webViewInstanceID: webViewInstanceID + ) + phase = .focusing(transaction) + return transaction.requestId + } + + mutating func noteFindFieldMounted(requestId: UUID) -> Bool { + switch phase { + case .focusing(var transaction) where transaction.requestId == requestId: + transaction.fieldMounted = true + phase = .focusing(transaction) + return true + case .focused(let transaction) where transaction.requestId == requestId: + return true + default: + return false + } + } + + mutating func noteFindFieldFocused(requestId: UUID?) -> Bool { + switch phase { + case .focusing(var transaction): + if let requestId, transaction.requestId != requestId { + return false + } + transaction.fieldMounted = true + transaction.fieldFocused = true + transaction.fieldEditingEnded = false + phase = .focused(transaction) + return true + case .focused(let transaction): + guard let requestId else { return true } + return transaction.requestId == requestId + case .idle: + return requestId == nil + case .waitingForWebViewFocus, .restoring: + return false + } + } + + func canApplyFindFieldFocusRequest(_ requestId: UUID) -> Bool { + pendingFindFieldFocusRequestId == requestId + } + + mutating func noteFindFieldEndedEditing(requestId: UUID?) -> Bool { + switch phase { + case .focusing(var transaction), .focused(var transaction): + if let requestId, transaction.requestId != requestId { + return false + } + transaction.fieldEditingEnded = true + phase = transaction.fieldFocused ? .focused(transaction) : .focusing(transaction) + return true + case .waitingForWebViewFocus(var transaction): + if let requestId, + let sourceFindRequestId = transaction.sourceFindRequestId, + sourceFindRequestId != requestId { + return false + } + transaction.findFieldEndEditingObserved = true + phase = .waitingForWebViewFocus(transaction) + return true + case .restoring(var transaction): + if let requestId, + let sourceFindRequestId = transaction.sourceFindRequestId, + sourceFindRequestId != requestId { + return false + } + transaction.findFieldEndEditingObserved = true + phase = .restoring(transaction) + return true + case .idle: + return requestId == nil + } + } + + mutating func beginFindDismiss( + reason: String, + shouldRestoreWebContent: Bool, + webViewInstanceID: UUID + ) -> WebContentRestoreTransaction? { + guard shouldRestoreWebContent else { + phase = .idle + return nil + } + let sourceFindRequestId: UUID? + let requiresFindOverlayTeardown: Bool + let requiresFindFieldEndEditing: Bool + switch phase { + case .focusing(let transaction), .focused(let transaction): + sourceFindRequestId = transaction.requestId + requiresFindOverlayTeardown = transaction.fieldMounted || transaction.fieldFocused + requiresFindFieldEndEditing = transaction.fieldFocused && !transaction.fieldEditingEnded + case .idle, .waitingForWebViewFocus, .restoring: + sourceFindRequestId = nil + requiresFindOverlayTeardown = false + requiresFindFieldEndEditing = false + } + let transaction = WebContentRestoreTransaction( + requestId: UUID(), + reason: reason, + webViewInstanceID: webViewInstanceID, + sourceFindRequestId: sourceFindRequestId, + requiresFindOverlayTeardown: requiresFindOverlayTeardown, + requiresFindFieldEndEditing: requiresFindFieldEndEditing, + findOverlayTeardownObserved: !requiresFindOverlayTeardown, + findFieldEndEditingObserved: !requiresFindFieldEndEditing + ) + phase = .waitingForWebViewFocus(transaction) + return transaction + } + + mutating func noteFindOverlayDisappeared() { + switch phase { + case .waitingForWebViewFocus(var transaction): + transaction.findOverlayTeardownObserved = true + phase = .waitingForWebViewFocus(transaction) + case .restoring(var transaction): + transaction.findOverlayTeardownObserved = true + phase = .restoring(transaction) + case .idle, .focusing, .focused: + break + } + } + + mutating func markWebContentRestoreApplying(_ transaction: WebContentRestoreTransaction) -> Bool { + guard case .waitingForWebViewFocus(let pending) = phase, + pending.requestId == transaction.requestId else { + return false + } + phase = .restoring(transaction) + return true + } + + mutating func completeWebContentRestore(_ transaction: WebContentRestoreTransaction) { + guard case .restoring(let active) = phase, + active.requestId == transaction.requestId else { + return + } + phase = .idle + } + + mutating func cancelFindFocus() { + switch phase { + case .focusing, .focused: + phase = .idle + case .idle, .waitingForWebViewFocus, .restoring: + break + } + } + + mutating func cancelWebContentRestore() { + switch phase { + case .waitingForWebViewFocus, .restoring: + phase = .idle + case .idle, .focusing, .focused: + break + } + } + } + + private struct BrowserFocusCoordinator: Equatable { + enum Phase: Equatable { + case paneInactive + case addressBar + case findField + case webContentSuppressed + case webContentWaitingForWindow + case webContentSuspendedWindow + case webContentActive + } + + private(set) var subfocusState: BrowserSubfocusState = .webView + private(set) var isPaneFocused = false + private(set) var isWebContentSuspendedByWindowResign = false + private(set) var isAddressBarSuppressingWebFocus = false + private(set) var suppressWebViewFocusUntil: Date? + + var pendingAddressBarFocusRequestId: UUID? { + subfocusState.pendingAddressBarFocusRequestId + } + + var preferredFocusIntent: BrowserPanelFocusIntent { + subfocusState.intent + } + + func phase(searchActive: Bool, hasWindow: Bool, windowIsKey: Bool) -> Phase { + guard isPaneFocused else { return .paneInactive } + switch subfocusState { + case .addressBar: + return .addressBar + case .findField: + return .findField + case .webView: + break + } + if shouldSuppressWebViewFocus(searchActive: searchActive) { + return .webContentSuppressed + } + guard hasWindow, windowIsKey else { return .webContentWaitingForWindow } + if isWebContentSuspendedByWindowResign { + return .webContentSuspendedWindow + } + return .webContentActive + } + + func shouldSuppressWebViewFocus(searchActive: Bool, now: Date = Date()) -> Bool { + if isAddressBarSuppressingWebFocus { + return true + } + if searchActive { + return true + } + if let suppressWebViewFocusUntil { + return now < suppressWebViewFocusUntil + } + return false + } + + func shouldAllowWebViewFirstResponder( + searchActive: Bool, + hasWindow: Bool, + windowIsKey: Bool, + now: Date = Date() + ) -> Bool { + guard isPaneFocused, + !shouldSuppressWebViewFocus(searchActive: searchActive, now: now), + !isWebContentSuspendedByWindowResign, + hasWindow, + windowIsKey else { + return false + } + return true + } + + func canApplyWebContentFocus( + searchActive: Bool, + windowIsKey: Bool, + now: Date = Date() + ) -> Bool { + windowIsKey && + !isWebContentSuspendedByWindowResign && + !shouldSuppressWebViewFocus(searchActive: searchActive, now: now) + } + + mutating func setSubfocusState(_ next: BrowserSubfocusState) { + subfocusState = next + } + + mutating func setPaneFocused(_ focused: Bool) { + isPaneFocused = focused + } + + mutating func setTemporaryWebViewFocusSuppression(for seconds: TimeInterval, now: Date = Date()) { + suppressWebViewFocusUntil = now.addingTimeInterval(seconds) + } + + mutating func clearTemporaryWebViewFocusSuppression() { + suppressWebViewFocusUntil = nil + } + + @discardableResult + mutating func beginAddressBarSuppression() -> Bool { + let didEnter = !isAddressBarSuppressingWebFocus + isAddressBarSuppressingWebFocus = true + return didEnter + } + + @discardableResult + mutating func endAddressBarSuppression() -> Bool { + let didExit = isAddressBarSuppressingWebFocus + isAddressBarSuppressingWebFocus = false + return didExit + } + + @discardableResult + mutating func suspendWebContentFocusForWindowResign(shouldSuspend: Bool) -> Bool { + guard shouldSuspend else { return false } + let didChange = !isWebContentSuspendedByWindowResign + isWebContentSuspendedByWindowResign = true + return didChange + } + + @discardableResult + mutating func resumeWebContentFocusIfWindowIsKey(windowIsKey: Bool) -> Bool { + guard windowIsKey, isWebContentSuspendedByWindowResign else { return false } + isWebContentSuspendedByWindowResign = false + return true + } + + mutating func clearWindowSuspension() { + isWebContentSuspendedByWindowResign = false + } + } + + @MainActor + private final class BrowserPaneController { + private var coordinator = BrowserFocusCoordinator() + private(set) var isAddressBarFocused = false + + var subfocusState: BrowserSubfocusState { + coordinator.subfocusState + } + + var isPaneFocused: Bool { + coordinator.isPaneFocused + } + + var isWebContentSuspendedByWindowResign: Bool { + coordinator.isWebContentSuspendedByWindowResign + } + + var pendingAddressBarFocusRequestId: UUID? { + coordinator.pendingAddressBarFocusRequestId + } + + var preferredFocusIntent: BrowserPanelFocusIntent { + coordinator.preferredFocusIntent + } + + func phase(searchActive: Bool, hasWindow: Bool, windowIsKey: Bool) -> BrowserFocusCoordinator.Phase { + coordinator.phase(searchActive: searchActive, hasWindow: hasWindow, windowIsKey: windowIsKey) + } + + func shouldSuppressWebViewFocus(searchActive: Bool, now: Date = Date()) -> Bool { + coordinator.shouldSuppressWebViewFocus(searchActive: searchActive, now: now) + } + + func shouldAllowWebViewFirstResponder( + searchActive: Bool, + hasWindow: Bool, + windowIsKey: Bool, + now: Date = Date() + ) -> Bool { + coordinator.shouldAllowWebViewFirstResponder( + searchActive: searchActive, + hasWindow: hasWindow, + windowIsKey: windowIsKey, + now: now + ) + } + + func canApplyWebContentFocus( + searchActive: Bool, + windowIsKey: Bool, + now: Date = Date() + ) -> Bool { + coordinator.canApplyWebContentFocus( + searchActive: searchActive, + windowIsKey: windowIsKey, + now: now + ) + } + + func setSubfocusState(_ next: BrowserSubfocusState) { + coordinator.setSubfocusState(next) + } + + func setPaneFocused(_ focused: Bool) { + coordinator.setPaneFocused(focused) + } + + func setTemporaryWebViewFocusSuppression(for seconds: TimeInterval) { + coordinator.setTemporaryWebViewFocusSuppression(for: seconds) + } + + func clearTemporaryWebViewFocusSuppression() { + coordinator.clearTemporaryWebViewFocusSuppression() + } + + @discardableResult + func beginAddressBarSuppression() -> Bool { + coordinator.beginAddressBarSuppression() + } + + @discardableResult + func endAddressBarSuppression() -> Bool { + coordinator.endAddressBarSuppression() + } + + @discardableResult + func suspendWebContentFocusForWindowResign(shouldSuspend: Bool) -> Bool { + coordinator.suspendWebContentFocusForWindowResign(shouldSuspend: shouldSuspend) + } + + @discardableResult + func resumeWebContentFocusIfWindowIsKey(windowIsKey: Bool) -> Bool { + coordinator.resumeWebContentFocusIfWindowIsKey(windowIsKey: windowIsKey) + } + + func clearWindowSuspension() { + coordinator.clearWindowSuspension() + } + + @discardableResult + func setAddressBarFocused(_ focused: Bool) -> Bool { + guard isAddressBarFocused != focused else { return false } + isAddressBarFocused = focused + return true + } + } + + /// Semantic in-panel focus target owned by BrowserPanel, not by overlay-local state. + @Published private var subfocusState: BrowserSubfocusState = .webView + @Published private(set) var isAddressBarFocused: Bool = false + private var findFocusCoordinator = BrowserFindFocusCoordinator() + private let paneController = BrowserPaneController() + + var pendingAddressBarFocusRequestId: UUID? { + paneController.pendingAddressBarFocusRequestId + } - /// Semantic in-panel focus target used by split switching and transient overlays. - private(set) var preferredFocusIntent: BrowserPanelFocusIntent = .webView + var pendingFindFieldFocusRequestId: UUID? { + findFocusCoordinator.pendingFindFieldFocusRequestId + } + + var preferredFocusIntent: BrowserPanelFocusIntent { + paneController.preferredFocusIntent + } - /// Incremented whenever async browser find focus ownership changes. - @Published private(set) var searchFocusRequestGeneration: UInt64 = 0 + var pendingWebContentRestoreRequestId: UUID? { + findFocusCoordinator.pendingWebContentRestoreRequestId + } /// Find-in-page state. Non-nil when the find bar is visible. @Published var searchState: BrowserSearchState? = nil { didSet { if let searchState { - preferredFocusIntent = .findField + refreshWebViewFirstResponderPolicy(reason: "searchState.created") NSLog("Find: browser search state created panel=%@", id.uuidString) searchNeedleCancellable = searchState.$needle .removeDuplicates() @@ -2234,9 +2500,9 @@ final class BrowserPanel: Panel, ObservableObject { } else if oldValue != nil { searchNeedleCancellable = nil if preferredFocusIntent == .findField { - preferredFocusIntent = .webView + updateSubfocusState(.webView) } - invalidateSearchFocusRequests(reason: "searchStateCleared") + refreshWebViewFirstResponderPolicy(reason: "searchState.cleared") NSLog("Find: browser search state cleared panel=%@", id.uuidString) executeFindClear() } @@ -2263,10 +2529,12 @@ final class BrowserPanel: Panel, ObservableObject { private var activePortalHostLease: PortalHostLease? private var pendingDistinctPortalHostReplacementPaneId: UUID? private var lockedPortalHost: PortalHostLock? + private var isPortalHostingSuspended: Bool = false private var webViewCancellables = Set() private var navigationDelegate: BrowserNavigationDelegate? private var uiDelegate: BrowserUIDelegate? private var downloadDelegate: BrowserDownloadDelegate? + private var webAuthnCoordinator: BrowserWebAuthnCoordinator? private var webViewObservers: [NSKeyValueObservation] = [] private var activeDownloadCount: Int = 0 @@ -2344,14 +2612,6 @@ final class BrowserPanel: Panel, ObservableObject { browserThemeMode } - /// Popups inherit this panel's exact WebKit storage and process context. - var popupBrowserContext: BrowserPopupBrowserContext { - BrowserPopupBrowserContext( - websiteDataStore: websiteDataStore, - processPool: webView.configuration.processPool - ) - } - private static let portalHostAreaThreshold: CGFloat = 4 private static let portalHostReplacementAreaGainRatio: CGFloat = 1.2 @@ -2372,25 +2632,60 @@ final class BrowserPanel: Panel, ObservableObject { lockedPortalHost = nil } #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.host.rearm panel=\(id.uuidString.prefix(5)) " + "reason=\(reason) pane=\(paneId.id.uuidString.prefix(5))" ) #endif } - func claimPortalHost( - hostId: ObjectIdentifier, - paneId: PaneID, - inWindow: Bool, - bounds: CGRect, - reason: String - ) -> Bool { - if shouldUseLocalInlineDeveloperToolsHosting() { + func suspendPortalHosting(reason: String) { + activePortalHostLease = nil + pendingDistinctPortalHostReplacementPaneId = nil + lockedPortalHost = nil + isPortalHostingSuspended = true +#if DEBUG + let line = + "browser.portal.host.suspend panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason)" + dlog(line) +#endif + } + + func resumePortalHosting(reason: String) { + guard isPortalHostingSuspended else { return } + isPortalHostingSuspended = false +#if DEBUG + let line = + "browser.portal.host.resume panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason)" + dlog(line) +#endif + } + + func claimPortalHost( + hostId: ObjectIdentifier, + paneId: PaneID, + inWindow: Bool, + bounds: CGRect, + reason: String + ) -> Bool { + if isPortalHostingSuspended { +#if DEBUG + let line = + "browser.portal.host.skip panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason).suspended host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " + + "inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height))" + dlog(line) +#endif + return false + } + + if shouldUseLocalInlineDeveloperToolsHosting() { activePortalHostLease = nil lockedPortalHost = nil #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.host.skip panel=\(id.uuidString.prefix(5)) " + "reason=\(reason).localInlineDevTools host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " + "inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height))" @@ -2426,7 +2721,7 @@ final class BrowserPanel: Panel, ObservableObject { inWindow if shouldForceDistinctReplacement { #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " + "reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " + "inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + @@ -2461,7 +2756,7 @@ final class BrowserPanel: Panel, ObservableObject { lockedPortalHost = nil } #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " + "reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " + "inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + @@ -2474,7 +2769,7 @@ final class BrowserPanel: Panel, ObservableObject { } #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.host.skip panel=\(id.uuidString.prefix(5)) " + "reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " + "inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + @@ -2488,7 +2783,7 @@ final class BrowserPanel: Panel, ObservableObject { activePortalHostLease = next #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " + "reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " + "inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + @@ -2506,7 +2801,7 @@ final class BrowserPanel: Panel, ObservableObject { lockedPortalHost = nil } #if DEBUG - cmuxDebugLog( + dlog( "browser.portal.host.release panel=\(id.uuidString.prefix(5)) " + "reason=\(reason) host=\(hostId) pane=\(current.paneId.uuidString.prefix(5)) " + "inWin=\(current.inWindow ? 1 : 0) area=\(String(format: "%.1f", current.area))" @@ -2575,23 +2870,15 @@ final class BrowserPanel: Panel, ObservableObject { forMainFrameOnly: true ) ) - // Track the last editable focused element continuously so omnibar exit can - // restore page input focus even if capture runs after first-responder handoff. - // Main frame only — same CAPTCHA interference concern as telemetry hooks. - configuration.userContentController.addUserScript( - WKUserScript( - source: Self.addressBarFocusTrackingBootstrapScript, - injectionTime: .atDocumentStart, - forMainFrameOnly: true - ) - ) - // Keep a native cache of whether the focused page element can currently accept - // plain-text paste so Cmd+Shift+V is only consumed when the browser can use it. + // WebAuthn can originate from same-origin child frames. The native + // bridge rejects first-time authorization requests from cross-origin + // subframes before touching the shared browser authorization state. configuration.userContentController.addUserScript( WKUserScript( - source: CmuxWebView.pasteAsPlainTextFocusTrackingBootstrapScriptSource, + source: BrowserWebAuthnBridgeContract.scriptSource, injectionTime: .atDocumentStart, - forMainFrameOnly: true + forMainFrameOnly: false, + in: .page ) ) } @@ -2612,6 +2899,9 @@ final class BrowserPanel: Panel, ObservableObject { webView.uiDelegate = uiDelegate setupObservers(for: webView) setupReactGrabMessageHandler(for: webView) + let webAuthnCoordinator = BrowserWebAuthnCoordinator() + webAuthnCoordinator.install(on: webView) + self.webAuthnCoordinator = webAuthnCoordinator } private func configureNavigationDelegateCallbacks() { @@ -2619,6 +2909,11 @@ final class BrowserPanel: Panel, ObservableObject { let boundWebViewInstanceID = webViewInstanceID let boundHistoryStore = historyStore + navigationDelegate.didStartNavigation = { [weak self] webView in + Task { @MainActor [weak self] in + guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return } + } + } navigationDelegate.didFinish = { [weak self] webView in Task { @MainActor [weak self] in guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return } @@ -2627,6 +2922,7 @@ final class BrowserPanel: Panel, ObservableObject { self.refreshFavicon(from: webView) // Keep find-in-page open through load completion and refresh matches for the new DOM. self.restoreFindStateAfterNavigation(replaySearch: true) + self.reassertFocusedWebContentAfterNavigationFinish(webView, reason: "navigation.didFinish") } } navigationDelegate.didFailNavigation = { [weak self] failedWebView, failedURL in @@ -2653,7 +2949,6 @@ final class BrowserPanel: Panel, ObservableObject { workspaceId: UUID, profileID: UUID? = nil, initialURL: URL? = nil, - initialRequest: URLRequest? = nil, bypassInsecureHTTPHostOnce: String? = nil, proxyEndpoint: BrowserProxyEndpoint? = nil, isRemoteWorkspace: Bool = false, @@ -2689,9 +2984,6 @@ final class BrowserPanel: Panel, ObservableObject { navDelegate.openInNewTab = { [weak self] url in self?.openLinkInNewTab(url: url) } - navDelegate.requestNavigation = { [weak self] request, intent in - self?.requestNavigation(request, intent: intent) - } navDelegate.shouldBlockInsecureHTTPNavigation = { [weak self] url in self?.shouldBlockInsecureHTTPNavigation(to: url) ?? false } @@ -2778,23 +3070,8 @@ final class BrowserPanel: Panel, ObservableObject { self?.webView.window ?? NSApp.keyWindow ?? NSApp.mainWindow } - if let initialRequest { - shouldRenderWebView = true - if let url = initialRequest.url, - insecureHTTPBypassHostOnce == nil, - shouldBlockInsecureHTTPNavigation(to: url) { - presentInsecureHTTPAlert( - for: initialRequest, - intent: .currentTab, - recordTypedNavigation: false - ) - } else { - navigateWithoutInsecureHTTPPrompt( - request: initialRequest, - recordTypedNavigation: false - ) - } - } else if let url = initialURL { + // Navigate to initial URL if provided + if let url = initialURL { shouldRenderWebView = true navigate(to: url) } @@ -2910,7 +3187,7 @@ final class BrowserPanel: Panel, ObservableObject { let desiredZoom = max(minPageZoom, min(maxPageZoom, previousWebView.pageZoom)) let restoreDeveloperTools = preferredDeveloperToolsVisible || isDeveloperToolsVisible() - invalidateSearchFocusRequests(reason: "profileSwitch") + updateSubfocusState(.webView) searchState = nil _ = hideDeveloperTools() @@ -2922,6 +3199,8 @@ final class BrowserPanel: Panel, ObservableObject { faviconTask = nil faviconRefreshGeneration &+= 1 BrowserWindowPortalRegistry.detach(webView: previousWebView) + webAuthnCoordinator?.uninstall(from: previousWebView) + webAuthnCoordinator = nil previousWebView.stopLoading() previousWebView.navigationDelegate = nil previousWebView.uiDelegate = nil @@ -3081,7 +3360,7 @@ final class BrowserPanel: Panel, ObservableObject { guard !restoredForwardHistoryStack.isEmpty else { return } #if DEBUG - cmuxDebugLog( + dlog( "browser.history.restore.forward.clear panel=\(id.uuidString.prefix(5)) " + "current=\(liveCurrentString)" ) @@ -3213,7 +3492,7 @@ final class BrowserPanel: Panel, ObservableObject { reason: "fullscreenStateChanged" ) #if DEBUG - cmuxDebugLog( + dlog( "browser.fullscreen.state panel=\(self.id.uuidString.prefix(5)) " + "web=\(ObjectIdentifier(webView)) state=\(String(describing: fullscreenState)) " + "active=\(isElementFullscreenActive ? 1 : 0)" @@ -3256,7 +3535,7 @@ final class BrowserPanel: Panel, ObservableObject { let restoreDevTools = preferredDeveloperToolsVisible #if DEBUG - cmuxDebugLog( + dlog( "browser.webview.replace.begin panel=\(id.uuidString.prefix(5)) " + "reason=\(reason) " + "renderable=\(wasRenderable ? 1 : 0) restoreURL=\(restoreURLString ?? "nil") " + @@ -3271,6 +3550,8 @@ final class BrowserPanel: Panel, ObservableObject { faviconTask = nil faviconRefreshGeneration &+= 1 BrowserWindowPortalRegistry.detach(webView: oldWebView) + webAuthnCoordinator?.uninstall(from: oldWebView) + webAuthnCoordinator = nil oldWebView.stopLoading() oldWebView.navigationDelegate = nil oldWebView.uiDelegate = nil @@ -3313,7 +3594,7 @@ final class BrowserPanel: Panel, ObservableObject { } #if DEBUG - cmuxDebugLog( + dlog( "browser.webview.replace.end panel=\(id.uuidString.prefix(5)) " + "reason=\(reason) " + "instance=\(webViewInstanceID.uuidString.prefix(6)) " + @@ -3332,10 +3613,15 @@ final class BrowserPanel: Panel, ObservableObject { func focus() { if shouldSuppressWebViewFocus() { + refreshWebViewFirstResponderPolicy(reason: "focus.suppressed") return } guard let window = webView.window, !webView.isHiddenOrHasHiddenAncestor else { return } + paneController.setPaneFocused(true) + resumeWebContentFocusIfWindowIsKey(window, reason: "focus") + allowWebViewFirstResponder(reason: "focus") + guard canApplyWebContentFocus(in: window, reason: "focus") else { return } // If nothing meaningful is loaded yet, prefer letting the omnibar take focus. if !webView.isLoading { @@ -3344,12 +3630,12 @@ final class BrowserPanel: Panel, ObservableObject { return } } - - if Self.responderChainContains(window.firstResponder, target: webView) { - noteWebViewFocused() - return - } - if window.makeFirstResponder(webView) { + let focusResult = acquireWebViewFirstResponder( + in: window, + reason: "focus", + forceWrapperReactivation: true + ) + if focusResult.didFocusResponder { noteWebViewFocused() } } @@ -3358,49 +3644,441 @@ final class BrowserPanel: Panel, ObservableObject { func requestExplicitWebViewFocus() -> Bool { // Programmatic WebView focus should win over stale omnibar focus state, especially // after workspace switches where the blank-page omnibar auto-focus can re-trigger. - endSuppressWebViewFocusForAddressBar() + clearAddressBarFocus(reason: "explicitWebViewFocus") clearWebViewFocusSuppression() - NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: id) guard let window = webView.window, !webView.isHiddenOrHasHiddenAncestor else { return false } + paneController.setPaneFocused(true) + resumeWebContentFocusIfWindowIsKey(window, reason: "explicitWebViewFocus") + allowWebViewFirstResponder(reason: "explicitWebViewFocus") + guard canApplyWebContentFocus(in: window, reason: "explicitWebViewFocus") else { return false } + + yieldOwnedBrowserTextResponderIfNeeded(in: window) + + let focusResult = acquireWebViewFirstResponder( + in: window, + reason: "explicitWebViewFocus", + forceWrapperReactivation: true + ) + guard focusResult.didFocusResponder else { return false } + suppressOmnibarAutofocusForExplicitWebViewFocus(reason: "explicitFocus") + if focusResult.didFocusResponder { + noteWebViewFocused() + } + + return true + } + + @discardableResult + private func acquireWebViewFirstResponder( + in window: NSWindow, + reason: String, + forceWrapperReactivation: Bool = false + ) -> CmuxWebContentFirstResponderResult { + acquireWebViewFirstResponder( + webView, + in: window, + reason: reason, + forceWrapperReactivation: forceWrapperReactivation + ) + } + + @discardableResult + private func acquireWebViewFirstResponder( + _ targetWebView: WKWebView, + in window: NSWindow, + reason: String, + forceWrapperReactivation: Bool = false + ) -> CmuxWebContentFirstResponderResult { + let result: CmuxWebContentFirstResponderResult + if let cmuxWebView = targetWebView as? CmuxWebView { + if forceWrapperReactivation { + result = cmuxWebView.reassertWebContentFirstResponder( + in: window, + reason: reason, + forceWrapperReactivation: true + ) + } else { + result = cmuxWebView.requestWebContentFirstResponder(in: window) + } + } else { + if forceWrapperReactivation { + _ = window.makeFirstResponder(nil) + } + result = window.makeFirstResponder(targetWebView) ? .wrapperOnly : .failed + } +#if DEBUG + dlog( + "browser.focus.acquireWebContent.panel panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) force=\(forceWrapperReactivation ? 1 : 0) " + + "result=\(result.debugDescription) " + + "focused=\(result.didFocusResponder ? 1 : 0) " + + "content=\(result.didFocusContent ? 1 : 0) " + + "fr=\(window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" + ) +#endif + return result + } - if Self.responderChainContains(window.firstResponder, target: webView) { - // Prevent omnibar auto-focus from immediately stealing first responder back. - suppressOmnibarAutofocus(for: 1.5) + @discardableResult + func requestMountedContentWebViewFocus(in window: NSWindow, reason: String) -> Bool { + guard webView.window === window, !webView.isHiddenOrHasHiddenAncestor else { return false } + paneController.setPaneFocused(true) + resumeWebContentFocusIfWindowIsKey(window, reason: "mounted.\(reason)") + allowWebViewFirstResponder(reason: "mounted.\(reason)") + guard canApplyWebContentFocus(in: window, reason: "mounted.\(reason)") else { return false } + let focusResult = acquireWebViewFirstResponder( + in: window, + reason: "mounted.\(reason)", + forceWrapperReactivation: true + ) + if focusResult.didFocusResponder { + suppressOmnibarAutofocusForExplicitWebViewFocus(reason: reason) + } + if focusResult.didFocusResponder { noteWebViewFocused() + } + return focusResult.didFocusResponder + } + + @discardableResult + func noteMountedWebViewHostDidMoveToWindow( + _ window: NSWindow, + shouldFocusWebView: Bool, + isPanelFocused: Bool, + reason: String + ) -> Bool { + refreshWebViewFirstResponderPolicy(reason: "mountedHost.\(reason)") + guard shouldFocusWebView, isPanelFocused else { +#if DEBUG + dlog( + "browser.focus.mountedHost.skip panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) shouldFocus=\(shouldFocusWebView ? 1 : 0) " + + "panelFocused=\(isPanelFocused ? 1 : 0)" + ) +#endif + return false + } + let focused = requestMountedContentWebViewFocus( + in: window, + reason: "mountedHost.\(reason)" + ) +#if DEBUG + dlog( + "browser.focus.mountedHost panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) result=\(focused ? 1 : 0) " + + "fr=\(window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" + ) +#endif + return focused + } + + func noteOwningWindowDidResignKey(_ window: NSWindow, reason: String) { + guard webView.window === window else { return } + if preferredFocusIntentForActivation() == .browser(.webView) || + hasWebViewFirstResponder(in: window) { + paneController.suspendWebContentFocusForWindowResign(shouldSuspend: true) + } + refreshWebViewFirstResponderPolicy(reason: "windowResign.\(reason)") +#if DEBUG + dlog( + "browser.focus.windowKey.suspend panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) suspended=\(paneController.isWebContentSuspendedByWindowResign ? 1 : 0) " + + "intent=\(String(describing: preferredFocusIntentForActivation())) " + + "fr=\(window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" + ) +#endif + } + + @discardableResult + func noteOwningWindowDidBecomeKey(_ window: NSWindow, reason: String) -> Bool { + guard webView.window === window else { return false } + paneController.clearWindowSuspension() + refreshWebViewFirstResponderPolicy(reason: "windowKey.\(reason)") + if drivePendingWebContentRestoreIfPossible(trigger: "windowKey.\(reason)") { return true } + return reassertWebContentFocusAfterWindowActivation(in: window, reason: reason) + } + + @discardableResult + func reassertWebContentFocusAfterWindowActivation(in window: NSWindow, reason: String) -> Bool { + guard webView.window === window, + window.isKeyWindow, + !webView.isHiddenOrHasHiddenAncestor, + !shouldSuppressWebViewFocus() else { +#if DEBUG + dlog( + "browser.focus.windowKey.reassert.skip panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) cause=ineligible " + + "webWin=\(webView.window?.windowNumber ?? -1) key=\(window.isKeyWindow ? 1 : 0) " + + "hidden=\(webView.isHiddenOrHasHiddenAncestor ? 1 : 0) " + + "suppress=\(shouldSuppressWebViewFocus() ? 1 : 0)" + ) +#endif + return false + } + + guard preferredFocusIntentForActivation() == .browser(.webView) else { +#if DEBUG + dlog( + "browser.focus.windowKey.reassert.skip panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) cause=non_web_intent " + + "intent=\(String(describing: preferredFocusIntentForActivation()))" + ) +#endif + return false + } + + paneController.setPaneFocused(true) + paneController.clearWindowSuspension() + allowWebViewFirstResponder(reason: "windowKey.\(reason)") + let focusResult: CmuxWebContentFirstResponderResult + if let cmuxWebView = webView as? CmuxWebView { + focusResult = cmuxWebView.reassertWebContentFirstResponder( + in: window, + reason: reason, + forceWrapperReactivation: true + ) + } else if window.firstResponder === webView { + _ = window.makeFirstResponder(nil) + focusResult = window.makeFirstResponder(webView) ? .wrapperOnly : .failed + } else { + focusResult = window.makeFirstResponder(webView) ? .wrapperOnly : .failed + } + if focusResult.didFocusResponder { + noteWebViewFocused() + } +#if DEBUG + dlog( + "browser.focus.windowKey.reassert panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) result=\(focusResult.debugDescription) " + + "focused=\(focusResult.didFocusResponder ? 1 : 0) " + + "content=\(focusResult.didFocusContent ? 1 : 0) " + + "fr=\(window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" + ) +#endif + return focusResult.didFocusResponder + } + + @discardableResult + func repairWebContentFocusForKeyboardInput( + in window: NSWindow, + reason: String, + repairFocusedWrapper: Bool = false + ) -> Bool { + guard webView.window === window, + !webView.isHiddenOrHasHiddenAncestor, + !shouldSuppressWebViewFocus() else { +#if DEBUG + dlog( + "browser.focus.keyRepair.skip panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) cause=ineligible " + + "webWin=\(webView.window?.windowNumber ?? -1) " + + "key=\(window.isKeyWindow ? 1 : 0) " + + "hidden=\(webView.isHiddenOrHasHiddenAncestor ? 1 : 0) " + + "suppress=\(shouldSuppressWebViewFocus() ? 1 : 0)" + ) +#endif + return false + } + + guard preferredFocusIntentForActivation() == .browser(.webView) else { +#if DEBUG + dlog( + "browser.focus.keyRepair.skip panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) cause=non_web_intent " + + "intent=\(String(describing: preferredFocusIntentForActivation()))" + ) +#endif + return false + } + + switch actualFocus(in: window) { + case let .browserWebViewWrapper(panelId) where panelId == id: +#if DEBUG + dlog( + "browser.focus.keyRepair.wrapper panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) action=already_owned " + + "fr=\(window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" + ) +#endif + return false + case let .browserAddressBar(panelId) where panelId == id: + return false + case let .browserFindField(panelId) where panelId == id: + return false + default: + break + } + + paneController.setPaneFocused(true) + paneController.clearWindowSuspension() + setWebViewFirstResponderPolicy(true, reason: "keyRepair.\(reason)") + let focusResult = acquireWebViewFirstResponder( + in: window, + reason: "keyRepair.\(reason)", + forceWrapperReactivation: true + ) + if focusResult.didFocusResponder { + noteWebViewFocused() + } + if !window.isKeyWindow { + refreshWebViewFirstResponderPolicy(reason: "keyRepair.\(reason).restoreInactivePolicy") + } +#if DEBUG + dlog( + "browser.focus.keyRepair panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) result=\(focusResult.debugDescription) " + + "focused=\(focusResult.didFocusResponder ? 1 : 0) " + + "content=\(focusResult.didFocusContent ? 1 : 0) " + + "fr=\(window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" + ) +#endif + return focusResult.didFocusResponder + } + + @discardableResult + func reassertFocusedWebContentAfterNavigationFinish( + _ finishedWebView: WKWebView, + reason: String + ) -> Bool { + guard finishedWebView === webView, + let window = webView.window, + window.isKeyWindow, + paneController.isPaneFocused, + preferredFocusIntentForActivation() == .browser(.webView), + !shouldSuppressWebViewFocus(), + !webView.isHiddenOrHasHiddenAncestor else { +#if DEBUG + dlog( + "browser.focus.navigation.reassert.skip panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) " + + "sameWeb=\(finishedWebView === webView ? 1 : 0) " + + "win=\(webView.window?.windowNumber ?? -1) " + + "key=\(webView.window?.isKeyWindow == true ? 1 : 0) " + + "paneFocused=\(paneController.isPaneFocused ? 1 : 0) " + + "intent=\(String(describing: preferredFocusIntentForActivation())) " + + "suppress=\(shouldSuppressWebViewFocus() ? 1 : 0) " + + "hidden=\(webView.isHiddenOrHasHiddenAncestor ? 1 : 0)" + ) +#endif + return false + } + + paneController.setPaneFocused(true) + paneController.clearWindowSuspension() + allowWebViewFirstResponder(reason: reason) + let focusResult: CmuxWebContentFirstResponderResult + if let cmuxWebView = webView as? CmuxWebView { + focusResult = cmuxWebView.reassertWebContentFirstResponder( + in: window, + reason: reason, + forceWrapperReactivation: true + ) + } else { + _ = window.makeFirstResponder(nil) + focusResult = window.makeFirstResponder(webView) ? .wrapperOnly : .failed + } + if focusResult.didFocusResponder { + noteWebViewFocused() + } +#if DEBUG + dlog( + "browser.focus.navigation.reassert panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) result=\(focusResult.debugDescription) " + + "focused=\(focusResult.didFocusResponder ? 1 : 0) " + + "content=\(focusResult.didFocusContent ? 1 : 0) " + + "fr=\(window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" + ) +#endif + return focusResult.didFocusResponder + } + + func hasWebViewFirstResponder(in window: NSWindow) -> Bool { + return Self.responderChainContains(window.firstResponder, target: webView) + } + + func hasWebContentFirstResponder(in window: NSWindow) -> Bool { + guard let responder = window.firstResponder else { return false } + return responder !== webView && Self.responderChainContains(responder, target: webView) + } - guard window.makeFirstResponder(webView) else { return false } - // Prevent omnibar auto-focus from immediately stealing first responder back. - suppressOmnibarAutofocus(for: 1.5) - noteWebViewFocused() + func actualFocus(in window: NSWindow?) -> WorkspaceFocusActual { + if AppDelegate.shared?.focusedBrowserAddressBarPanelId() == id { + return .browserAddressBar(id) + } - DispatchQueue.main.async { [weak self, weak window, weak webView] in - guard let self, let window, let webView else { return } - guard webView.window === window else { return } - if !Self.responderChainContains(window.firstResponder, target: webView), - window.makeFirstResponder(webView) { - self.suppressOmnibarAutofocus(for: 1.5) - self.noteWebViewFocused() + if let responder = window?.firstResponder { + if browserSearchOverlayPanelId(for: responder) == id { + return .browserFindField(id) + } + if let window, + BrowserWindowPortalRegistry.searchOverlayPanelId(for: responder, in: window) == id { + return .browserFindField(id) } } - return true + guard let window else { return .none } + if hasWebContentFirstResponder(in: window) { + return .browserWebContent(id) + } + if hasWebViewFirstResponder(in: window) { + return .browserWebViewWrapper(id) + } + return .none } - func unfocus() { - invalidateSearchFocusRequests(reason: "panelUnfocus") - guard let window = webView.window else { return } - if Self.responderChainContains(window.firstResponder, target: webView) { - window.makeFirstResponder(nil) + private func yieldOwnedBrowserTextResponderIfNeeded(in window: NSWindow) { + if yieldFocusIntent(.browser(.findField), in: window) { + return } + if yieldFocusIntent(.browser(.addressBar), in: window) { + return + } + if let editor = window.firstResponder as? NSTextView, editor.isFieldEditor { + _ = window.makeFirstResponder(nil) + } + } + + func unfocus() { + paneController.setPaneFocused(false) + setWebViewFirstResponderPolicy(false, reason: "unfocus") + guard let window = webView.window, + Self.responderChainContains(window.firstResponder, target: webView) else { return } +#if DEBUG + dlog( + "browser.focus.unfocus.preserveWebResponder panel=\(id.uuidString.prefix(5)) " + + "fr=\(window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" + ) +#endif + } + + @discardableResult + func resignWebViewFirstResponderIfOwned(reason: String) -> Bool { + guard let window = webView.window, + Self.responderChainContains(window.firstResponder, target: webView) else { return false } + let result = window.makeFirstResponder(nil) +#if DEBUG + dlog( + "browser.focus.resignWebResponder panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) result=\(result ? 1 : 0) " + + "fr=\(window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" + ) +#endif + return result } func close() { // Ensure we don't keep a hidden WKWebView (or its content view) as first responder while - // bonsplit/SwiftUI reshuffles views during close. + // WorkspaceSplit/SwiftUI reshuffles views during close. unfocus() + resignWebViewFirstResponderIfOwned(reason: "close") + setBrowserPortalVisibility( + visibleInUI: false, + zPriority: 0, + source: "browserPanelClose" + ) // Snapshot first: popup close unregisters itself from popupControllers. let popupsToClose = popupControllers @@ -3412,6 +4090,8 @@ final class BrowserPanel: Panel, ObservableObject { popup.closePopup() } + webAuthnCoordinator?.uninstall(from: webView) + webAuthnCoordinator = nil webView.stopLoading() webView.navigationDelegate = nil webView.uiDelegate = nil @@ -3432,7 +4112,6 @@ final class BrowserPanel: Panel, ObservableObject { let controller = BrowserPopupWindowController( configuration: configuration, windowFeatures: windowFeatures, - browserContext: popupBrowserContext, openerPanel: self ) popupControllers.append(controller) @@ -3458,7 +4137,7 @@ final class BrowserPanel: Panel, ObservableObject { guard self.isCurrentWebView(webView, instanceID: refreshWebViewInstanceID) else { return } guard self.isCurrentFaviconRefresh(generation: refreshGeneration) else { return } #if DEBUG - cmuxDebugLog( + dlog( "browser.favicon.begin " + "panel=\(id.uuidString.prefix(5)) " + "page=\(pageURL.absoluteString)" @@ -3529,7 +4208,7 @@ final class BrowserPanel: Panel, ObservableObject { let iconURL = discoveredURL ?? fallbackURL guard let iconURL else { return } #if DEBUG - cmuxDebugLog( + dlog( "browser.favicon.iconURL " + "panel=\(id.uuidString.prefix(5)) " + "discovered=\(discoveredURL?.absoluteString ?? "") " + @@ -3542,7 +4221,7 @@ final class BrowserPanel: Panel, ObservableObject { let iconURLString = iconURL.absoluteString if iconURLString == lastFaviconURLString, faviconPNGData != nil { #if DEBUG - cmuxDebugLog( + dlog( "browser.favicon.skipCached " + "panel=\(id.uuidString.prefix(5)) " + "icon=\(iconURLString)" @@ -3565,7 +4244,7 @@ final class BrowserPanel: Panel, ObservableObject { defer { remoteSession?.finishTasksAndInvalidate() } if let remoteSession { #if DEBUG - cmuxDebugLog( + dlog( "browser.favicon.fetch " + "panel=\(id.uuidString.prefix(5)) " + "via=proxy " + @@ -3575,7 +4254,7 @@ final class BrowserPanel: Panel, ObservableObject { (data, response) = try await remoteSession.data(for: effectiveRequest) } else { #if DEBUG - cmuxDebugLog( + dlog( "browser.favicon.fetch " + "panel=\(id.uuidString.prefix(5)) " + "via=direct " + @@ -3586,7 +4265,7 @@ final class BrowserPanel: Panel, ObservableObject { } } catch { #if DEBUG - cmuxDebugLog( + dlog( "browser.favicon.fetchError " + "panel=\(id.uuidString.prefix(5)) " + "error=\(String(describing: error))" @@ -3601,7 +4280,7 @@ final class BrowserPanel: Panel, ObservableObject { (200..<300).contains(http.statusCode) else { #if DEBUG let status = (response as? HTTPURLResponse)?.statusCode ?? -1 - cmuxDebugLog( + dlog( "browser.favicon.badResponse " + "panel=\(id.uuidString.prefix(5)) " + "status=\(status)" @@ -3610,7 +4289,7 @@ final class BrowserPanel: Panel, ObservableObject { return } #if DEBUG - cmuxDebugLog( + dlog( "browser.favicon.response " + "panel=\(id.uuidString.prefix(5)) " + "status=\(http.statusCode) " + @@ -3621,7 +4300,7 @@ final class BrowserPanel: Panel, ObservableObject { // Use >= 2x the rendered point size so we don't upscale (blurry) on Retina. guard let png = Self.makeFaviconPNGData(from: data, targetPx: 32) else { #if DEBUG - cmuxDebugLog( + dlog( "browser.favicon.decodeFailed " + "panel=\(id.uuidString.prefix(5)) " + "bytes=\(data.count)" @@ -3632,7 +4311,7 @@ final class BrowserPanel: Panel, ObservableObject { // Only update if we got a real icon; keep the old one otherwise to avoid flashes. faviconPNGData = png #if DEBUG - cmuxDebugLog( + dlog( "browser.favicon.ready " + "panel=\(id.uuidString.prefix(5)) " + "pngBytes=\(png.count)" @@ -3867,7 +4546,7 @@ final class BrowserPanel: Panel, ObservableObject { var rewrittenRequest = request rewrittenRequest.url = rewrittenURL #if DEBUG - cmuxDebugLog( + dlog( "browser.remoteProxy.\(logScope) " + "panel=\(id.uuidString.prefix(5)) " + "from=\(url.absoluteString) " + @@ -3936,17 +4615,12 @@ final class BrowserPanel: Panel, ObservableObject { } private func shouldBlockInsecureHTTPNavigation(to url: URL) -> Bool { - if consumeOneTimeInsecureHTTPBypassIfNeeded(for: url) { + if browserShouldConsumeOneTimeInsecureHTTPBypass(url, bypassHostOnce: &insecureHTTPBypassHostOnce) { return false } return browserShouldBlockInsecureHTTPURL(url) } - @discardableResult - private func consumeOneTimeInsecureHTTPBypassIfNeeded(for url: URL) -> Bool { - browserShouldConsumeOneTimeInsecureHTTPBypass(url, bypassHostOnce: &insecureHTTPBypassHostOnce) - } - private func requestNavigation(_ request: URLRequest, intent: BrowserInsecureHTTPNavigationIntent) { guard let url = request.url else { return } if shouldBlockInsecureHTTPNavigation(to: url) { @@ -3957,7 +4631,7 @@ final class BrowserPanel: Panel, ObservableObject { case .currentTab: navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: false) case .newTab: - openLinkInNewTab(request: request) + openLinkInNewTab(url: url) } } @@ -4023,7 +4697,7 @@ final class BrowserPanel: Panel, ObservableObject { insecureHTTPBypassHostOnce = host navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation) case .newTab: - openLinkInNewTab(request: request, bypassInsecureHTTPHostOnce: host) + openLinkInNewTab(url: url, bypassInsecureHTTPHostOnce: host) } default: return @@ -4042,8 +4716,11 @@ final class BrowserPanel: Panel, ObservableObject { } webViewObservers.removeAll() webViewCancellables.removeAll() + let webAuthnCoordinator = webAuthnCoordinator + self.webAuthnCoordinator = nil let webView = webView Task { @MainActor in + webAuthnCoordinator?.uninstall(from: webView) BrowserWindowPortalRegistry.detach(webView: webView) } } @@ -4072,7 +4749,7 @@ extension BrowserPanel { func resetForWorkspaceContextChange(reason: String) { guard needsWorkspaceContextReset else { #if DEBUG - cmuxDebugLog( + dlog( "browser.contextReset.skip panel=\(id.uuidString.prefix(5)) " + "reason=\(reason) render=\(shouldRenderWebView ? 1 : 0)" ) @@ -4081,7 +4758,7 @@ extension BrowserPanel { } #if DEBUG - cmuxDebugLog( + dlog( "browser.contextReset.begin panel=\(id.uuidString.prefix(5)) " + "reason=\(reason) render=\(shouldRenderWebView ? 1 : 0) " + "url=\(preferredURLStringForOmnibar() ?? "nil")" @@ -4113,13 +4790,10 @@ extension BrowserPanel { navigationDelegate?.lastAttemptedURL = nil abandonRestoredSessionHistoryIfNeeded() - pendingAddressBarFocusRequestId = nil - preferredFocusIntent = .addressBar - suppressOmnibarAutofocusUntil = nil - suppressWebViewFocusUntil = nil - endSuppressWebViewFocusForAddressBar() - invalidateAddressBarPageFocusRestoreAttempts() - invalidateSearchFocusRequests(reason: "contextReset") + updateSubfocusState(.addressBar(.active)) + clearOmnibarAutofocusSuppression(reason: "reset") + paneController.clearTemporaryWebViewFocusSuppression() + clearAddressBarFocus(reason: "reset") searchState = nil pageTitle = "" @@ -4134,6 +4808,8 @@ extension BrowserPanel { webViewObservers.removeAll() webViewCancellables.removeAll() BrowserWindowPortalRegistry.detach(webView: oldWebView) + webAuthnCoordinator?.uninstall(from: oldWebView) + webAuthnCoordinator = nil oldWebView.stopLoading() oldWebView.navigationDelegate = nil oldWebView.uiDelegate = nil @@ -4153,7 +4829,7 @@ extension BrowserPanel { refreshNavigationAvailability() #if DEBUG - cmuxDebugLog( + dlog( "browser.contextReset.end panel=\(id.uuidString.prefix(5)) " + "reason=\(reason) instance=\(webViewInstanceID.uuidString.prefix(6))" ) @@ -4262,30 +4938,16 @@ extension BrowserPanel { /// Open a link in a new browser surface in the same pane func openLinkInNewTab(url: URL, bypassInsecureHTTPHostOnce: String? = nil) { - openLinkInNewTab( - request: URLRequest(url: url), - bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce - ) - } - - /// Opens a request in a sibling browser tab without dropping request metadata. - func openLinkInNewTab(request: URLRequest, bypassInsecureHTTPHostOnce: String? = nil) { - guard let seed = browserNewTabNavigationSeed( - from: request, - bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce - ) else { - return - } #if DEBUG - cmuxDebugLog( + dlog( "browser.newTab.open.begin panel=\(id.uuidString.prefix(5)) " + - "workspace=\(workspaceId.uuidString.prefix(5)) url=\(browserNavigationDebugURL(seed.url)) " + - "bypass=\(seed.bypassInsecureHTTPHostOnce ?? "nil")" + "workspace=\(workspaceId.uuidString.prefix(5)) url=\(url.absoluteString) " + + "bypass=\(bypassInsecureHTTPHostOnce ?? "nil")" ) #endif guard let app = AppDelegate.shared else { #if DEBUG - cmuxDebugLog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=missingAppDelegate") + dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=missingAppDelegate") #endif return } @@ -4294,31 +4956,25 @@ extension BrowserPanel { preferredWorkspaceId: workspaceId )?.workspace else { #if DEBUG - cmuxDebugLog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=workspaceMissing") + dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=workspaceMissing") #endif return } guard let paneId = workspace.paneId(forPanelId: id) else { #if DEBUG - cmuxDebugLog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=paneMissing") + dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=paneMissing") #endif return } - guard let _ = workspace.newBrowserSurface( + workspace.createBrowserPanel( inPane: paneId, - url: seed.url, - initialRequest: seed.initialRequest, + url: url, focus: true, preferredProfileID: profileID, - bypassInsecureHTTPHostOnce: seed.bypassInsecureHTTPHostOnce - ) else { -#if DEBUG - cmuxDebugLog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=newPanelFailed") -#endif - return - } + bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce + ) #if DEBUG - cmuxDebugLog( + dlog( "browser.newTab.open.done panel=\(id.uuidString.prefix(5)) " + "workspace=\(workspace.id.uuidString.prefix(5)) pane=\(paneId.id.uuidString.prefix(5))" ) @@ -4426,7 +5082,7 @@ extension BrowserPanel { self.setPreferredDeveloperToolsVisible(false) self.cancelDeveloperToolsRestoreRetry() #if DEBUG - cmuxDebugLog( + dlog( "browser.devtools detachedClose.manual panel=\(self.id.uuidString.prefix(5)) " + "\(self.debugDeveloperToolsStateSummary()) \(self.debugDeveloperToolsGeometrySummary())" ) @@ -4445,7 +5101,7 @@ extension BrowserPanel { let mainWindow = webView.window else { return } for window in NSApp.windows where window !== mainWindow && Self.isDetachedInspectorWindow(window) { #if DEBUG - cmuxDebugLog( + dlog( "browser.devtools strayWindow.close panel=\(id.uuidString.prefix(5)) " + "title=\(window.title) frame=\(NSStringFromRect(window.frame))" ) @@ -4566,7 +5222,7 @@ extension BrowserPanel { cancelDeveloperToolsRestoreRetry() } #if DEBUG - cmuxDebugLog( + dlog( "browser.devtools transition.queue panel=\(id.uuidString.prefix(5)) " + "source=\(source) target=\(targetVisible ? 1 : 0) \(debugDeveloperToolsStateSummary())" ) @@ -4633,7 +5289,7 @@ extension BrowserPanel { @discardableResult func toggleDeveloperTools() -> Bool { #if DEBUG - cmuxDebugLog( + dlog( "browser.devtools toggle.begin panel=\(id.uuidString.prefix(5)) " + "\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())" ) @@ -4641,13 +5297,13 @@ extension BrowserPanel { let targetVisible = !effectiveDeveloperToolsVisibilityIntent() let handled = enqueueDeveloperToolsVisibilityTransition(to: targetVisible, source: "toggle") #if DEBUG - cmuxDebugLog( + dlog( "browser.devtools toggle.end panel=\(id.uuidString.prefix(5)) targetVisible=\(targetVisible ? 1 : 0) " + "\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())" ) DispatchQueue.main.async { [weak self] in guard let self else { return } - cmuxDebugLog( + dlog( "browser.devtools toggle.tick panel=\(self.id.uuidString.prefix(5)) " + "\(self.debugDeveloperToolsStateSummary()) \(self.debugDeveloperToolsGeometrySummary())" ) @@ -4772,7 +5428,7 @@ extension BrowserPanel { forceDeveloperToolsRefreshOnNextAttach = false cancelDeveloperToolsRestoreRetry() #if DEBUG - cmuxDebugLog( + dlog( "browser.devtools attachedClose.consume panel=\(id.uuidString.prefix(5)) " + "\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())" ) @@ -4803,7 +5459,7 @@ extension BrowserPanel { developerToolsLastKnownVisibleAt = Date() #if DEBUG if shouldForceRefresh { - cmuxDebugLog("browser.devtools refresh.consumeVisible panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") + dlog("browser.devtools refresh.consumeVisible panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") } #endif cancelDeveloperToolsRestoreRetry() @@ -4816,7 +5472,7 @@ extension BrowserPanel { developerToolsDetachedOpenGraceDeadline = nil cancelDeveloperToolsRestoreRetry() #if DEBUG - cmuxDebugLog( + dlog( "browser.devtools detachedClose.consume panel=\(id.uuidString.prefix(5)) " + "\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())" ) @@ -4830,7 +5486,7 @@ extension BrowserPanel { #if DEBUG if shouldForceRefresh { - cmuxDebugLog("browser.devtools refresh.forceShowWhenHidden panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") + dlog("browser.devtools refresh.forceShowWhenHidden panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") } #endif // WebKit inspector show can trigger transient first-responder churn while @@ -4873,7 +5529,7 @@ extension BrowserPanel { guard preferredDeveloperToolsVisible else { return } forceDeveloperToolsRefreshOnNextAttach = true #if DEBUG - cmuxDebugLog("browser.devtools refresh.request panel=\(id.uuidString.prefix(5)) reason=\(reason) \(debugDeveloperToolsStateSummary())") + dlog("browser.devtools refresh.request panel=\(id.uuidString.prefix(5)) reason=\(reason) \(debugDeveloperToolsStateSummary())") #endif } @@ -4959,55 +5615,16 @@ extension BrowserPanel { // MARK: - Find in Page func startFind() { - preferredFocusIntent = .findField let created = searchState == nil if created { searchState = BrowserSearchState() } - pendingAddressBarFocusRequestId = nil - NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: id) - let generation = beginSearchFocusRequest(reason: "startFind") -#if DEBUG - let window = webView.window - cmuxDebugLog( - "browser.find.start panel=\(id.uuidString.prefix(5)) " + - "created=\(created ? 1 : 0) render=\(shouldRenderWebView ? 1 : 0) " + - "generation=\(generation) " + - "window=\(window?.windowNumber ?? -1) key=\(NSApp.keyWindow === window ? 1 : 0) " + - "firstResponder=\(String(describing: window?.firstResponder))" - ) -#endif - postBrowserSearchFocusNotification(reason: "immediate", generation: generation) - // Focus notification can race with portal overlay mount. Re-post on the - // next runloop and shortly after so the find field can claim first responder. - DispatchQueue.main.async { [weak self] in - self?.postBrowserSearchFocusNotification(reason: "async0", generation: generation) - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.postBrowserSearchFocusNotification(reason: "async50ms", generation: generation) - } - } - - private func postBrowserSearchFocusNotification(reason: String, generation: UInt64) { - guard canApplySearchFocusRequest(generation) else { -#if DEBUG - cmuxDebugLog( - "browser.find.focusNotification.skip panel=\(id.uuidString.prefix(5)) " + - "reason=\(reason) generation=\(generation)" - ) -#endif - return - } + clearPendingWebContentRestore() + clearAddressBarFocus(reason: "startFind") + _ = requestFindFieldFocus(reason: "startFind") #if DEBUG - let window = webView.window - cmuxDebugLog( - "browser.find.focusNotification panel=\(id.uuidString.prefix(5)) " + - "generation=\(generation) " + - "reason=\(reason) window=\(window?.windowNumber ?? -1) " + - "firstResponder=\(String(describing: window?.firstResponder))" - ) + debugLogWebContentFocusSnapshot(event: "startFind", detail: "created=\(created ? 1 : 0)") #endif - NotificationCenter.default.post(name: .browserSearchFocus, object: id) } func findNext() { @@ -5026,9 +5643,74 @@ extension BrowserPanel { } } - func hideFind() { - invalidateSearchFocusRequests(reason: "hideFind") + func hideFind(reason: String = "api") { + let shouldRestorePageFocus = searchState != nil && preferredFocusIntent == .findField +#if DEBUG + debugLogWebContentFocusSnapshot( + event: "hideFind", + detail: "reason=\(reason) shouldRestore=\(shouldRestorePageFocus ? 1 : 0)" + ) +#endif + guard shouldRestorePageFocus else { + clearPendingWebContentRestore() + findFocusCoordinator.cancelFindFocus() + searchState = nil + return + } + let restoreTransaction = findFocusCoordinator.beginFindDismiss( + reason: "findDismiss.\(reason)", + shouldRestoreWebContent: true, + webViewInstanceID: webViewInstanceID + ) +#if DEBUG + if let restoreTransaction { + dlog( + "browser.focus.find.dismiss.begin panel=\(id.uuidString.prefix(5)) " + + "request=\(restoreTransaction.requestId.uuidString.prefix(8)) " + + "sourceFind=\(restoreTransaction.sourceFindRequestId?.uuidString.prefix(8) ?? "nil") " + + "reason=\(restoreTransaction.reason) " + + "requiresOverlay=\(restoreTransaction.requiresFindOverlayTeardown ? 1 : 0) " + + "requiresEditEnd=\(restoreTransaction.requiresFindFieldEndEditing ? 1 : 0)" + ) + } else { + dlog( + "browser.focus.find.dismiss.begin panel=\(id.uuidString.prefix(5)) " + + "reason=findDismiss.\(reason) request=nil" + ) + } +#endif + updateSubfocusState(.webView) searchState = nil + drivePendingWebContentRestoreIfPossible(trigger: "findDismiss.\(reason)") + } + + func noteFindOverlayDisappeared(source: String) { + if searchState != nil, preferredFocusIntent == .findField { + _ = requestFindFieldFocus(reason: "findOverlayRemount.\(source)") + } else { + let hadPendingRestore = findFocusCoordinator.pendingWebContentRestore != nil + findFocusCoordinator.noteFindOverlayDisappeared() + drivePendingWebContentRestoreIfPossible(trigger: "findOverlayDisappear.\(source)") + if !hadPendingRestore, + paneController.isPaneFocused, + let window = webView.window, + !webView.isHiddenOrHasHiddenAncestor, + !Self.responderChainContains(window.firstResponder, target: webView) { + _ = transitionToWebContentFocus( + reason: "findOverlayDisappear.\(source)", + yieldFindFieldResponder: false + ) + } + } +#if DEBUG + let window = webView.window + dlog( + "browser.focus.find.overlayDisappear panel=\(id.uuidString.prefix(5)) " + + "source=\(source) findVisible=\(searchState == nil ? 0 : 1) " + + "pendingRestore=\(pendingWebContentRestoreRequestId?.uuidString.prefix(8) ?? "nil") " + + "fr=\(window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" + ) +#endif } private func restoreFindStateAfterNavigation(replaySearch: Bool) { @@ -5038,10 +5720,6 @@ extension BrowserPanel { if replaySearch, !state.needle.isEmpty { executeFindSearch(state.needle) } - postBrowserSearchFocusNotification( - reason: "restoreAfterNavigation", - generation: searchFocusRequestGeneration - ) } private func executeFindSearch(_ needle: String) { @@ -5099,20 +5777,32 @@ extension BrowserPanel { webView.underPageBackgroundColor = GhosttyBackgroundTheme.currentColor() } - func suppressOmnibarAutofocus(for seconds: TimeInterval) { - suppressOmnibarAutofocusUntil = Date().addingTimeInterval(seconds) + func suppressOmnibarAutofocusForExplicitWebViewFocus(reason: String) { + suppressOmnibarAutofocusWhileWebViewFocused = true #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.omnibarAutofocus.suppress panel=\(id.uuidString.prefix(5)) " + - "seconds=\(String(format: "%.2f", seconds))" + "reason=\(reason)" + ) +#endif + } + + func clearOmnibarAutofocusSuppression(reason: String) { + guard suppressOmnibarAutofocusWhileWebViewFocused else { return } + suppressOmnibarAutofocusWhileWebViewFocused = false +#if DEBUG + dlog( + "browser.focus.omnibarAutofocus.suppress.clear panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason)" ) #endif } func suppressWebViewFocus(for seconds: TimeInterval) { - suppressWebViewFocusUntil = Date().addingTimeInterval(seconds) + paneController.setTemporaryWebViewFocusSuppression(for: seconds) + refreshWebViewFirstResponderPolicy(reason: "suppressWebViewFocus") #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.webView.suppress panel=\(id.uuidString.prefix(5)) " + "seconds=\(String(format: "%.2f", seconds))" ) @@ -5120,63 +5810,49 @@ extension BrowserPanel { } func clearWebViewFocusSuppression() { - suppressWebViewFocusUntil = nil + paneController.clearTemporaryWebViewFocusSuppression() + refreshWebViewFirstResponderPolicy(reason: "clearWebViewFocusSuppression") #if DEBUG - cmuxDebugLog("browser.focus.webView.suppress.clear panel=\(id.uuidString.prefix(5))") + dlog("browser.focus.webView.suppress.clear panel=\(id.uuidString.prefix(5))") #endif } func shouldSuppressOmnibarAutofocus() -> Bool { - if let until = suppressOmnibarAutofocusUntil { - return Date() < until - } - return false + guard suppressOmnibarAutofocusWhileWebViewFocused, + let window = webView.window else { return false } + return Self.responderChainContains(window.firstResponder, target: webView) } func shouldSuppressWebViewFocus() -> Bool { - if suppressWebViewFocusForAddressBar { - return true - } - if searchState != nil { - return true - } - if let until = suppressWebViewFocusUntil { - return Date() < until - } - return false + paneController.shouldSuppressWebViewFocus(searchActive: searchState != nil) } - func beginSuppressWebViewFocusForAddressBar() { - let enteringAddressBar = !suppressWebViewFocusForAddressBar - if enteringAddressBar { + private func beginSuppressWebViewFocusForAddressBar() { + if paneController.beginAddressBarSuppression() { #if DEBUG - cmuxDebugLog("browser.focus.addressBarSuppress.begin panel=\(id.uuidString.prefix(5))") + dlog("browser.focus.addressBarSuppress.begin panel=\(id.uuidString.prefix(5))") #endif - invalidateAddressBarPageFocusRestoreAttempts() - } - suppressWebViewFocusForAddressBar = true - if enteringAddressBar { - captureAddressBarPageFocusIfNeeded() } + refreshWebViewFirstResponderPolicy(reason: "addressBarSuppress.begin") } - func endSuppressWebViewFocusForAddressBar() { - if suppressWebViewFocusForAddressBar { + private func endSuppressWebViewFocusForAddressBar() { + if paneController.endAddressBarSuppression() { #if DEBUG - cmuxDebugLog("browser.focus.addressBarSuppress.end panel=\(id.uuidString.prefix(5))") + dlog("browser.focus.addressBarSuppress.end panel=\(id.uuidString.prefix(5))") #endif } - suppressWebViewFocusForAddressBar = false + refreshWebViewFirstResponderPolicy(reason: "addressBarSuppress.end") } @discardableResult func requestAddressBarFocus() -> UUID { - preferredFocusIntent = .addressBar - invalidateSearchFocusRequests(reason: "requestAddressBarFocus") + clearPendingWebContentRestore() + clearFindFieldSubfocus() beginSuppressWebViewFocusForAddressBar() if let pendingAddressBarFocusRequestId { #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.addressBar.request panel=\(id.uuidString.prefix(5)) " + "request=\(pendingAddressBarFocusRequestId.uuidString.prefix(8)) result=reuse_pending" ) @@ -5184,9 +5860,9 @@ extension BrowserPanel { return pendingAddressBarFocusRequestId } let requestId = UUID() - pendingAddressBarFocusRequestId = requestId + updateSubfocusState(.addressBar(.pending(requestId))) #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.addressBar.request panel=\(id.uuidString.prefix(5)) " + "request=\(requestId.uuidString.prefix(8)) result=new" ) @@ -5194,27 +5870,261 @@ extension BrowserPanel { return requestId } - func noteWebViewFocused() { - guard searchState == nil else { return } - guard preferredFocusIntent != .webView else { return } - preferredFocusIntent = .webView - invalidateSearchFocusRequests(reason: "webViewFocused") + func noteWebViewFocused(source: BrowserWebViewFocusSource = .explicit) { + if let window = webView.window { + resumeWebContentFocusIfWindowIsKey( + window, + reason: "webViewFocused.\(String(describing: source))" + ) + } + if let window = webView.window, + (!window.isKeyWindow || paneController.isWebContentSuspendedByWindowResign) { +#if DEBUG + dlog( + "browser.focus.webViewFocused.skip panel=\(id.uuidString.prefix(5)) " + + "source=\(String(describing: source)) cause=window_inactive " + + "key=\(window.isKeyWindow ? 1 : 0) " + + "suspended=\(paneController.isWebContentSuspendedByWindowResign ? 1 : 0)" + ) +#endif + return + } + if source == .passiveObservation { + switch paneController.subfocusState { + case .webView: + break + case .addressBar, .findField: + return + } + } + if source == .explicit { + if isAddressBarFocused { + setAddressBarFocused(false, reason: "webViewFocused.\(String(describing: source))") + } else { + endSuppressWebViewFocusForAddressBar() + } + paneController.setPaneFocused(true) + } + updateSubfocusState(.webView) + if source == .explicit { + allowWebViewFirstResponder(reason: "noteWebViewFocused") + } + findFocusCoordinator.cancelFindFocus() + applyPendingWebContentRestoreIfNeeded(source: source) + } + + func notePanelFocusChanged(_ focused: Bool) { + paneController.setPaneFocused(focused) + if focused, let window = webView.window { + resumeWebContentFocusIfWindowIsKey(window, reason: "paneFocused") + } + refreshWebViewFirstResponderPolicy(reason: focused ? "paneFocused" : "paneBlurred") +#if DEBUG + dlog( + "browser.focus.panelFocus panel=\(id.uuidString.prefix(5)) focused=\(focused ? 1 : 0) " + + "pref=\(String(describing: preferredFocusIntent)) subfocus=\(String(reflecting: paneController.subfocusState)) " + + "search=\(searchState == nil ? 0 : 1) suppressWeb=\(shouldSuppressWebViewFocus() ? 1 : 0)" + ) +#endif + guard focused else { return } + drivePendingWebContentRestoreIfPossible(trigger: "paneFocused") + requestFindFieldFocusForFocusedPaneIfNeeded(reason: "paneFocused") + } + + private func allowWebViewFirstResponder(reason: String) { + setWebViewFirstResponderPolicy(shouldAllowWebViewFirstResponder(), reason: reason) + } + + func refreshWebViewFirstResponderPolicy(reason: String) { + setWebViewFirstResponderPolicy( + shouldAllowWebViewFirstResponder(), + reason: reason + ) + } + + private func shouldAllowWebViewFirstResponder() -> Bool { + paneController.shouldAllowWebViewFirstResponder( + searchActive: searchState != nil, + hasWindow: webView.window != nil, + windowIsKey: webView.window?.isKeyWindow == true + ) + } + + private func resumeWebContentFocusIfWindowIsKey(_ window: NSWindow, reason: String) { + guard paneController.resumeWebContentFocusIfWindowIsKey( + windowIsKey: window.isKeyWindow + ) else { return } +#if DEBUG + dlog( + "browser.focus.windowKey.resume panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason)" + ) +#endif + } + + private func canApplyWebContentFocus(in window: NSWindow, reason: String) -> Bool { + guard paneController.canApplyWebContentFocus( + searchActive: searchState != nil, + windowIsKey: window.isKeyWindow + ) else { +#if DEBUG + dlog( + "browser.focus.webContent.skip panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) cause=window_inactive " + + "key=\(window.isKeyWindow ? 1 : 0) " + + "suspended=\(paneController.isWebContentSuspendedByWindowResign ? 1 : 0) " + + "suppress=\(shouldSuppressWebViewFocus() ? 1 : 0)" + ) +#endif + return false + } + return true + } + + private func setWebViewFirstResponderPolicy(_ allowed: Bool, reason: String) { + guard let cmuxWebView = webView as? CmuxWebView else { return } + if cmuxWebView.allowsFirstResponderAcquisition != allowed { +#if DEBUG + dlog( + "browser.focus.policy.owner panel=\(id.uuidString.prefix(5)) " + + "web=\(ObjectIdentifier(cmuxWebView)) " + + "old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " + + "new=\(allowed ? 1 : 0) reason=\(reason) " + + "paneFocused=\(paneController.isPaneFocused ? 1 : 0) " + + "suppress=\(shouldSuppressWebViewFocus() ? 1 : 0) " + + "key=\(webView.window?.isKeyWindow == true ? 1 : 0) " + + "suspended=\(paneController.isWebContentSuspendedByWindowResign ? 1 : 0)" + ) +#endif + } + cmuxWebView.allowsFirstResponderAcquisition = allowed + } + + private func clearAddressBarFocus(reason: String) { + if isAddressBarFocused { + setAddressBarFocused(false, reason: reason) + } else { + noteAddressBarBlurred(reason: reason) + NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: id) + } + } + + func setAddressBarFocused(_ focused: Bool, reason: String) { + guard paneController.setAddressBarFocused(focused) else { + if !focused { + noteAddressBarBlurred(reason: reason) + } +#if DEBUG + dlog( + "browser.focus.addressBar.state.noop panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) value=\(focused ? 1 : 0)" + ) +#endif + return + } + + isAddressBarFocused = paneController.isAddressBarFocused + if focused { + beginSuppressWebViewFocusForAddressBar() + noteAddressBarFocused() + NotificationCenter.default.post(name: .browserDidFocusAddressBar, object: id) + } else { + noteAddressBarBlurred(reason: reason) + NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: id) + } +#if DEBUG + dlog( + "browser.focus.addressBar.state panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) focused=\(focused ? 1 : 0) " + + "subfocus=\(String(reflecting: paneController.subfocusState))" + ) +#endif + } + + private func noteAddressBarFocused() { + updateSubfocusState(.addressBar(.active)) } - func noteAddressBarFocused() { - guard preferredFocusIntent != .addressBar else { return } - preferredFocusIntent = .addressBar - invalidateSearchFocusRequests(reason: "addressBarFocused") + private func noteAddressBarBlurred(reason: String) { + let wasAddressBarFocused: Bool + if case .addressBar = paneController.subfocusState { + wasAddressBarFocused = true + updateSubfocusState(.webView) + } else { + wasAddressBarFocused = false + } + endSuppressWebViewFocusForAddressBar() +#if DEBUG + dlog( + "browser.focus.addressBar.blur panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) cleared=\(wasAddressBarFocused ? 1 : 0)" + ) +#endif } - func noteFindFieldFocused() { - guard preferredFocusIntent != .findField else { return } - preferredFocusIntent = .findField + func noteFindFieldMounted(requestId: UUID? = nil) { + guard let requestId else { return } + guard findFocusCoordinator.noteFindFieldMounted(requestId: requestId) else { +#if DEBUG + dlog( + "browser.focus.find.requestMount panel=\(id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8)) result=ignored " + + "pending=\(pendingFindFieldFocusRequestId?.uuidString.prefix(8) ?? "nil")" + ) +#endif + return + } +#if DEBUG + dlog( + "browser.focus.find.requestMount panel=\(id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8)) result=accepted" + ) +#endif + } + + func noteFindFieldFocused(requestId: UUID? = nil) { + guard findFocusCoordinator.noteFindFieldFocused(requestId: requestId) else { +#if DEBUG + dlog( + "browser.focus.find.requestAck panel=\(id.uuidString.prefix(5)) " + + "request=\(requestId?.uuidString.prefix(8) ?? "nil") result=ignored " + + "pending=\(pendingFindFieldFocusRequestId?.uuidString.prefix(8) ?? "nil")" + ) +#endif + return + } + updateSubfocusState(.findField(.active)) +#if DEBUG + dlog( + "browser.focus.find.requestAck panel=\(id.uuidString.prefix(5)) " + + "request=\(requestId?.uuidString.prefix(8) ?? "nil") result=accepted" + ) +#endif } - func canApplySearchFocusRequest(_ generation: UInt64) -> Bool { - generation != 0 && - generation == searchFocusRequestGeneration && + func noteFindFieldEndedEditing(requestId: UUID? = nil, source: String) { + guard findFocusCoordinator.noteFindFieldEndedEditing(requestId: requestId) else { +#if DEBUG + dlog( + "browser.focus.find.editEnd panel=\(id.uuidString.prefix(5)) " + + "request=\(requestId?.uuidString.prefix(8) ?? "nil") result=ignored " + + "pending=\(pendingWebContentRestoreRequestId?.uuidString.prefix(8) ?? "nil") " + + "source=\(source)" + ) +#endif + return + } + drivePendingWebContentRestoreIfPossible(trigger: "findFieldEndEditing.\(source)") +#if DEBUG + dlog( + "browser.focus.find.editEnd panel=\(id.uuidString.prefix(5)) " + + "request=\(requestId?.uuidString.prefix(8) ?? "nil") result=accepted source=\(source)" + ) +#endif + } + + func canApplyFindFieldFocusRequest(_ requestId: UUID) -> Bool { + findFocusCoordinator.canApplyFindFieldFocusRequest(requestId) && searchState != nil && preferredFocusIntent == .findField } @@ -5248,21 +6158,30 @@ extension BrowserPanel { func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) { guard case .browser(let target) = intent else { return } + paneController.setPaneFocused(true) switch target { case .webView: - preferredFocusIntent = .webView - invalidateSearchFocusRequests(reason: "prepareWebView") - endSuppressWebViewFocusForAddressBar() + clearAddressBarFocus(reason: "prepareActivation.webView") case .addressBar: - preferredFocusIntent = .addressBar - invalidateSearchFocusRequests(reason: "prepareAddressBar") + if let requestId = pendingAddressBarFocusRequestId { + updateSubfocusState(.addressBar(.pending(requestId))) + } else { + updateSubfocusState(.addressBar(.active)) + } beginSuppressWebViewFocusForAddressBar() case .findField: - preferredFocusIntent = .findField + if let requestId = pendingFindFieldFocusRequestId { + updateSubfocusState(.findField(.pending(requestId))) + } else if searchState != nil { + _ = requestFindFieldFocus(reason: "activation") + } else { + updateSubfocusState(.findField(.active)) + } } + refreshWebViewFirstResponderPolicy(reason: "prepareActivation.\(String(describing: target))") #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.prepare panel=\(id.uuidString.prefix(5)) " + "target=\(String(describing: target)) suppressWeb=\(shouldSuppressWebViewFocus() ? 1 : 0)" ) @@ -5275,14 +6194,13 @@ extension BrowserPanel { switch target { case .webView: - noteWebViewFocused() focus() return true case .addressBar: let requestId = requestAddressBarFocus() NotificationCenter.default.post(name: .browserFocusAddressBar, object: id) #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.restore panel=\(id.uuidString.prefix(5)) " + "target=addressBar request=\(requestId.uuidString.prefix(8))" ) @@ -5299,6 +6217,10 @@ extension BrowserPanel { return .browser(.addressBar) } + if browserSearchOverlayPanelId(for: responder) == id { + return .browser(.findField) + } + if BrowserWindowPortalRegistry.searchOverlayPanelId(for: responder, in: window) == id { return .browser(.findField) } @@ -5316,11 +6238,19 @@ extension BrowserPanel { switch target { case .findField: - invalidateSearchFocusRequests(reason: "yieldFindField") + if browserSearchOverlayPanelId(for: window.firstResponder) == id { + let yielded = window.makeFirstResponder(nil) +#if DEBUG + if yielded { + dlog("focus.handoff.yield panel=\(id.uuidString.prefix(5)) target=browserFind") + } +#endif + return yielded + } let yielded = BrowserWindowPortalRegistry.yieldSearchOverlayFocusIfOwned(by: id, in: window) #if DEBUG if yielded { - cmuxDebugLog("focus.handoff.yield panel=\(id.uuidString.prefix(5)) target=browserFind") + dlog("focus.handoff.yield panel=\(id.uuidString.prefix(5)) target=browserFind") } #endif return yielded @@ -5329,42 +6259,26 @@ extension BrowserPanel { let yielded = window.makeFirstResponder(nil) #if DEBUG if yielded { - cmuxDebugLog("focus.handoff.yield panel=\(id.uuidString.prefix(5)) target=addressBar") + dlog("focus.handoff.yield panel=\(id.uuidString.prefix(5)) target=addressBar") } #endif return yielded case .webView: guard Self.responderChainContains(window.firstResponder, target: webView) else { return false } - return window.makeFirstResponder(nil) - } - } - - @discardableResult - private func beginSearchFocusRequest(reason: String) -> UInt64 { - searchFocusRequestGeneration &+= 1 #if DEBUG - cmuxDebugLog( - "browser.find.focusLease.begin panel=\(id.uuidString.prefix(5)) " + - "generation=\(searchFocusRequestGeneration) reason=\(reason)" - ) -#endif - return searchFocusRequestGeneration - } - - private func invalidateSearchFocusRequests(reason: String) { - searchFocusRequestGeneration &+= 1 -#if DEBUG - cmuxDebugLog( - "browser.find.focusLease.invalidate panel=\(id.uuidString.prefix(5)) " + - "generation=\(searchFocusRequestGeneration) reason=\(reason)" - ) + dlog( + "focus.handoff.yield panel=\(id.uuidString.prefix(5)) " + + "target=browserWeb mode=preserveUntilTarget fr=\(window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" + ) #endif + return true + } } func acknowledgeAddressBarFocusRequest(_ requestId: UUID) { guard pendingAddressBarFocusRequestId == requestId else { #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.addressBar.requestAck panel=\(id.uuidString.prefix(5)) " + "request=\(requestId.uuidString.prefix(8)) result=ignored " + "pending=\(pendingAddressBarFocusRequestId?.uuidString.prefix(8) ?? "nil")" @@ -5372,146 +6286,320 @@ extension BrowserPanel { #endif return } - pendingAddressBarFocusRequestId = nil + setAddressBarFocused(true, reason: "addressBar.requestAck") + updateSubfocusState(.addressBar(.active)) #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.addressBar.requestAck panel=\(id.uuidString.prefix(5)) " + "request=\(requestId.uuidString.prefix(8)) result=cleared" ) #endif } - private func captureAddressBarPageFocusIfNeeded() { - webView.evaluateJavaScript(Self.addressBarFocusCaptureScript) { [weak self] result, error in + private func updateSubfocusState(_ next: BrowserSubfocusState) { + guard paneController.subfocusState != next else { return } #if DEBUG - guard let self else { return } - if let error { - cmuxDebugLog( - "browser.focus.addressBar.capture panel=\(self.id.uuidString.prefix(5)) " + - "result=error message=\(error.localizedDescription)" - ) - return - } - let resultValue = (result as? String) ?? "unknown" - cmuxDebugLog( - "browser.focus.addressBar.capture panel=\(self.id.uuidString.prefix(5)) " + - "result=\(resultValue)" + let previous = String(reflecting: paneController.subfocusState) + let phase = String( + describing: paneController.phase( + searchActive: searchState != nil, + hasWindow: webView.window != nil, + windowIsKey: webView.window?.isKeyWindow == true ) -#else - _ = self - _ = result - _ = error + ) #endif + if next != .webView { + clearOmnibarAutofocusSuppression(reason: "subfocus.\(String(describing: next))") } + paneController.setSubfocusState(next) + subfocusState = next +#if DEBUG + dlog( + "browser.focus.subfocus panel=\(id.uuidString.prefix(5)) old=\(previous) " + + "new=\(String(reflecting: next)) search=\(searchState == nil ? 0 : 1) " + + "paneFocused=\(paneController.isPaneFocused ? 1 : 0) " + + "suppressWeb=\(shouldSuppressWebViewFocus() ? 1 : 0) " + + "phase=\(phase)" + ) +#endif } - private enum AddressBarPageFocusRestoreStatus: String { - case restored - case noState = "no_state" - case missingTarget = "missing_target" - case notFocused = "not_focused" - case error + private func clearFindFieldSubfocus() { + if case .findField = paneController.subfocusState { + updateSubfocusState(.webView) + } + findFocusCoordinator.cancelFindFocus() } - private static func addressBarPageFocusRestoreStatus( - from result: Any?, - error: Error? - ) -> AddressBarPageFocusRestoreStatus { - if error != nil { return .error } - guard let raw = result as? String else { return .error } - return AddressBarPageFocusRestoreStatus(rawValue: raw) ?? .error - } + @discardableResult + func transitionToWebContentFocus( + reason: String, + yieldFindFieldResponder: Bool = false, + beforeRestore: (() -> Void)? = nil, + completion: ((Bool) -> Void)? = nil + ) -> Bool { + let complete: (Bool) -> Void = completion ?? { _ in } + + guard let window = webView.window, !webView.isHiddenOrHasHiddenAncestor else { + beforeRestore?() + complete(false) + return false + } + resumeWebContentFocusIfWindowIsKey(window, reason: "transition.\(reason)") + guard canApplyWebContentFocus(in: window, reason: "transition.\(reason)") else { + beforeRestore?() + complete(false) + return false + } + + let yieldedFindFieldResponder: Bool + if yieldFindFieldResponder { + let yieldedOwnedFindField = yieldFocusIntent(.browser(.findField), in: window) + let yieldedFieldEditorFallback: Bool + if yieldedOwnedFindField { + yieldedFieldEditorFallback = false + } else if let editor = window.firstResponder as? NSTextView, + editor.isFieldEditor { + yieldedFieldEditorFallback = window.makeFirstResponder(nil) + } else { + yieldedFieldEditorFallback = false + } + yieldedFindFieldResponder = yieldedOwnedFindField || yieldedFieldEditorFallback + } else { + yieldedFindFieldResponder = false + } - func invalidateAddressBarPageFocusRestoreAttempts() { - addressBarFocusRestoreGeneration &+= 1 + paneController.setPaneFocused(true) + allowWebViewFirstResponder(reason: "transition.\(reason)") + let initialFocusResult = acquireWebViewFirstResponder( + in: window, + reason: "transition.\(reason)", + forceWrapperReactivation: true + ) + if initialFocusResult.didFocusResponder { + noteWebViewFocused() + } #if DEBUG - cmuxDebugLog( - "browser.focus.addressBar.restore.invalidate panel=\(id.uuidString.prefix(5)) " + - "generation=\(addressBarFocusRestoreGeneration)" + debugLogWebContentFocusSnapshot( + event: "webContent.return.begin", + detail: "reason=\(reason) yieldedFind=\(yieldedFindFieldResponder ? 1 : 0) " + + "result=\(initialFocusResult.debugDescription) focused=\(initialFocusResult.didFocusResponder ? 1 : 0)" ) #endif - } - func restoreAddressBarPageFocusIfNeeded(completion: @escaping (Bool) -> Void) { - addressBarFocusRestoreGeneration &+= 1 - let generation = addressBarFocusRestoreGeneration - let delays: [TimeInterval] = [0.0, 0.03, 0.09, 0.2] - restoreAddressBarPageFocusAttemptIfNeeded( - attempt: 0, - delays: delays, - generation: generation, - completion: completion + beforeRestore?() + guard let refreshedWindow = webView.window, + !webView.isHiddenOrHasHiddenAncestor else { + complete(initialFocusResult.didFocusResponder) + return initialFocusResult.didFocusResponder + } + guard canApplyWebContentFocus(in: refreshedWindow, reason: "transition.\(reason).reacquire") else { + complete(initialFocusResult.didFocusResponder) + return initialFocusResult.didFocusResponder + } + let reacquiredFocusResult = acquireWebViewFirstResponder( + in: refreshedWindow, + reason: "transition.\(reason).reacquire", + forceWrapperReactivation: true ) + let hasWebFocusResponder = + reacquiredFocusResult.didFocusResponder || + hasWebViewFirstResponder(in: refreshedWindow) + let hasNativeWebContentResponder = hasWebContentFirstResponder(in: refreshedWindow) + if hasWebFocusResponder { + noteWebViewFocused() + } +#if DEBUG + dlog( + "browser.focus.webContent.return panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) focused=\(hasWebFocusResponder ? 1 : 0) " + + "content=\(hasNativeWebContentResponder ? 1 : 0) " + + "reacquired=\(reacquiredFocusResult.debugDescription)" + ) + debugLogWebContentFocusSnapshot( + event: "webContent.return.end", + detail: "reason=\(reason) focused=\(hasWebFocusResponder ? 1 : 0) " + + "content=\(hasNativeWebContentResponder ? 1 : 0) reacquired=\(reacquiredFocusResult.debugDescription)" + ) +#endif + complete(hasWebFocusResponder) + + return initialFocusResult.didFocusResponder || hasWebFocusResponder } - private func restoreAddressBarPageFocusAttemptIfNeeded( - attempt: Int, - delays: [TimeInterval], - generation: UInt64, - completion: @escaping (Bool) -> Void - ) { - guard generation == addressBarFocusRestoreGeneration else { - completion(false) - return + private func clearPendingWebContentRestore() { + findFocusCoordinator.cancelWebContentRestore() + } + + @discardableResult + private func drivePendingWebContentRestoreIfPossible( + trigger: String, + completion: ((Bool) -> Void)? = nil + ) -> Bool { + guard let pendingRestore = findFocusCoordinator.pendingWebContentRestore else { return false } + guard pendingRestore.findOverlayTeardownObserved else { +#if DEBUG + dlog( + "browser.focus.webContent.pending.skip panel=\(id.uuidString.prefix(5)) " + + "request=\(pendingRestore.requestId.uuidString.prefix(8)) " + + "reason=\(pendingRestore.reason) source=findDismiss " + + "trigger=\(trigger) cause=find_overlay_visible" + ) +#endif + return false + } + guard pendingRestore.findFieldEndEditingObserved else { +#if DEBUG + dlog( + "browser.focus.webContent.pending.skip panel=\(id.uuidString.prefix(5)) " + + "request=\(pendingRestore.requestId.uuidString.prefix(8)) " + + "reason=\(pendingRestore.reason) source=findDismiss " + + "trigger=\(trigger) cause=find_field_editing" + ) +#endif + return false } - webView.evaluateJavaScript(Self.addressBarFocusRestoreScript) { [weak self] result, error in - guard let self else { - completion(false) - return - } - guard generation == self.addressBarFocusRestoreGeneration else { - completion(false) - return - } - let status = Self.addressBarPageFocusRestoreStatus(from: result, error: error) - let canRetry = (status == .notFocused || status == .error) - let hasNextAttempt = attempt + 1 < delays.count + guard paneController.isPaneFocused else { +#if DEBUG + dlog( + "browser.focus.webContent.pending.skip panel=\(id.uuidString.prefix(5)) " + + "request=\(pendingRestore.requestId.uuidString.prefix(8)) " + + "reason=\(pendingRestore.reason) source=findDismiss " + + "trigger=\(trigger) cause=pane_unfocused" + ) +#endif + return false + } + guard pendingRestore.webViewInstanceID == webViewInstanceID else { +#if DEBUG + dlog( + "browser.focus.webContent.pending.drop panel=\(id.uuidString.prefix(5)) " + + "request=\(pendingRestore.requestId.uuidString.prefix(8)) " + + "reason=\(pendingRestore.reason) source=findDismiss " + + "trigger=\(trigger) cause=webview_replaced" + ) +#endif + clearPendingWebContentRestore() + return false + } + guard let window = webView.window, !webView.isHiddenOrHasHiddenAncestor else { +#if DEBUG + dlog( + "browser.focus.webContent.pending.skip panel=\(id.uuidString.prefix(5)) " + + "request=\(pendingRestore.requestId.uuidString.prefix(8)) " + + "reason=\(pendingRestore.reason) source=findDismiss " + + "trigger=\(trigger) cause=webview_unavailable " + + "hasWindow=\(webView.window == nil ? 0 : 1) hidden=\(webView.isHiddenOrHasHiddenAncestor ? 1 : 0)" + ) +#endif + return false + } + resumeWebContentFocusIfWindowIsKey(window, reason: "pending.\(trigger)") + guard canApplyWebContentFocus(in: window, reason: "pending.\(trigger)") else { +#if DEBUG + dlog( + "browser.focus.webContent.pending.skip panel=\(id.uuidString.prefix(5)) " + + "request=\(pendingRestore.requestId.uuidString.prefix(8)) " + + "reason=\(pendingRestore.reason) source=findDismiss " + + "trigger=\(trigger) cause=window_inactive" + ) +#endif + return false + } + guard findFocusCoordinator.markWebContentRestoreApplying(pendingRestore) else { return false } #if DEBUG - if let error { - cmuxDebugLog( - "browser.focus.addressBar.restore panel=\(self.id.uuidString.prefix(5)) " + - "attempt=\(attempt) status=\(status.rawValue) " + - "message=\(error.localizedDescription)" - ) - } else { - cmuxDebugLog( - "browser.focus.addressBar.restore panel=\(self.id.uuidString.prefix(5)) " + - "attempt=\(attempt) status=\(status.rawValue)" + let hasWebViewResponder = Self.responderChainContains(window.firstResponder, target: webView) + let firstResponderDescription = window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "browser.focus.webContent.pending.apply panel=\(id.uuidString.prefix(5)) " + + "request=\(pendingRestore.requestId.uuidString.prefix(8)) " + + "reason=\(pendingRestore.reason) source=findDismiss " + + "trigger=\(trigger) webViewResponder=\(hasWebViewResponder ? 1 : 0) " + + "fr=\(firstResponderDescription)" + ) +#endif + let focused = transitionToWebContentFocus( + reason: pendingRestore.reason, + yieldFindFieldResponder: true, + completion: { [weak self] restored in + guard let self else { return } + self.findFocusCoordinator.completeWebContentRestore(pendingRestore) +#if DEBUG + dlog( + "browser.focus.webContent.pending.result panel=\(self.id.uuidString.prefix(5)) " + + "reason=\(pendingRestore.reason) restored=\(restored ? 1 : 0)" ) +#endif + completion?(restored) } + ) +#if DEBUG + dlog( + "browser.focus.webContent.pending.transition panel=\(id.uuidString.prefix(5)) " + + "request=\(pendingRestore.requestId.uuidString.prefix(8)) " + + "reason=\(pendingRestore.reason) focused=\(focused ? 1 : 0) " + + "fr=\(window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" + ) #endif + return true + } - if status == .restored { - completion(true) - return - } + private func applyPendingWebContentRestoreIfNeeded(source: BrowserWebViewFocusSource) { + drivePendingWebContentRestoreIfPossible( + trigger: "webViewFocused.\(String(describing: source))" + ) + } - if canRetry && hasNextAttempt { - let delay = delays[attempt + 1] - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self else { - completion(false) - return - } - guard generation == self.addressBarFocusRestoreGeneration else { - completion(false) - return - } - self.restoreAddressBarPageFocusAttemptIfNeeded( - attempt: attempt + 1, - delays: delays, - generation: generation, - completion: completion - ) - } - return - } + @discardableResult + private func requestFindFieldFocus(reason: String) -> UUID { + let requestId = findFocusCoordinator.beginFindFocus( + reason: reason, + webViewInstanceID: webViewInstanceID + ) + updateSubfocusState(.findField(.pending(requestId))) +#if DEBUG + dlog( + "browser.focus.find.request panel=\(id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8)) reason=\(reason)" + ) +#endif + return requestId + } - completion(false) + private func requestFindFieldFocusForFocusedPaneIfNeeded(reason: String) { + guard searchState != nil, preferredFocusIntent == .findField else { return } + guard let window = webView.window else { return } + guard !windowFirstResponderOwnsFindField(window) else { + noteFindFieldFocused(requestId: nil) + return } + _ = requestFindFieldFocus(reason: reason) + } + + private func windowFirstResponderOwnsFindField(_ window: NSWindow) -> Bool { + guard let firstResponder = window.firstResponder else { return false } + return browserSearchOverlayPanelId(for: firstResponder) == id || + BrowserWindowPortalRegistry.searchOverlayPanelId(for: firstResponder, in: window) == id + } + +#if DEBUG + func debugLogWebContentFocusSnapshot(event: String, detail: String = "") { + let panelId = id.uuidString.prefix(5) + let searchActive = searchState != nil ? 1 : 0 + let preferred = String(describing: preferredFocusIntent) + let subfocus = String(reflecting: paneController.subfocusState) + let window = webView.window + let firstResponder = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let detailSuffix = detail.isEmpty ? "" : " \(detail)" + dlog( + "browser.focus.snapshot event=\(event) panel=\(panelId) " + + "pref=\(preferred) subfocus=\(subfocus) search=\(searchActive) " + + "win=\(window?.windowNumber ?? -1) fr=\(firstResponder)\(detailSuffix)" + ) } +#endif /// Returns the most reliable URL string for omnibar-related matching and UI decisions. /// `currentURL` can lag behind navigation changes, so prefer the live WKWebView URL. @@ -5793,7 +6881,19 @@ private extension BrowserPanel { } extension BrowserPanel { - func hideBrowserPortalView(source: String) { + func setBrowserPortalVisibility( + visibleInUI: Bool, + zPriority: Int, + source: String + ) { + BrowserWindowPortalRegistry.updateEntryVisibility( + for: webView, + visibleInUI: visibleInUI, + zPriority: zPriority + ) + if visibleInUI { + return + } BrowserWindowPortalRegistry.hide( webView: webView, source: source @@ -5908,7 +7008,7 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { self?.onDownloadStarted?(safeFilename) } #if DEBUG - cmuxDebugLog("download.decideDestination file=\(safeFilename)") + dlog("download.decideDestination file=\(safeFilename)") #endif NSLog("BrowserPanel download: temp path=%@", destURL.path) completionHandler(destURL) @@ -5917,12 +7017,12 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { func downloadDidFinish(_ download: WKDownload) { guard let info = removeState(for: download) else { #if DEBUG - cmuxDebugLog("download.finished missing-state") + dlog("download.finished missing-state") #endif return } #if DEBUG - cmuxDebugLog("download.finished file=\(info.suggestedFilename)") + dlog("download.finished file=\(info.suggestedFilename)") #endif NSLog("BrowserPanel download finished: %@", info.suggestedFilename) @@ -5959,7 +7059,7 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { self?.onDownloadFailed?(error) } #if DEBUG - cmuxDebugLog("download.failed error=\(error.localizedDescription)") + dlog("download.failed error=\(error.localizedDescription)") #endif NSLog("BrowserPanel download failed: %@", error.localizedDescription) } @@ -6027,145 +7127,12 @@ func browserNavigationShouldFallbackNilTargetToNewTab( navigationType != .other } -func browserNavigationHasSimpleUserActivation( - currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type -) -> Bool { - switch currentEventType { - case .keyDown, .keyUp, .leftMouseDown, .leftMouseUp: - return true - default: - return false - } -} - -func browserNavigationPopupFeaturesWereSpecified( - x: NSNumber?, - y: NSNumber?, - width: NSNumber?, - height: NSNumber?, - menuBarVisibility: NSNumber?, - statusBarVisibility: NSNumber?, - toolbarsVisibility: NSNumber?, - allowsResizing: NSNumber? -) -> Bool { - x != nil || - y != nil || - width != nil || - height != nil || - menuBarVisibility != nil || - statusBarVisibility != nil || - toolbarsVisibility != nil || - allowsResizing != nil -} - -// Keep popup retargeting intentionally narrow. Explicit cross-host alias groups -// preserve known first-party search flows without guessing at the public suffix -// list for arbitrary hosted tenants, while same-host scripted popups stay on -// the popup path so opener-dependent browser flows keep working. -private let browserNavigationSimpleUserGesturePopupRetargetHostAliases: [Set] = [ - [ - "bilibili.com", - "search.bilibili.com", - "www.bilibili.com", - ], -] - -private func browserNavigationDefaultPort(for scheme: String) -> Int? { - switch scheme { - case "http": - return 80 - case "https": - return 443 - default: - return nil - } -} - -private func browserNavigationShouldRetargetSimpleUserGesturePopup( - requestURL: URL?, - openerURL: URL? -) -> Bool { - guard let requestURL, - let openerURL, - let requestScheme = requestURL.scheme?.lowercased(), !requestScheme.isEmpty, - let openerScheme = openerURL.scheme?.lowercased(), !openerScheme.isEmpty, - requestScheme == openerScheme, - (requestURL.port ?? browserNavigationDefaultPort(for: requestScheme)) - == (openerURL.port ?? browserNavigationDefaultPort(for: openerScheme)), - let requestHost = BrowserInsecureHTTPSettings.normalizeHost(requestURL.host ?? ""), - let openerHost = BrowserInsecureHTTPSettings.normalizeHost(openerURL.host ?? "") else { - return false - } - for aliases in browserNavigationSimpleUserGesturePopupRetargetHostAliases { - if requestHost != openerHost, - aliases.contains(requestHost), - aliases.contains(openerHost) { - return true - } - } - return false -} - -private func browserNavigationDebugURL(_ url: URL?) -> String { - guard let url, - var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return "nil" - } - components.query = nil - components.fragment = nil - return components.string ?? "\(url.scheme ?? "unknown")://\(url.host ?? "")" -} - -func browserNavigationShouldOpenSimpleUserGesturePopupInCurrentTab( - navigationType: WKNavigationType, - requestMethod: String?, - requestURL: URL?, - openerURL: URL?, - modifierFlags: NSEvent.ModifierFlags = [], - buttonNumber: Int = 0, - hasRecentMiddleClickIntent: Bool = false, - currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type, - currentEventButtonNumber: Int? = NSApp.currentEvent?.buttonNumber, - popupFeaturesWereSpecified: Bool -) -> Bool { - guard navigationType == .other else { - return false - } - // Some sites use `window.open()` for plain same-site searches triggered by a - // direct keyboard submit or left-click, without requesting popup chrome or - // opener-style geometry. Route those to a normal tab while keeping - // cross-site/OAuth-style popups on the popup path. - guard browserNavigationHasSimpleUserActivation(currentEventType: currentEventType) else { - return false - } - guard !browserNavigationShouldOpenInNewTab( - navigationType: navigationType, - modifierFlags: modifierFlags, - buttonNumber: buttonNumber, - hasRecentMiddleClickIntent: hasRecentMiddleClickIntent, - currentEventType: currentEventType, - currentEventButtonNumber: currentEventButtonNumber - ) else { - return false - } - guard (requestMethod ?? "GET").uppercased() == "GET" else { - return false - } - guard !popupFeaturesWereSpecified else { - return false - } - return browserNavigationShouldRetargetSimpleUserGesturePopup( - requestURL: requestURL, - openerURL: openerURL - ) -} - private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { + var didStartNavigation: ((WKWebView) -> Void)? var didFinish: ((WKWebView) -> Void)? var didFailNavigation: ((WKWebView, String) -> Void)? var didTerminateWebContentProcess: ((WKWebView) -> Void)? var openInNewTab: ((URL) -> Void)? - var requestNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)? var shouldBlockInsecureHTTPNavigation: ((URL) -> Bool)? var handleBlockedInsecureHTTPNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)? /// Direct reference to the download delegate — must be set synchronously in didBecome callbacks. @@ -6176,6 +7143,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { lastAttemptedURL = webView.url + didStartNavigation?(webView) } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { @@ -6233,7 +7201,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { #if DEBUG - cmuxDebugLog("browser.webcontent.terminated panel=\(String(describing: self))") + dlog("browser.webcontent.terminated panel=\(String(describing: self))") #endif didTerminateWebContentProcess?(webView) } @@ -6331,15 +7299,6 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void ) { - let openRequestInNewTab: (URLRequest) -> Void = { [requestNavigation, openInNewTab] request in - if let requestNavigation { - requestNavigation(request, .newTab) - return - } - if let url = request.url { - openInNewTab?(url) - } - } let hasRecentMiddleClickIntent = CmuxWebView.hasRecentMiddleClickIntent(for: webView) let shouldOpenInNewTab = browserNavigationShouldOpenInNewTab( navigationType: navigationAction.navigationType, @@ -6351,13 +7310,9 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil" let navType = String(describing: navigationAction.navigationType) - let requestMethod = navigationAction.request.httpMethod ?? "nil" - let requestURL = browserNavigationDebugURL(navigationAction.request.url) - let targetMainFrame = navigationAction.targetFrame.map { $0.isMainFrame ? "1" : "0" } ?? "nil" - cmuxDebugLog( + dlog( "browser.nav.decidePolicy navType=\(navType) button=\(navigationAction.buttonNumber) " + "mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " + - "targetMain=\(targetMainFrame) method=\(requestMethod) url=\(requestURL) " + "eventType=\(currentEventType) eventButton=\(currentEventButton) " + "recentMiddleIntent=\(hasRecentMiddleClickIntent ? 1 : 0) " + "openInNewTab=\(shouldOpenInNewTab ? 1 : 0)" @@ -6374,7 +7329,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { intent = .currentTab } #if DEBUG - cmuxDebugLog( + dlog( "browser.nav.decidePolicy.action kind=blockedInsecure intent=\(intent == .newTab ? "newTab" : "currentTab") " + "url=\(url.absoluteString)" ) @@ -6394,7 +7349,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString) } #if DEBUG - cmuxDebugLog("browser.navigation.external source=navDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)") + dlog("browser.navigation.external source=navDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)") #endif decisionHandler(.cancel) return @@ -6402,13 +7357,11 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { // Cmd+click and middle-click on regular links should always open in a new tab. if shouldOpenInNewTab, - let requestURL = navigationAction.request.url { + let url = navigationAction.request.url { #if DEBUG - cmuxDebugLog( - "browser.nav.decidePolicy.action kind=openInNewTab url=\(requestURL.absoluteString)" - ) + dlog("browser.nav.decidePolicy.action kind=openInNewTab url=\(url.absoluteString)") #endif - openRequestInNewTab(navigationAction.request) + openInNewTab?(url) decisionHandler(.cancel) return } @@ -6420,20 +7373,18 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { browserNavigationShouldFallbackNilTargetToNewTab( navigationType: navigationAction.navigationType ), - let requestURL = navigationAction.request.url { + let url = navigationAction.request.url { #if DEBUG - cmuxDebugLog( - "browser.nav.decidePolicy.action kind=openInNewTabFromNilTarget url=\(requestURL.absoluteString)" - ) + dlog("browser.nav.decidePolicy.action kind=openInNewTabFromNilTarget url=\(url.absoluteString)") #endif - openRequestInNewTab(navigationAction.request) + openInNewTab?(url) decisionHandler(.cancel) return } #if DEBUG let targetURL = navigationAction.request.url?.absoluteString ?? "nil" - cmuxDebugLog("browser.nav.decidePolicy.action kind=allow url=\(targetURL)") + dlog("browser.nav.decidePolicy.action kind=allow url=\(targetURL)") #endif decisionHandler(.allow) } @@ -6471,7 +7422,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { if contentDisposition.lowercased().hasPrefix("attachment") { NSLog("BrowserPanel download: content-disposition=attachment mime=%@ url=%@", mime, responseURL) #if DEBUG - cmuxDebugLog("download.policy=download reason=content-disposition mime=\(mime)") + dlog("download.policy=download reason=content-disposition mime=\(mime)") #endif decisionHandler(.download) return @@ -6481,7 +7432,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { if !canShow { NSLog("BrowserPanel download: cannotShowMIME mime=%@ url=%@", mime, responseURL) #if DEBUG - cmuxDebugLog("download.policy=download reason=cannotShowMIME mime=\(mime)") + dlog("download.policy=download reason=cannotShowMIME mime=\(mime)") #endif decisionHandler(.download) return @@ -6492,7 +7443,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { #if DEBUG - cmuxDebugLog("download.didBecome source=navigationAction") + dlog("download.didBecome source=navigationAction") #endif NSLog("BrowserPanel download didBecome from navigationAction") download.delegate = downloadDelegate @@ -6500,7 +7451,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { #if DEBUG - cmuxDebugLog("download.didBecome source=navigationResponse") + dlog("download.didBecome source=navigationResponse") #endif NSLog("BrowserPanel download didBecome from navigationResponse") download.delegate = downloadDelegate @@ -6548,25 +7499,10 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil" let navType = String(describing: navigationAction.navigationType) - let requestMethod = navigationAction.request.httpMethod ?? "nil" - let requestURL = navigationAction.request.url?.absoluteString ?? "nil" - let targetMainFrame = navigationAction.targetFrame.map { $0.isMainFrame ? "1" : "0" } ?? "nil" - let windowFeaturesSummary = [ - "x=\(windowFeatures.x?.stringValue ?? "nil")", - "y=\(windowFeatures.y?.stringValue ?? "nil")", - "w=\(windowFeatures.width?.stringValue ?? "nil")", - "h=\(windowFeatures.height?.stringValue ?? "nil")", - "toolbars=\(windowFeatures.toolbarsVisibility?.stringValue ?? "nil")", - "resizable=\(windowFeatures.allowsResizing?.stringValue ?? "nil")", - "status=\(windowFeatures.statusBarVisibility?.stringValue ?? "nil")", - "menu=\(windowFeatures.menuBarVisibility?.stringValue ?? "nil")" - ].joined(separator: ",") - cmuxDebugLog( + dlog( "browser.nav.createWebView navType=\(navType) button=\(navigationAction.buttonNumber) " + "mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " + - "targetMain=\(targetMainFrame) method=\(requestMethod) url=\(requestURL) " + - "eventType=\(currentEventType) eventButton=\(currentEventButton) " + - "windowFeatures={\(windowFeaturesSummary)}" + "eventType=\(currentEventType) eventButton=\(currentEventButton)" ) #endif // External URL schemes → hand off to macOS, don't create a popup @@ -6577,50 +7513,11 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString) } #if DEBUG - cmuxDebugLog("browser.navigation.external source=uiDelegate opened=\(opened ? 1 : 0) url=\(browserNavigationDebugURL(url))") + dlog("browser.navigation.external source=uiDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)") #endif return nil } - let hasRecentMiddleClickIntent = CmuxWebView.hasRecentMiddleClickIntent(for: webView) - let popupFeaturesWereSpecified = browserNavigationPopupFeaturesWereSpecified( - x: windowFeatures.x, - y: windowFeatures.y, - width: windowFeatures.width, - height: windowFeatures.height, - menuBarVisibility: windowFeatures.menuBarVisibility, - statusBarVisibility: windowFeatures.statusBarVisibility, - toolbarsVisibility: windowFeatures.toolbarsVisibility, - allowsResizing: windowFeatures.allowsResizing - ) - let shouldOpenSimpleUserGesturePopupInCurrentTab = browserNavigationShouldOpenSimpleUserGesturePopupInCurrentTab( - navigationType: navigationAction.navigationType, - requestMethod: navigationAction.request.httpMethod, - requestURL: navigationAction.request.url, - openerURL: webView.url, - modifierFlags: navigationAction.modifierFlags, - buttonNumber: navigationAction.buttonNumber, - hasRecentMiddleClickIntent: hasRecentMiddleClickIntent, - popupFeaturesWereSpecified: popupFeaturesWereSpecified - ) - - if shouldOpenSimpleUserGesturePopupInCurrentTab { - if let url = navigationAction.request.url { -#if DEBUG - cmuxDebugLog( - "browser.nav.createWebView.action kind=requestNavigationSimpleUserGesture intent=currentTab " + - "url=\(browserNavigationDebugURL(url))" - ) -#endif - if let requestNavigation { - requestNavigation(navigationAction.request, .currentTab) - } else { - browserLoadRequest(navigationAction.request, in: webView) - } - } - return nil - } - // Classifier: only scripted requests (window.open()) get popup windows. // User-initiated actions (link clicks, context menu "Open Link in New Tab", // Cmd+click, middle-click) fall through to existing new-tab behavior. @@ -6632,12 +7529,12 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { navigationType: navigationAction.navigationType, modifierFlags: navigationAction.modifierFlags, buttonNumber: navigationAction.buttonNumber, - hasRecentMiddleClickIntent: hasRecentMiddleClickIntent + hasRecentMiddleClickIntent: CmuxWebView.hasRecentMiddleClickIntent(for: webView) ) if isScriptedPopup, let popupWebView = openPopup?(configuration, windowFeatures) { #if DEBUG - cmuxDebugLog("browser.nav.createWebView.action kind=popup") + dlog("browser.nav.createWebView.action kind=popup") #endif return popupWebView } @@ -6647,15 +7544,15 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { if let requestNavigation { let intent: BrowserInsecureHTTPNavigationIntent = .newTab #if DEBUG - cmuxDebugLog( + dlog( "browser.nav.createWebView.action kind=requestNavigation intent=newTab " + - "url=\(browserNavigationDebugURL(url))" + "url=\(url.absoluteString)" ) #endif requestNavigation(navigationAction.request, intent) } else { #if DEBUG - cmuxDebugLog("browser.nav.createWebView.action kind=openInNewTab url=\(url.absoluteString)") + dlog("browser.nav.createWebView.action kind=openInNewTab url=\(url.absoluteString)") #endif openInNewTab?(url) } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 551c5531c232..f8c908181eab 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -1,9 +1,104 @@ -import Bonsplit import SwiftUI import WebKit import AppKit import ObjectiveC +@MainActor +final class BrowserPanelWorkspaceContentView: NSView { + private let hostingController = NSHostingController(rootView: AnyView(EmptyView())) + private let retainedWebViewHost = WebViewRepresentable.HostContainerView() + private weak var currentPanel: BrowserPanel? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + installHostedViewIfNeeded() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update( + panel: BrowserPanel, + descriptor: WorkspaceBrowserPaneContent, + activeDropZone: DropZone? + ) { + currentPanel = panel + let rootView = AnyView( + BrowserPanelView( + panel: panel, + paneId: descriptor.paneId, + isFocused: descriptor.isFocused, + isVisibleInUI: descriptor.isVisibleInUI, + prefersLocalInlineHosting: descriptor.prefersLocalInlineHosting, + retainedWebViewHost: retainedWebViewHost, + portalPriority: descriptor.portalPriority, + onRequestPanelFocus: descriptor.onRequestPanelFocus + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .environment(\.paneDropZone, activeDropZone) + .transaction { tx in + tx.disablesAnimations = true + } + ) + + hostingController.rootView = rootView + installHostedViewIfNeeded() + } + + func prepareForRemoval(reason: String) { +#if DEBUG + if let currentPanel { + let line = + "browser.workspaceHost.remove ws=\(currentPanel.workspaceId.uuidString.prefix(5)) " + + "panel=\(currentPanel.id.uuidString.prefix(5)) " + + "web=\(browserPanelViewObjectID(currentPanel.webView)) " + + "reason=\(reason)" + dlog(line) + } +#endif + if let currentPanel { + WebViewRepresentable.deactivatePortalHosts( + in: hostingController.view, + panel: currentPanel, + reason: reason + ) + currentPanel.suspendPortalHosting(reason: reason) + } + currentPanel?.setBrowserPortalVisibility( + visibleInUI: false, + zPriority: 0, + source: reason + ) + } + + override func layout() { + super.layout() + hostingController.view.frame = bounds + } + + private func installHostedViewIfNeeded() { + let hostedView = hostingController.view + hostedView.translatesAutoresizingMaskIntoConstraints = true + hostedView.autoresizingMask = [.width, .height] + hostedView.frame = bounds + + if hostedView.superview !== self { + hostedView.removeFromSuperview() + addSubview(hostedView) + } + } + +#if DEBUG + var debugRetainedWebViewHost: NSView { + retainedWebViewHost + } +#endif +} + private var cmuxBrowserPanelNeedsRenderingStateReattachKey: UInt8 = 0 let browserOmnibarTextFieldIdentifier = NSUserInterfaceItemIdentifier("cmux.browserOmnibarTextField") @@ -78,7 +173,7 @@ private extension WKWebView { #if DEBUG if !firedSelectors.isEmpty { - cmuxDebugLog( + dlog( "\(force ? "browser.localHost.webview.forceRefresh" : "browser.localHost.webview.reattach") " + "web=\(browserPanelViewObjectID(self)) " + "reason=\(reason) selectors=\(firedSelectors.joined(separator: ",")) " + @@ -95,7 +190,7 @@ private extension WKWebView { } #if DEBUG if !firedSelectors.isEmpty { - cmuxDebugLog( + dlog( "browser.localHost.webview.hidden web=\(browserPanelViewObjectID(self)) " + "reason=\(reason) selectors=\(firedSelectors.joined(separator: ","))" ) @@ -155,8 +250,8 @@ enum BrowserDevToolsIconOption: String, CaseIterable, Identifiable { } enum BrowserDevToolsIconColorOption: String, CaseIterable, Identifiable { - case bonsplitInactive - case bonsplitActive + case workspaceSplitInactive + case workspaceSplitActive case accent case tertiary @@ -164,8 +259,8 @@ enum BrowserDevToolsIconColorOption: String, CaseIterable, Identifiable { var title: String { switch self { - case .bonsplitInactive: return "Bonsplit Inactive (Terminal/Globe)" - case .bonsplitActive: return "Bonsplit Active (Terminal/Globe)" + case .workspaceSplitInactive: return "WorkspaceSplit Inactive (Terminal/Globe)" + case .workspaceSplitActive: return "WorkspaceSplit Active (Terminal/Globe)" case .accent: return "Accent" case .tertiary: return "Tertiary" } @@ -173,11 +268,11 @@ enum BrowserDevToolsIconColorOption: String, CaseIterable, Identifiable { var color: Color { switch self { - case .bonsplitInactive: - // Matches Bonsplit tab icon tint for inactive tabs. + case .workspaceSplitInactive: + // Matches WorkspaceSplit tab icon tint for inactive tabs. return Color(nsColor: .secondaryLabelColor) - case .bonsplitActive: - // Matches Bonsplit tab icon tint for active tabs. + case .workspaceSplitActive: + // Matches WorkspaceSplit tab icon tint for active tabs. return Color(nsColor: .labelColor) case .accent: return cmuxAccentColor() @@ -191,7 +286,7 @@ enum BrowserDevToolsButtonDebugSettings { static let iconNameKey = "browserDevToolsIconName" static let iconColorKey = "browserDevToolsIconColor" static let defaultIcon = BrowserDevToolsIconOption.wrenchAndScrewdriver - static let defaultColor = BrowserDevToolsIconColorOption.bonsplitInactive + static let defaultColor = BrowserDevToolsIconColorOption.workspaceSplitInactive static func iconOption(defaults: UserDefaults = .standard) -> BrowserDevToolsIconOption { guard let raw = defaults.string(forKey: iconNameKey), @@ -202,11 +297,22 @@ enum BrowserDevToolsButtonDebugSettings { } static func colorOption(defaults: UserDefaults = .standard) -> BrowserDevToolsIconColorOption { - guard let raw = defaults.string(forKey: iconColorKey), - let option = BrowserDevToolsIconColorOption(rawValue: raw) else { + guard let raw = defaults.string(forKey: iconColorKey) else { + return defaultColor + } + if let option = BrowserDevToolsIconColorOption(rawValue: raw) { + return option + } + + // Backward compatibility for pre-WorkspaceSplit raw values. + switch raw { + case "bonsplitInactive": + return .workspaceSplitInactive + case "bonsplitActive": + return .workspaceSplitActive + default: return defaultColor } - return option } static func copyPayload(defaults: UserDefaults = .standard) -> String { @@ -380,6 +486,24 @@ private struct BrowserChromeStyle { } } +enum BrowserFindOverlayPresentationMode: Equatable { + case none + case swiftUI + case appKitPortal +} + +func resolveBrowserFindOverlayPresentationMode( + hasSearchState: Bool, + shouldRenderWebView: Bool, + usesLocalInlineHosting: Bool +) -> BrowserFindOverlayPresentationMode { + guard hasSearchState else { return .none } + if !shouldRenderWebView || usesLocalInlineHosting { + return .swiftUI + } + return .appKitPortal +} + /// View for rendering a browser panel with address bar struct BrowserPanelView: View { @ObservedObject var panel: BrowserPanel @@ -387,12 +511,13 @@ struct BrowserPanelView: View { let paneId: PaneID let isFocused: Bool let isVisibleInUI: Bool + let prefersLocalInlineHosting: Bool + let retainedWebViewHost: WebViewRepresentable.HostContainerView? let portalPriority: Int let onRequestPanelFocus: () -> Void @Environment(\.colorScheme) private var colorScheme @Environment(\.paneDropZone) private var paneDropZone @State private var omnibarState = OmnibarState() - @State private var addressBarFocused: Bool = false @AppStorage(BrowserSearchSettings.searchEngineKey) private var searchEngineRaw = BrowserSearchSettings.defaultSearchEngine.rawValue @AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var searchSuggestionsEnabledStorage = BrowserSearchSettings.defaultSearchSuggestionsEnabled @AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var devToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue @@ -422,10 +547,10 @@ struct BrowserPanelView: View { @State private var focusFlashAnimationGeneration: Int = 0 @State private var omnibarPillFrame: CGRect = .zero @State private var addressBarHeight: CGFloat = 0 + @State private var omnibarSelectAllRequestGeneration: UInt64 = 0 @State private var isBrowserImportHintPopoverPresented = false + @State private var pendingBrowserImportDialogPresentation = false @State private var lastHandledAddressBarFocusRequestId: UUID? - @State private var pendingAddressBarFocusRetryRequestId: UUID? - @State private var pendingAddressBarFocusRetryGeneration: UInt64 = 0 @State private var isBrowserProfileMenuPresented = false @State private var isBrowserThemeMenuPresented = false @State private var browserChromeStyle = BrowserChromeStyle.resolve( @@ -549,9 +674,92 @@ struct BrowserPanelView: View { return currentPaneId.id == paneId.id } + private var usesLocalInlineHostingForFindOverlay: Bool { + isCurrentPaneOwner && + (prefersLocalInlineHosting || panel.shouldUseLocalInlineDeveloperToolsHosting()) + } + + private var searchOverlayConfiguration: BrowserPortalSearchOverlayConfiguration? { + panel.searchState.map { searchState in + BrowserPortalSearchOverlayConfiguration( + panelId: panel.id, + searchState: searchState, + focusRequestId: panel.pendingFindFieldFocusRequestId, + canApplyFocusRequest: { requestId in + canApplyBrowserFindFieldFocusRequest(requestId) + }, + onNext: { panel.findNext() }, + onPrevious: { panel.findPrevious() }, + onClose: { panel.hideFind(reason: "overlay") }, + onFieldMounted: { requestId in + panel.noteFindFieldMounted(requestId: requestId) + }, + onFieldDidFocus: { requestId in + panel.noteFindFieldFocused(requestId: requestId) + }, + onFieldDidEndEditing: { requestId in + panel.noteFindFieldEndedEditing(requestId: requestId, source: "fieldEndEditing") + }, + onDisappear: { + panel.noteFindOverlayDisappeared(source: "overlayDisappear") + } + ) + } + } + + private var searchOverlayPresentationMode: BrowserFindOverlayPresentationMode { + resolveBrowserFindOverlayPresentationMode( + hasSearchState: panel.searchState != nil, + shouldRenderWebView: panel.shouldRenderWebView, + usesLocalInlineHosting: usesLocalInlineHostingForFindOverlay + ) + } + var body: some View { - // Layering contract: browser find UI is mounted in the portal-hosted AppKit - // container. Rendering it here can hide it behind the portal-hosted WKWebView. + panelContentWithNotifications + } + + private var panelContentWithPreferences: some View { + layeredPanelContent + .coordinateSpace(name: "BrowserPanelViewSpace") + .onPreferenceChange(OmnibarPillFramePreferenceKey.self) { frame in + omnibarPillFrame = frame + } + .onPreferenceChange(BrowserAddressBarHeightPreferenceKey.self) { height in + addressBarHeight = height + } + } + + private var panelContentWithPrimaryEvents: some View { + panelContentWithPreferences + .onReceive(NotificationCenter.default.publisher(for: .webViewDidReceiveClick), perform: handleWebViewDidReceiveClick) + .onAppear(perform: handleViewAppear) + .onChange(of: panel.focusFlashToken) { _, newValue in handleFocusFlashTokenChange(newValue) } + .onChange(of: panel.currentURL) { _, newValue in handleCurrentURLChange(newValue) } + .onChange(of: browserThemeModeRaw) { _, newValue in handleBrowserThemeModeStorageChange(newValue) } + .onChange(of: colorScheme) { _, newValue in handleColorSchemeChange(newValue) } + .onChange(of: panel.pendingAddressBarFocusRequestId) { _, newValue in handlePendingAddressBarFocusRequestChange(newValue) } + .onChange(of: panel.profileID) { _, newValue in handleProfileIDChange(newValue) } + .onChange(of: isBrowserImportHintPopoverPresented) { _, newValue in handleImportHintPopoverPresentationChange(newValue) } + } + + private var panelContentWithNotifications: some View { + panelContentWithPrimaryEvents + .onChange(of: isBrowserProfileMenuPresented) { _, newValue in handleProfileMenuPresentationChange(newValue) } + .onChange(of: isVisibleInUI) { _, newValue in handleVisibleInUIChange(newValue) } + .onChange(of: isFocused) { _, newValue in handlePanelFocusChange(newValue) } + .onChange(of: addressBarFocused) { _, newValue in handleAddressBarFocusChange(newValue) } + .onReceive(NotificationCenter.default.publisher(for: .browserMoveOmnibarSelection), perform: handleBrowserMoveOmnibarSelection) + .onReceive(panel.historyStore.$entries, perform: handleHistoryEntriesChange) + .onReceive(NotificationCenter.default.publisher(for: .browserDidBlurAddressBar), perform: handleExternalAddressBarBlur) + .onReceive(NotificationCenter.default.publisher(for: .commandPaletteVisibilityDidChange), perform: handleCommandPaletteVisibilityDidChange) + .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange), perform: handleGhosttyDefaultBackgroundDidChange) + } + + private var layeredPanelContent: some View { + // Portal-hosted browsers need the find UI inside their AppKit container. + // Direct-pane local-inline browsers own their visible surface here, so the + // SwiftUI layer is the correct owner for the find overlay on that path. VStack(spacing: 0) { addressBar .fixedSize(horizontal: false, vertical: true) @@ -559,21 +767,20 @@ struct BrowserPanelView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .overlay { - // Keep browser find usable when the browser is still in the empty new-tab - // state (no WKWebView mounted yet). WebView-backed cases are hosted - // in AppKit by WindowBrowserPortal to avoid layering/clipping issues. - if !panel.shouldRenderWebView, let searchState = panel.searchState { + if searchOverlayPresentationMode == .swiftUI, + let configuration = searchOverlayConfiguration { BrowserSearchOverlay( - panelId: panel.id, - searchState: searchState, - focusRequestGeneration: panel.searchFocusRequestGeneration, - canApplyFocusRequest: { generation in - canApplyBrowserFindFieldFocusRequest(generation) - }, - onNext: { panel.findNext() }, - onPrevious: { panel.findPrevious() }, - onClose: { panel.hideFind() }, - onFieldDidFocus: { panel.noteFindFieldFocused() } + panelId: configuration.panelId, + searchState: configuration.searchState, + focusRequestId: configuration.focusRequestId, + canApplyFocusRequest: configuration.canApplyFocusRequest, + onNext: configuration.onNext, + onPrevious: configuration.onPrevious, + onClose: configuration.onClose, + onFieldMounted: configuration.onFieldMounted, + onFieldDidFocus: configuration.onFieldDidFocus, + onFieldDidEndEditing: configuration.onFieldDidEndEditing, + onDisappear: configuration.onDisappear ) } } @@ -606,236 +813,259 @@ struct BrowserPanelView: View { .environment(\.colorScheme, browserChromeColorScheme) } } - .coordinateSpace(name: "BrowserPanelViewSpace") - .onPreferenceChange(OmnibarPillFramePreferenceKey.self) { frame in - omnibarPillFrame = frame - } - .onPreferenceChange(BrowserAddressBarHeightPreferenceKey.self) { height in - addressBarHeight = height - } - .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 + } + + private func handleViewAppear() { + UserDefaults.standard.register(defaults: [ + BrowserSearchSettings.searchEngineKey: BrowserSearchSettings.defaultSearchEngine.rawValue, + BrowserSearchSettings.searchSuggestionsEnabledKey: BrowserSearchSettings.defaultSearchSuggestionsEnabled, + BrowserToolbarAccessorySpacingDebugSettings.key: BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing, + BrowserProfilePopoverDebugSettings.horizontalPaddingKey: BrowserProfilePopoverDebugSettings.defaultHorizontalPadding, + BrowserProfilePopoverDebugSettings.verticalPaddingKey: BrowserProfilePopoverDebugSettings.defaultVerticalPadding, + BrowserThemeSettings.modeKey: BrowserThemeSettings.defaultMode.rawValue, + ]) + refreshBrowserChromeStyle() + let resolvedThemeMode = BrowserThemeSettings.mode(defaults: .standard) + if browserThemeModeRaw != resolvedThemeMode.rawValue { + browserThemeModeRaw = resolvedThemeMode.rawValue + } + let resolvedHintVariant = BrowserImportHintSettings.variant(for: browserImportHintVariantRaw) + if browserImportHintVariantRaw != resolvedHintVariant.rawValue { + browserImportHintVariantRaw = resolvedHintVariant.rawValue + } + let resolvedToolbarAccessorySpacing = BrowserToolbarAccessorySpacingDebugSettings.resolved(browserToolbarAccessorySpacingRaw) + if browserToolbarAccessorySpacingRaw != resolvedToolbarAccessorySpacing { + browserToolbarAccessorySpacingRaw = resolvedToolbarAccessorySpacing + } + let resolvedProfilePopoverHorizontalPadding = BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(browserProfilePopoverHorizontalPaddingRaw) + if browserProfilePopoverHorizontalPaddingRaw != resolvedProfilePopoverHorizontalPadding { + browserProfilePopoverHorizontalPaddingRaw = resolvedProfilePopoverHorizontalPadding + } + let resolvedProfilePopoverVerticalPadding = BrowserProfilePopoverDebugSettings.resolvedVerticalPadding(browserProfilePopoverVerticalPaddingRaw) + if browserProfilePopoverVerticalPaddingRaw != resolvedProfilePopoverVerticalPadding { + browserProfilePopoverVerticalPaddingRaw = resolvedProfilePopoverVerticalPadding + } + panel.refreshAppearanceDrivenColors() + panel.setBrowserThemeMode(browserThemeMode) + panel.notePanelFocusChanged(isFocused) + applyPendingAddressBarFocusRequestIfNeeded() + syncURLFromPanel() + // If the browser surface is focused but has no URL loaded yet, auto-focus the omnibar. + autoFocusOmnibarIfBlank() + refreshEmptyStateImportBrowsers() + panel.historyStore.loadIfNeeded() #if DEBUG - cmuxDebugLog( - "browser.focus.clickIntent panel=\(panel.id.uuidString.prefix(5)) " + - "isFocused=\(isFocused ? 1 : 0) " + - "addressFocused=\(addressBarFocused ? 1 : 0)" - ) + logBrowserFocusState(event: "view.onAppear") #endif - if addressBarFocused { + } + + private func handleWebViewDidReceiveClick(_ notification: Notification) { + guard let webView = notification.object as? CmuxWebView, + webView === panel.webView else { return } #if DEBUG - logBrowserFocusState(event: "addressBarFocus.webViewClickBlur") + dlog( + "browser.focus.clickIntent panel=\(panel.id.uuidString.prefix(5)) " + + "isFocused=\(isFocused ? 1 : 0) " + + "addressFocused=\(addressBarFocused ? 1 : 0)" + ) #endif - setAddressBarFocused(false, reason: "webView.clickIntent") - } - if !isFocused { - onRequestPanelFocus() - } - } - .onAppear { - UserDefaults.standard.register(defaults: [ - BrowserSearchSettings.searchEngineKey: BrowserSearchSettings.defaultSearchEngine.rawValue, - BrowserSearchSettings.searchSuggestionsEnabledKey: BrowserSearchSettings.defaultSearchSuggestionsEnabled, - BrowserToolbarAccessorySpacingDebugSettings.key: BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing, - BrowserProfilePopoverDebugSettings.horizontalPaddingKey: BrowserProfilePopoverDebugSettings.defaultHorizontalPadding, - BrowserProfilePopoverDebugSettings.verticalPaddingKey: BrowserProfilePopoverDebugSettings.defaultVerticalPadding, - BrowserThemeSettings.modeKey: BrowserThemeSettings.defaultMode.rawValue, - ]) - refreshBrowserChromeStyle() - let resolvedThemeMode = BrowserThemeSettings.mode(defaults: .standard) - if browserThemeModeRaw != resolvedThemeMode.rawValue { - browserThemeModeRaw = resolvedThemeMode.rawValue - } - let resolvedHintVariant = BrowserImportHintSettings.variant(for: browserImportHintVariantRaw) - if browserImportHintVariantRaw != resolvedHintVariant.rawValue { - browserImportHintVariantRaw = resolvedHintVariant.rawValue - } - let resolvedToolbarAccessorySpacing = BrowserToolbarAccessorySpacingDebugSettings.resolved(browserToolbarAccessorySpacingRaw) - if browserToolbarAccessorySpacingRaw != resolvedToolbarAccessorySpacing { - browserToolbarAccessorySpacingRaw = resolvedToolbarAccessorySpacing - } - let resolvedProfilePopoverHorizontalPadding = BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(browserProfilePopoverHorizontalPaddingRaw) - if browserProfilePopoverHorizontalPaddingRaw != resolvedProfilePopoverHorizontalPadding { - browserProfilePopoverHorizontalPaddingRaw = resolvedProfilePopoverHorizontalPadding - } - let resolvedProfilePopoverVerticalPadding = BrowserProfilePopoverDebugSettings.resolvedVerticalPadding(browserProfilePopoverVerticalPaddingRaw) - if browserProfilePopoverVerticalPaddingRaw != resolvedProfilePopoverVerticalPadding { - browserProfilePopoverVerticalPaddingRaw = resolvedProfilePopoverVerticalPadding - } - panel.refreshAppearanceDrivenColors() - panel.setBrowserThemeMode(browserThemeMode) - applyPendingAddressBarFocusRequestIfNeeded() - syncURLFromPanel() - // If the browser surface is focused but has no URL loaded yet, auto-focus the omnibar. - autoFocusOmnibarIfBlank() - syncWebViewResponderPolicyWithViewState(reason: "onAppear") - refreshEmptyStateImportBrowsers() - panel.historyStore.loadIfNeeded() + if addressBarFocused { #if DEBUG - logBrowserFocusState(event: "view.onAppear") + logBrowserFocusState(event: "addressBarFocus.webViewClickBlur") #endif + setAddressBarFocused(false, reason: "webView.clickIntent") } - .onChange(of: panel.focusFlashToken) { _ in - triggerFocusFlashAnimation() - } - .onChange(of: panel.currentURL) { _ in - let addressWasEmpty = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - syncURLFromPanel() - // If we auto-focused a blank omnibar but then a URL loads programmatically, move focus - // into WebKit unless the user had already started typing. - if addressBarFocused, - !panel.shouldSuppressWebViewFocus(), - addressWasEmpty, - !isWebViewBlank() { - setAddressBarFocused(false, reason: "panel.currentURL.loaded") - } - if isWebViewBlank() { - refreshEmptyStateImportBrowsers() - } - panel.resetReactGrabState( - preserveRoundTrip: true, - reason: "panel.currentURL.changed" - ) + panel.noteWebViewFocused() + if !isFocused { + onRequestPanelFocus() } - .onChange(of: browserThemeModeRaw) { _ in - let normalizedMode = BrowserThemeSettings.mode(for: browserThemeModeRaw) - if browserThemeModeRaw != normalizedMode.rawValue { - browserThemeModeRaw = normalizedMode.rawValue - } - panel.setBrowserThemeMode(normalizedMode) + } + + private func handleFocusFlashTokenChange(_: Int) { + triggerFocusFlashAnimation() + } + + private func handleCurrentURLChange(_: URL?) { + let addressWasEmpty = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + syncURLFromPanel() + // If we auto-focused a blank omnibar but then a URL loads programmatically, move focus + // into WebKit unless the user had already started typing. + if addressBarFocused, + !panel.shouldSuppressWebViewFocus(), + addressWasEmpty, + !isWebViewBlank() { + setAddressBarFocused(false, reason: "panel.currentURL.loaded") } - .onChange(of: colorScheme) { _ in - refreshBrowserChromeStyle() - panel.refreshAppearanceDrivenColors() + if isWebViewBlank() { + refreshEmptyStateImportBrowsers() } - .onChange(of: panel.pendingAddressBarFocusRequestId) { _ in - applyPendingAddressBarFocusRequestIfNeeded() + panel.resetReactGrabState( + preserveRoundTrip: true, + reason: "panel.currentURL.changed" + ) + } + + private func handleBrowserThemeModeStorageChange(_: String) { + let normalizedMode = BrowserThemeSettings.mode(for: browserThemeModeRaw) + if browserThemeModeRaw != normalizedMode.rawValue { + browserThemeModeRaw = normalizedMode.rawValue } - .onChange(of: panel.profileID) { _ in - panel.historyStore.loadIfNeeded() - if addressBarFocused { - refreshSuggestions() - } + panel.setBrowserThemeMode(normalizedMode) + } + + private func handleColorSchemeChange(_: ColorScheme) { + refreshBrowserChromeStyle() + panel.refreshAppearanceDrivenColors() + } + + private func handlePendingAddressBarFocusRequestChange(_: UUID?) { + applyPendingAddressBarFocusRequestIfNeeded() + } + + private func handleProfileIDChange(_: UUID) { + panel.historyStore.loadIfNeeded() + if addressBarFocused { + refreshSuggestions() } - .onChange(of: isVisibleInUI) { visibleInUI in - if visibleInUI { - panel.cancelPendingDeveloperToolsVisibilityLossCheck() - return - } - if panel.shouldUseLocalInlineDeveloperToolsHosting() { - // Workspace switches keep the attached inspector alive off-screen. - // Treating that hide as a manual X-close can clear the restore intent - // before the original local-inline host becomes visible again. - panel.cancelPendingDeveloperToolsVisibilityLossCheck() - return - } - // Pane/workspace churn can briefly mark the browser hidden before the - // final host settles. Only treat a stable hide as a signal to consume - // an attached-inspector X-close. - panel.scheduleDeveloperToolsVisibilityLossCheck() + } + + private func handleImportHintPopoverPresentationChange(_: Bool) { + flushPendingBrowserImportDialogPresentationIfNeeded() + } + + private func handleProfileMenuPresentationChange(_: Bool) { + flushPendingBrowserImportDialogPresentationIfNeeded() + } + + private func handleVisibleInUIChange(_ visibleInUI: Bool) { +#if DEBUG + let line = + "browser.visibleInUI ws=\(panel.workspaceId.uuidString.prefix(5)) " + + "panel=\(panel.id.uuidString.prefix(5)) web=\(browserPanelViewObjectID(panel.webView)) " + + "next=\(visibleInUI ? 1 : 0) focused=\(isFocused ? 1 : 0) " + + "window=\(panel.webView.window?.windowNumber ?? -1)" + dlog(line) +#endif + if visibleInUI { + panel.cancelPendingDeveloperToolsVisibilityLossCheck() + return } - .onChange(of: isFocused) { focused in + if panel.shouldUseLocalInlineDeveloperToolsHosting() { + // Workspace switches keep the attached inspector alive off-screen. + // Treating that hide as a manual X-close can clear the restore intent + // before the original local-inline host becomes visible again. + panel.cancelPendingDeveloperToolsVisibilityLossCheck() + return + } + // Pane/workspace churn can briefly mark the browser hidden before the + // final host settles. Only treat a stable hide as a signal to consume + // an attached-inspector X-close. + panel.scheduleDeveloperToolsVisibilityLossCheck() + } + + private func handlePanelFocusChange(_ focused: Bool) { #if DEBUG - logBrowserFocusState( - event: "panelFocus.onChange", - detail: "next=\(focused ? 1 : 0)" - ) + logBrowserFocusState( + event: "panelFocus.onChange", + detail: "next=\(focused ? 1 : 0)" + ) + panel.debugLogWebContentFocusSnapshot( + event: focused ? "panelFocus.focus" : "panelFocus.blur", + detail: "next=\(focused ? 1 : 0)" + ) #endif - // Ensure this view doesn't retain focus while hidden (bonsplit keepAllAlive). - if focused { - applyPendingAddressBarFocusRequestIfNeeded() - autoFocusOmnibarIfBlank() - } else { - panel.invalidateAddressBarPageFocusRestoreAttempts() - hideSuggestions() - setAddressBarFocused(false, reason: "panelFocus.onChange.unfocused") - // Surface switches in split layouts can keep the browser visible, so - // `isVisibleInUI` never flips to false. Check for an attached-inspector - // X-close when focus leaves as well so the persisted intent stays in sync. - DispatchQueue.main.async { - guard isVisibleInUI else { return } - panel.scheduleDeveloperToolsVisibilityLossCheck() - } + // Ensure this view doesn't retain focus while hidden (WorkspaceSplit keepAllAlive). + panel.notePanelFocusChanged(focused) + if focused { + applyPendingAddressBarFocusRequestIfNeeded() + autoFocusOmnibarIfBlank() + } else { + hideSuggestions() + setAddressBarFocused(false, reason: "panelFocus.onChange.unfocused") + // Surface switches in split layouts can keep the browser visible, so + // `isVisibleInUI` never flips to false. Check for an attached-inspector + // X-close when focus leaves as well so the persisted intent stays in sync. + DispatchQueue.main.async { + guard isVisibleInUI else { return } + panel.scheduleDeveloperToolsVisibilityLossCheck() } - syncWebViewResponderPolicyWithViewState( - reason: "panelFocusChanged", - isPanelFocusedOverride: focused - ) } - .onChange(of: addressBarFocused) { focused in + } + + private func handleAddressBarFocusChange(_ focused: Bool) { #if DEBUG - logBrowserFocusState( - event: "addressBarFocus.onChange", - detail: "next=\(focused ? 1 : 0)" - ) + logBrowserFocusState( + event: "addressBarFocus.onChange", + detail: "next=\(focused ? 1 : 0)" + ) #endif - let urlString = panel.preferredURLStringForOmnibar() ?? "" - if focused { - panel.beginSuppressWebViewFocusForAddressBar() - NotificationCenter.default.post(name: .browserDidFocusAddressBar, object: panel.id) - // Only request panel focus if this pane isn't currently focused. When already - // focused (e.g. Cmd+L), forcing focus can steal first responder back to WebKit. - if !isFocused { + let urlString = panel.preferredURLStringForOmnibar() ?? "" + if focused { + // Only request panel focus if this pane isn't currently focused. When already + // focused (e.g. Cmd+L), forcing focus can steal first responder back to WebKit. + if !isFocused { #if DEBUG - logBrowserFocusState(event: "addressBarFocus.requestPanelFocus") + logBrowserFocusState(event: "addressBarFocus.requestPanelFocus") #endif - onRequestPanelFocus() - } - let effects = omnibarReduce(state: &omnibarState, event: .focusGained(currentURLString: urlString)) + onRequestPanelFocus() + } + let effects = omnibarReduce(state: &omnibarState, event: .focusGained(currentURLString: urlString)) + applyOmnibarEffects(effects) + refreshInlineCompletion() + } else { + if suppressNextFocusLostRevert { + suppressNextFocusLostRevert = false + let effects = omnibarReduce(state: &omnibarState, event: .focusLostPreserveBuffer(currentURLString: urlString)) applyOmnibarEffects(effects) - refreshInlineCompletion() } else { - panel.endSuppressWebViewFocusForAddressBar() - NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: panel.id) - if suppressNextFocusLostRevert { - suppressNextFocusLostRevert = false - let effects = omnibarReduce(state: &omnibarState, event: .focusLostPreserveBuffer(currentURLString: urlString)) - applyOmnibarEffects(effects) - } else { - let effects = omnibarReduce(state: &omnibarState, event: .focusLostRevertBuffer(currentURLString: urlString)) - applyOmnibarEffects(effects) - } - inlineCompletion = nil + let effects = omnibarReduce(state: &omnibarState, event: .focusLostRevertBuffer(currentURLString: urlString)) + applyOmnibarEffects(effects) } - syncWebViewResponderPolicyWithViewState(reason: "addressBarFocusChanged") + inlineCompletion = nil + } #if DEBUG - logBrowserFocusState(event: "addressBarFocus.onChange.applied") + logBrowserFocusState(event: "addressBarFocus.onChange.applied") #endif - } - .onReceive(NotificationCenter.default.publisher(for: .browserMoveOmnibarSelection)) { notification in - guard let panelId = notification.object as? UUID, panelId == panel.id else { return } - guard addressBarFocused, !omnibarState.suggestions.isEmpty else { return } - guard let delta = notification.userInfo?["delta"] as? Int, delta != 0 else { return } + } + + private func handleBrowserMoveOmnibarSelection(_ notification: Notification) { + guard let panelId = notification.object as? UUID, + panelId == panel.id else { return } + guard addressBarFocused, !omnibarState.suggestions.isEmpty else { return } + guard let delta = notification.userInfo?["delta"] as? Int, delta != 0 else { return } #if DEBUG - logBrowserFocusState(event: "addressBarFocus.moveSelection", detail: "delta=\(delta)") + logBrowserFocusState(event: "addressBarFocus.moveSelection", detail: "delta=\(delta)") #endif - let effects = omnibarReduce(state: &omnibarState, event: .moveSelection(delta: delta)) - applyOmnibarEffects(effects) - refreshInlineCompletion() - } - .onReceive(panel.historyStore.$entries) { _ in - guard addressBarFocused else { return } - refreshSuggestions() - } - .onReceive(NotificationCenter.default.publisher(for: .browserDidBlurAddressBar).filter { note in - guard let panelId = note.object as? UUID else { return false } - return panelId == panel.id - }) { _ in - if addressBarFocused { + let effects = omnibarReduce(state: &omnibarState, event: .moveSelection(delta: delta)) + applyOmnibarEffects(effects) + refreshInlineCompletion() + } + + private func handleHistoryEntriesChange(_: [BrowserHistoryStore.Entry]) { + guard addressBarFocused else { return } + refreshSuggestions() + } + + private func handleExternalAddressBarBlur(_ notification: Notification) { + guard let panelId = notification.object as? UUID, + panelId == panel.id else { return } + if addressBarFocused { #if DEBUG - logBrowserFocusState(event: "addressBarFocus.externalBlur") + logBrowserFocusState(event: "addressBarFocus.externalBlur") #endif - setAddressBarFocused(false, reason: "notification.externalBlur") - } - } - .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in - refreshBrowserChromeStyle() + setAddressBarFocused(false, reason: "notification.externalBlur") } } + private func handleCommandPaletteVisibilityDidChange(_ notification: Notification) { + guard shouldHandleCommandPaletteVisibilityChange(notification) else { return } + applyPendingAddressBarFocusRequestIfNeeded() + } + + private func handleGhosttyDefaultBackgroundDidChange(_: Notification) { + refreshBrowserChromeStyle() + } + private var addressBar: some View { HStack(spacing: 8) { addressBarButtonBar @@ -875,7 +1105,7 @@ struct BrowserPanelView: View { return HStack(spacing: 0) { Button(action: { #if DEBUG - cmuxDebugLog("browser.back panel=\(panel.id.uuidString.prefix(5))") + dlog("browser.back panel=\(panel.id.uuidString.prefix(5))") #endif panel.goBack() }) { @@ -891,7 +1121,7 @@ struct BrowserPanelView: View { Button(action: { #if DEBUG - cmuxDebugLog("browser.forward panel=\(panel.id.uuidString.prefix(5))") + dlog("browser.forward panel=\(panel.id.uuidString.prefix(5))") #endif panel.goForward() }) { @@ -908,12 +1138,12 @@ struct BrowserPanelView: View { Button(action: { if panel.isLoading { #if DEBUG - cmuxDebugLog("browser.stop panel=\(panel.id.uuidString.prefix(5))") + dlog("browser.stop panel=\(panel.id.uuidString.prefix(5))") #endif panel.stopLoading() } else { #if DEBUG - cmuxDebugLog("browser.reload panel=\(panel.id.uuidString.prefix(5))") + dlog("browser.reload panel=\(panel.id.uuidString.prefix(5))") #endif panel.reload() } @@ -1176,7 +1406,8 @@ struct BrowserPanelView: View { refreshInlineCompletion() } ), - isFocused: $addressBarFocused, + isFocused: addressBarFocusBinding, + selectAllRequestGeneration: omnibarSelectAllRequestGeneration, inlineCompletion: inlineCompletion, placeholder: String(localized: "browser.addressBar.placeholder", defaultValue: "Search or enter URL"), onTap: { @@ -1246,42 +1477,35 @@ struct BrowserPanelView: View { } private var webView: some View { + let useLocalInlineHosting = prefersLocalInlineHosting && isCurrentPaneOwner let useLocalInlineDeveloperToolsHosting = panel.shouldUseLocalInlineDeveloperToolsHosting() && isCurrentPaneOwner + let usesLocalInlineHosting = useLocalInlineHosting || useLocalInlineDeveloperToolsHosting return Group { if panel.shouldRenderWebView { WebViewRepresentable( panel: panel, paneId: paneId, - shouldAttachWebView: isVisibleInUI && isCurrentPaneOwner && !useLocalInlineDeveloperToolsHosting, - useLocalInlineHosting: useLocalInlineDeveloperToolsHosting, + shouldAttachWebView: isVisibleInUI && isCurrentPaneOwner && !usesLocalInlineHosting, + useLocalInlineHosting: usesLocalInlineHosting, + retainedHostView: retainedWebViewHost, shouldFocusWebView: isFocused && !addressBarFocused, isPanelFocused: isFocused, portalZPriority: portalPriority, paneDropZone: paneDropZone, - searchOverlay: panel.searchState.map { searchState in - BrowserPortalSearchOverlayConfiguration( - panelId: panel.id, - searchState: searchState, - focusRequestGeneration: panel.searchFocusRequestGeneration, - canApplyFocusRequest: { generation in - canApplyBrowserFindFieldFocusRequest(generation) - }, - onNext: { panel.findNext() }, - onPrevious: { panel.findPrevious() }, - onClose: { panel.hideFind() }, - onFieldDidFocus: { panel.noteFindFieldFocused() } - ) - }, + searchOverlay: searchOverlayPresentationMode == .appKitPortal + ? searchOverlayConfiguration + : nil, paneTopChromeHeight: addressBarHeight ) .accessibilityIdentifier("BrowserWebViewSurface") // Keep the host stable for normal pane churn, but force a remount when - // BrowserPanel replaces its underlying WKWebView after process termination - // or when the browser moves to a different Bonsplit pane host. - .id("\(panel.webViewInstanceID.uuidString)-\(paneId.id.uuidString)") + // BrowserPanel replaces its underlying WKWebView after process termination. + // Pane moves now keep the same retained host view, so pane identity must not + // force a remount during browser drag/split reparenting. + .id(panel.webViewInstanceID.uuidString) .contentShape(Rectangle()) .accessibilityIdentifier(browserContentAccessibilityIdentifier) .simultaneousGesture(TapGesture().onEnded { @@ -1354,24 +1578,15 @@ struct BrowserPanelView: View { ) } - private func syncWebViewResponderPolicyWithViewState( - reason: String, - isPanelFocusedOverride: Bool? = nil - ) { - guard let cmuxWebView = panel.webView as? CmuxWebView else { return } - let isPanelFocused = isPanelFocusedOverride ?? isFocused - let next = isPanelFocused && !panel.shouldSuppressWebViewFocus() - if cmuxWebView.allowsFirstResponderAcquisition != next { -#if DEBUG - cmuxDebugLog( - "browser.focus.policy.resync panel=\(panel.id.uuidString.prefix(5)) " + - "web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " + - "new=\(next ? 1 : 0) reason=\(reason) " + - "panelFocusedUsed=\(isPanelFocused ? 1 : 0)" - ) -#endif - } - cmuxWebView.allowsFirstResponderAcquisition = next + private var addressBarFocused: Bool { + panel.isAddressBarFocused + } + + private var addressBarFocusBinding: Binding { + Binding( + get: { panel.isAddressBarFocused }, + set: { setAddressBarFocused($0, reason: "omnibar.binding") } + ) } private func setAddressBarFocused(_ focused: Bool, reason: String) { @@ -1388,10 +1603,7 @@ struct BrowserPanelView: View { ) } #endif - addressBarFocused = focused - if focused { - panel.noteAddressBarFocused() - } + panel.setAddressBarFocused(focused, reason: reason) } private func browserFocusResponderChainContains( @@ -1418,8 +1630,8 @@ struct BrowserPanelView: View { return workspace.focusedPanelId == panel.id } - private func canApplyBrowserFindFieldFocusRequest(_ generation: UInt64) -> Bool { - isPanelFocusedInModel() && panel.canApplySearchFocusRequest(generation) + private func canApplyBrowserFindFieldFocusRequest(_ requestId: UUID) -> Bool { + isPanelFocusedInModel() && panel.canApplyFindFieldFocusRequest(requestId) } private func shouldApplyAddressBarExitFallback(in window: NSWindow) -> Bool { @@ -1458,7 +1670,7 @@ struct BrowserPanelView: View { if !detail.isEmpty { line += " \(detail)" } - cmuxDebugLog(line) + dlog(line) } #endif @@ -1491,27 +1703,28 @@ struct BrowserPanelView: View { return false } - private func clearPendingAddressBarFocusRetry() { - pendingAddressBarFocusRetryRequestId = nil - pendingAddressBarFocusRetryGeneration &+= 1 + private func commandPaletteRelevantWindow() -> NSWindow? { + if let window = panel.webView.window { + return window + } + guard let app = AppDelegate.shared, + let manager = app.tabManagerFor(tabId: panel.workspaceId), + let windowId = app.windowId(for: manager), + let window = app.mainWindow(for: windowId) else { + return NSApp.keyWindow ?? NSApp.mainWindow + } + return window } - private func schedulePendingAddressBarFocusRetryIfNeeded(requestId: UUID) { - guard pendingAddressBarFocusRetryRequestId != requestId else { return } - pendingAddressBarFocusRetryRequestId = requestId - pendingAddressBarFocusRetryGeneration &+= 1 - let generation = pendingAddressBarFocusRetryGeneration - DispatchQueue.main.asyncAfter(deadline: .now() + 0.10) { - guard pendingAddressBarFocusRetryGeneration == generation else { return } - pendingAddressBarFocusRetryRequestId = nil - guard panel.pendingAddressBarFocusRequestId == requestId else { return } - applyPendingAddressBarFocusRequestIfNeeded() - } + private func shouldHandleCommandPaletteVisibilityChange(_ notification: Notification) -> Bool { + guard panel.pendingAddressBarFocusRequestId != nil else { return false } + guard let window = notification.object as? NSWindow else { return true } + guard let relevantWindow = commandPaletteRelevantWindow() else { return true } + return window === relevantWindow } private func applyPendingAddressBarFocusRequestIfNeeded() { guard let requestId = panel.pendingAddressBarFocusRequestId else { - clearPendingAddressBarFocusRetry() return } guard !isCommandPaletteVisibleForPanelWindow() else { @@ -1521,10 +1734,8 @@ struct BrowserPanelView: View { detail: "reason=command_palette_visible request=\(requestId.uuidString.prefix(8))" ) #endif - schedulePendingAddressBarFocusRetryIfNeeded(requestId: requestId) return } - clearPendingAddressBarFocusRetry() guard lastHandledAddressBarFocusRequestId != requestId else { #if DEBUG logBrowserFocusState( @@ -1535,7 +1746,6 @@ struct BrowserPanelView: View { return } lastHandledAddressBarFocusRequestId = requestId - panel.beginSuppressWebViewFocusForAddressBar() #if DEBUG logBrowserFocusState( event: "addressBarFocus.request.apply", @@ -1695,22 +1905,15 @@ struct BrowserPanelView: View { } private func presentImportDialogFromHint() { + pendingBrowserImportDialogPresentation = true isBrowserImportHintPopoverPresented = false - // Let the popover fully dismiss before entering the modal import flow. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { - BrowserDataImportCoordinator.shared.presentImportDialog( - defaultDestinationProfileID: panel.profileID - ) - } + flushPendingBrowserImportDialogPresentationIfNeeded() } private func presentImportDialogFromProfileMenu() { + pendingBrowserImportDialogPresentation = true isBrowserProfileMenuPresented = false - DispatchQueue.main.async { - BrowserDataImportCoordinator.shared.presentImportDialog( - defaultDestinationProfileID: panel.profileID - ) - } + flushPendingBrowserImportDialogPresentationIfNeeded() } private func openBrowserImportSettings() { @@ -1724,6 +1927,15 @@ struct BrowserPanelView: View { isBrowserImportHintPopoverPresented = false } + private func flushPendingBrowserImportDialogPresentationIfNeeded() { + guard pendingBrowserImportDialogPresentation else { return } + guard !isBrowserImportHintPopoverPresented, !isBrowserProfileMenuPresented else { return } + pendingBrowserImportDialogPresentation = false + BrowserDataImportCoordinator.shared.presentImportDialog( + defaultDestinationProfileID: panel.profileID + ) + } + /// Treat a WebView with no URL (or about:blank) as "blank" for UX purposes. private func isWebViewBlank() -> Bool { guard let url = panel.webView.url else { return true } @@ -1802,7 +2014,7 @@ struct BrowserPanelView: View { private func openDevTools() { #if DEBUG - cmuxDebugLog("browser.toggleDevTools panel=\(panel.id.uuidString.prefix(5))") + dlog("browser.toggleDevTools panel=\(panel.id.uuidString.prefix(5))") #endif if !panel.toggleDeveloperTools() { NSSound.beep() @@ -2221,28 +2433,19 @@ struct BrowserPanelView: View { refreshSuggestions() } if effects.shouldSelectAll { - // Apply immediately for fast Cmd+L typing, then retry once in case - // first responder wasn't fully settled on the same runloop. - DispatchQueue.main.async { - NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: nil) - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: nil) - } + omnibarSelectAllRequestGeneration &+= 1 } if effects.shouldBlurToWebView { hideSuggestions() // This transition is stateful: drop omnibar focus suppression before // attempting responder handoff so WKWebView can actually become first responder. - panel.endSuppressWebViewFocusForAddressBar() - syncWebViewResponderPolicyWithViewState(reason: "effects.blurToWebView.preHandoff") setAddressBarFocused(false, reason: "effects.blurToWebView") DispatchQueue.main.async { guard let window = panel.webView.window, !panel.webView.isHiddenOrHasHiddenAncestor else { return } guard shouldApplyAddressBarExitFallback(in: window) else { #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " + "result=skip_not_focused" ) @@ -2250,22 +2453,11 @@ struct BrowserPanelView: View { NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id) return } - syncWebViewResponderPolicyWithViewState(reason: "effects.blurToWebView.handoff") panel.clearWebViewFocusSuppression() - let focusedWebView = window.makeFirstResponder(panel.webView) - if focusedWebView { - panel.noteWebViewFocused() - } -#if DEBUG - cmuxDebugLog( - "browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " + - "focusedWebView=\(focusedWebView ? 1 : 0)" - ) -#endif - panel.restoreAddressBarPageFocusIfNeeded { restored in + panel.transitionToWebContentFocus(reason: "addressBarExit", completion: { restored in guard shouldApplyAddressBarExitFallback(in: window) else { #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " + "result=skip_stale_restore restored=\(restored ? 1 : 0)" ) @@ -2273,24 +2465,8 @@ struct BrowserPanelView: View { NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id) return } - var hasWebViewResponder = - browserFocusResponderChainContains(window.firstResponder, target: panel.webView) - if !hasWebViewResponder { - let fallbackFocusedWebView = window.makeFirstResponder(panel.webView) - hasWebViewResponder = fallbackFocusedWebView -#if DEBUG - cmuxDebugLog( - "browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " + - "fallbackFocusedWebView=\(fallbackFocusedWebView ? 1 : 0) " + - "restored=\(restored ? 1 : 0)" - ) -#endif - } - if hasWebViewResponder { - panel.noteWebViewFocused() - } NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id) - } + }) } } } @@ -3123,10 +3299,9 @@ func omnibarReduce(state: inout OmnibarState, event: OmnibarEvent) -> OmnibarEff case .escape: guard state.isFocused else { break } - // Chrome semantics: - // - If user input is in progress OR the popup is open: revert to the page URL and select-all. - // - Otherwise: exit omnibar focus. - if state.isUserEditing || !state.suggestions.isEmpty { + // Chrome semantics: Escape exits a Cmd+L focus immediately. It only + // stays in the omnibar to revert when the user has edited the buffer. + if state.isUserEditing { state.isUserEditing = false state.buffer = state.currentURLString state.suggestions = [] @@ -3134,6 +3309,9 @@ func omnibarReduce(state: inout OmnibarState, event: OmnibarEvent) -> OmnibarEff state.selectedSuggestionID = nil effects.shouldSelectAll = true } else { + state.suggestions = [] + state.selectedSuggestionIndex = 0 + state.selectedSuggestionID = nil effects.shouldBlurToWebView = true } } @@ -3332,7 +3510,7 @@ private final class OmnibarNativeTextField: NSTextField { override func mouseDown(with event: NSEvent) { #if DEBUG let frType = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - cmuxDebugLog( + dlog( "browser.omnibarClick win=\(window?.windowNumber ?? -1) " + "fr=\(frType) hasEditor=\(currentEditor() == nil ? 0 : 1)" ) @@ -3346,7 +3524,7 @@ private final class OmnibarNativeTextField: NSTextField { let result = window?.makeFirstResponder(self) ?? false #if DEBUG let frAfter = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - cmuxDebugLog( + dlog( "browser.omnibarClick.makeFirstResponder result=\(result ? 1 : 0) " + "win=\(window?.windowNumber ?? -1) fr=\(frAfter)" ) @@ -3474,6 +3652,7 @@ private final class OmnibarNativeTextField: NSTextField { private struct OmnibarTextFieldRepresentable: NSViewRepresentable { @Binding var text: String @Binding var isFocused: Bool + let selectAllRequestGeneration: UInt64 let inlineCompletion: OmnibarInlineCompletion? let placeholder: String let onTap: () -> Void @@ -3497,6 +3676,7 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { var lastPublishedHasMarkedText: Bool = false /// Guards against infinite focus loops: `true` = focus requested, `false` = blur requested, `nil` = idle. var pendingFocusRequest: Bool? + var lastHandledSelectAllRequestGeneration: UInt64 = 0 init(parent: OmnibarTextFieldRepresentable) { self.parent = parent @@ -3527,7 +3707,7 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { if !detail.isEmpty { line += " \(detail)" } - cmuxDebugLog(line) + dlog(line) } #endif @@ -3628,6 +3808,7 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { } } attachSelectionObserverIfNeeded() + applySelectAllIfNeeded() publishSelectionState() } @@ -3809,6 +3990,15 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { weak var parentField: OmnibarNativeTextField? + func applySelectAllIfNeeded() { + guard parent.selectAllRequestGeneration > lastHandledSelectAllRequestGeneration else { return } + guard let field = parentField, + let editor = field.currentEditor() as? NSTextView else { return } + lastHandledSelectAllRequestGeneration = parent.selectAllRequestGeneration + editor.selectAll(nil) + publishSelectionState() + } + func publishSelectionState() { guard let field = parentField else { return } if let editor = field.currentEditor() as? NSTextView { @@ -4033,6 +4223,7 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { coordinator?.logFocusEvent("updateNSView.requestFocus.apply") #endif window.makeFirstResponder(nsView) + coordinator?.applySelectAllIfNeeded() } } else if !isFocused, isFirstResponder, context.coordinator.pendingFocusRequest != false { #if DEBUG @@ -4067,6 +4258,10 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { } } + if isFocused { + context.coordinator.applySelectAllIfNeeded() + } + if let editor = nsView.currentEditor() as? NSTextView, !editor.hasMarkedText() { if let activeInlineCompletion { let currentSelection = editor.selectedRange() @@ -4271,21 +4466,7 @@ private struct OmnibarSuggestionsView: View { ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in Button { #if DEBUG - let suggestionKind: String = { - switch item.kind { - case .search: - return "search" - case .navigate: - return "navigate" - case .history: - return "history" - case .switchToTab: - return "switchToTab" - case .remote: - return "remote" - } - }() - cmuxDebugLog("browser.suggestionClick index=\(idx) kind=\(suggestionKind) textBytes=\(item.listText.utf8.count)") + dlog("browser.suggestionClick index=\(idx) text=\"\(item.listText)\"") #endif onCommit(item) } label: { @@ -4408,6 +4589,7 @@ struct WebViewRepresentable: NSViewRepresentable { let paneId: PaneID let shouldAttachWebView: Bool let useLocalInlineHosting: Bool + let retainedHostView: HostContainerView? let shouldFocusWebView: Bool let isPanelFocused: Bool let portalZPriority: Int @@ -4577,7 +4759,7 @@ struct WebViewRepresentable: NSViewRepresentable { let recordedWidthDesc = recordedHostedInspectorSideDockWidth.map { String(format: "%.1f", $0) } ?? "nil" - cmuxDebugLog( + dlog( "browser.panel.hostedInspector stage=\(reason).dockControls " + "host=\(Self.debugObjectID(self)) allowSideDock=\(sideDockAllowed ? 1 : 0) " + "recordedWidth=\(recordedWidthDesc) bounds=\(Self.debugRect(bounds))" @@ -4699,7 +4881,7 @@ struct WebViewRepresentable: NSViewRepresentable { guard let window, let contentView = window.contentView else { return .zero } return contentView.convert(bounds, from: self) }() - cmuxDebugLog( + dlog( "browser.panel.host stage=\(stage) event=\(String(describing: event?.type)) " + "point=\(String(format: "%.1f,%.1f", point.x, point.y)) pass=\(passThrough ? 1 : 0) " + "hostFrameInContent=\(String(format: "%.1f,%.1f %.1fx%.1f", hostRectInContent.origin.x, hostRectInContent.origin.y, hostRectInContent.width, hostRectInContent.height)) " + @@ -4723,7 +4905,7 @@ struct WebViewRepresentable: NSViewRepresentable { ) { let pointDesc = point.map { String(format: "%.1f,%.1f", $0.x, $0.y) } ?? "nil" let preferredWidthDesc = preferredHostedInspectorWidth.map { String(format: "%.1f", $0) } ?? "nil" - cmuxDebugLog( + dlog( "browser.panel.hostedInspector stage=\(stage) point=\(pointDesc) " + "host=\(Self.debugObjectID(self)) container=\(Self.debugObjectID(hit.containerView)) " + "page=\(Self.debugObjectID(hit.pageView)) inspector=\(Self.debugObjectID(hit.inspectorView)) " + @@ -4744,7 +4926,7 @@ struct WebViewRepresentable: NSViewRepresentable { } ?? "nil" lastLoggedHostedInspectorFrames = nil hasLoggedMissingHostedInspectorCandidate = true - cmuxDebugLog( + dlog( "browser.panel.hostedInspector stage=\(reason).candidateMissing " + "host=\(Self.debugObjectID(self)) preferredWidth=\(preferredWidthDesc)" ) @@ -5167,7 +5349,7 @@ struct WebViewRepresentable: NSViewRepresentable { adaptiveBottomDockRequestCooldownDeadline = now.addingTimeInterval(Self.adaptiveBottomDockRequestCooldown) updateHostedInspectorDockControlAvailabilityIfNeeded(reason: reason) #if DEBUG - cmuxDebugLog( + dlog( "browser.panel.hostedInspector stage=\(reason).adaptiveBottomDock " + "host=\(Self.debugObjectID(self)) bounds=\(Self.debugRect(bounds))" ) @@ -5544,23 +5726,17 @@ struct WebViewRepresentable: NSViewRepresentable { if hostedInspectorHit != nil { return false } - // Pass through narrow content-edge bands so shared sidebar divider - // handles receive hover/click even when WKWebView is attached here. - let isLeadingContentEdge = point.x >= 0 && - point.x <= SidebarResizeInteraction.contentSideHitWidth - let isTrailingContentEdge = point.x >= bounds.maxX - SidebarResizeInteraction.contentSideHitWidth && - point.x <= bounds.maxX - guard isLeadingContentEdge || isTrailingContentEdge else { + // 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.contentSideHitWidth else { return false } guard let window, let contentView = window.contentView else { return false } let hostRectInContent = contentView.convert(bounds, from: self) - if isLeadingContentEdge { - return hostRectInContent.minX > 1 - } - return contentView.bounds.maxX - hostRectInContent.maxX > 24 + return hostRectInContent.minX > 1 } private func updateDividerCursor( @@ -5795,7 +5971,7 @@ struct WebViewRepresentable: NSViewRepresentable { let preferredWidthDesc = preferredHostedInspectorWidth.map { String(format: "%.1f", $0) } ?? "nil" - cmuxDebugLog( + dlog( "browser.panel.hostedInspector stage=\(reason).captureMissingCandidate " + "host=\(Self.debugObjectID(self)) preferredWidth=\(preferredWidthDesc)" ) @@ -5901,7 +6077,7 @@ struct WebViewRepresentable: NSViewRepresentable { let isLiveDrag = reason == "drag" #if DEBUG - cmuxDebugLog( + dlog( "browser.panel.hostedInspector stage=\(reason).reapply " + "host=\(Self.debugObjectID(self)) preferredWidth=\(String(format: "%.1f", preferredWidth)) " + "liveDrag=\(isLiveDrag ? 1 : 0) " + @@ -5969,7 +6145,7 @@ struct WebViewRepresentable: NSViewRepresentable { if let details, !details.isEmpty { line += " \(details)" } - cmuxDebugLog(line) + dlog(line) } private static func objectID(_ object: AnyObject?) -> String { @@ -6005,6 +6181,35 @@ struct WebViewRepresentable: NSViewRepresentable { return false } + private static func paneStillOwnsPanel(_ panel: BrowserPanel, paneId: PaneID) -> Bool { + guard let app = AppDelegate.shared, + let manager = app.tabManagerFor(tabId: panel.workspaceId), + let workspace = manager.tabs.first(where: { $0.id == panel.workspaceId }), + let currentPaneId = workspace.paneId(forPanelId: panel.id) else { + return false + } + return currentPaneId.id == paneId.id + } + + private static func replayMountedWebViewFocusIfNeeded( + panel: BrowserPanel, + webView: WKWebView, + host: NSView, + paneId: PaneID, + shouldFocusWebView: Bool, + isPanelFocused: Bool, + reason: String + ) { + guard paneStillOwnsPanel(panel, paneId: paneId) else { return } + guard let window = webView.window ?? host.window else { return } + _ = panel.noteMountedWebViewHostDidMoveToWindow( + window, + shouldFocusWebView: shouldFocusWebView, + isPanelFocused: isPanelFocused, + reason: reason + ) + } + private static func isLikelyInspectorResponder(_ responder: NSResponder?) -> Bool { guard let responder else { return false } let responderType = String(describing: type(of: responder)) @@ -6044,7 +6249,7 @@ struct WebViewRepresentable: NSViewRepresentable { } func makeNSView(context: Context) -> NSView { - let container = HostContainerView() + let container = retainedHostView ?? HostContainerView() container.wantsLayer = true return container } @@ -6056,6 +6261,19 @@ struct WebViewRepresentable: NSViewRepresentable { host.clearLocalInlineCallbacks() } + static func deactivatePortalHosts(in root: NSView, panel: BrowserPanel, reason: String) { + if let host = root as? HostContainerView { + clearPortalCallbacks(for: host) + _ = panel.releasePortalHostIfOwned( + hostId: ObjectIdentifier(host), + reason: reason + ) + } + for subview in root.subviews { + deactivatePortalHosts(in: subview, panel: panel, reason: reason) + } + } + private static func shouldPreserveExternalFullscreenHost( for webView: WKWebView, relativeTo expectedWindow: NSWindow? @@ -6140,7 +6358,7 @@ struct WebViewRepresentable: NSViewRepresentable { var movedSubviewCount = 0 var reusedSourceLocalFrames = false #if DEBUG - cmuxDebugLog( + dlog( "browser.localHost.reparent.batch reason=\(reason) source=\(Self.objectID(sourceSuperview)) " + "container=\(Self.objectID(container)) count=\(relatedSubviews.count) " + "sourceType=\(String(describing: type(of: sourceSuperview))) targetType=\(String(describing: type(of: container)))" @@ -6166,7 +6384,7 @@ struct WebViewRepresentable: NSViewRepresentable { view.frame = targetFrame movedSubviewCount += 1 #if DEBUG - cmuxDebugLog( + dlog( "browser.localHost.reparent.batch.item reason=\(reason) class=\(className) " + "view=\(Self.objectID(view))" ) @@ -6223,6 +6441,7 @@ struct WebViewRepresentable: NSViewRepresentable { coordinator.desiredPortalVisibleInUI = false coordinator.desiredPortalZPriority = 0 coordinator.attachGeneration += 1 + let generation = coordinator.attachGeneration if panel.releasePortalHostIfOwned( hostId: ObjectIdentifier(host), @@ -6235,6 +6454,21 @@ struct WebViewRepresentable: NSViewRepresentable { ) } + host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator, weak browserPanel = panel] in + guard let host, let webView, let coordinator, let browserPanel else { return } + guard coordinator.attachGeneration == generation else { return } + guard host.window != nil else { return } + Self.replayMountedWebViewFocusIfNeeded( + panel: browserPanel, + webView: webView, + host: host, + paneId: paneId, + shouldFocusWebView: shouldFocusWebView, + isPanelFocused: isPanelFocused, + reason: "localHost.didMoveToWindow" + ) + } + let shouldPreserveExistingExternalLocalHost = host.window == nil && webView.superview != nil && @@ -6247,7 +6481,7 @@ struct WebViewRepresentable: NSViewRepresentable { coordinator.lastPortalHostId = nil coordinator.lastSynchronizedHostGeometryRevision = 0 #if DEBUG - cmuxDebugLog( + dlog( "browser.localHost.reparent.skip web=\(Self.objectID(webView)) " + "reason=offWindowReplacementHost super=\(Self.objectID(webView.superview)) " + "host=\(Self.objectID(host)) slot=\(Self.objectID(slotView))" @@ -6265,7 +6499,7 @@ struct WebViewRepresentable: NSViewRepresentable { #if DEBUG if shouldPreserveExternalFullscreenHost { - cmuxDebugLog( + dlog( "browser.localHost.reparent.skip web=\(Self.objectID(webView)) " + "reason=fullscreenExternalHost host=\(Self.objectID(host)) " + "slot=\(Self.objectID(slotView)) state=\(String(describing: webView.fullscreenState))" @@ -6457,16 +6691,19 @@ struct WebViewRepresentable: NSViewRepresentable { let portalHostAccepted = shouldAttachWebView && isCurrentPaneOwner && - panel.claimPortalHost( - hostId: hostId, - paneId: paneId, - inWindow: host.window != nil, - bounds: host.bounds, - reason: "update" - ) + { + panel.resumePortalHosting(reason: "update") + return panel.claimPortalHost( + hostId: hostId, + paneId: paneId, + inWindow: host.window != nil, + bounds: host.bounds, + reason: "update" + ) + }() #if DEBUG if !isCurrentPaneOwner && (shouldAttachWebView || host.window != nil) { - cmuxDebugLog( + dlog( "browser.portal.owner.skip panel=\(panel.id.uuidString.prefix(5)) " + "viewPane=\(paneId.id.uuidString.prefix(5)) " + "currentPane=\(paneDropContext?.paneId.id.uuidString.prefix(5) ?? "nil") " + @@ -6483,6 +6720,7 @@ struct WebViewRepresentable: NSViewRepresentable { guard let host, let webView, let coordinator, let portalAnchorView, let browserPanel else { return } guard coordinator.attachGeneration == generation else { return } guard currentPaneDropContext()?.paneId.id == paneId.id else { return } + browserPanel.resumePortalHosting(reason: "didMoveToWindow") guard browserPanel.claimPortalHost( hostId: ObjectIdentifier(host), paneId: paneId, @@ -6510,11 +6748,21 @@ struct WebViewRepresentable: NSViewRepresentable { BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) coordinator.lastPortalHostId = ObjectIdentifier(host) coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision + Self.replayMountedWebViewFocusIfNeeded( + panel: browserPanel, + webView: webView, + host: host, + paneId: paneId, + shouldFocusWebView: shouldFocusWebView, + isPanelFocused: isPanelFocused, + reason: "portalHost.didMoveToWindow" + ) } host.onGeometryChanged = { [weak host, weak webView, weak coordinator, weak portalAnchorView, weak browserPanel = panel] in guard let host, let webView, let coordinator, let portalAnchorView, let browserPanel else { return } guard coordinator.attachGeneration == generation else { return } guard currentPaneDropContext()?.paneId.id == paneId.id else { return } + browserPanel.resumePortalHosting(reason: "geometryChanged") guard browserPanel.claimPortalHost( hostId: ObjectIdentifier(host), paneId: paneId, @@ -6547,6 +6795,15 @@ struct WebViewRepresentable: NSViewRepresentable { } BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView) coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision + Self.replayMountedWebViewFocusIfNeeded( + panel: browserPanel, + webView: webView, + host: host, + paneId: paneId, + shouldFocusWebView: shouldFocusWebView, + isPanelFocused: isPanelFocused, + reason: "portalHost.geometryChanged" + ) } if !shouldAttachWebView { @@ -6624,7 +6881,7 @@ struct WebViewRepresentable: NSViewRepresentable { panel.restoreDeveloperToolsAfterAttachIfNeeded() - #if DEBUG +#if DEBUG Self.logDevToolsState( panel, event: "portal.update", @@ -6632,7 +6889,7 @@ struct WebViewRepresentable: NSViewRepresentable { retryCount: 0, details: Self.attachContext(webView: webView, host: host) ) - #endif +#endif return portalHostAccepted && !shouldPreserveExternalFullscreenHost } @@ -6652,11 +6909,6 @@ struct WebViewRepresentable: NSViewRepresentable { let hostOwnsPortal = useLocalInlineHosting ? updateUsingLocalInlineHosting(nsView, context: context, webView: webView) : updateUsingWindowPortal(nsView, context: context, webView: webView) - Self.applyWebViewFirstResponderPolicy( - panel: panel, - webView: webView, - isPanelFocused: isPanelFocused && isCurrentPaneOwner && hostOwnsPortal - ) Self.applyFocus( panel: panel, @@ -6677,7 +6929,7 @@ struct WebViewRepresentable: NSViewRepresentable { // Focus handling. Avoid fighting the address bar when it is focused. guard let window = nsView.window else { #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + "action=skip reason=no_window shouldFocus=\(shouldFocusWebView ? 1 : 0) " + "panelFocused=\(isPanelFocused ? 1 : 0)" @@ -6685,70 +6937,65 @@ struct WebViewRepresentable: NSViewRepresentable { #endif return } - if isPanelFocused && responderChainContains(window.firstResponder, target: webView) { - panel.noteWebViewFocused() + if isPanelFocused && panel.hasWebContentFirstResponder(in: window) { + panel.noteWebViewFocused(source: .passiveObservation) + } else if isPanelFocused && panel.hasWebViewFirstResponder(in: window) { +#if DEBUG + dlog( + "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + + "action=observe actual=\(panel.actualFocus(in: window).debugDescription)" + ) +#endif } if shouldFocusWebView { if panel.shouldSuppressWebViewFocus() { #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + "action=skip reason=suppressed panelFocused=\(isPanelFocused ? 1 : 0)" ) #endif return } - if responderChainContains(window.firstResponder, target: webView) { + if window.firstResponder === webView { + panel.noteWebViewFocused() #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + - "action=skip reason=already_first_responder_chain" + "action=observeWrapper " + + "actual=\(panel.actualFocus(in: window).debugDescription) " + + "fr=\(responderDescription(window.firstResponder))" ) #endif return } - let result = window.makeFirstResponder(webView) - if result { - panel.noteWebViewFocused() - } + let result = panel.requestMountedContentWebViewFocus(in: window, reason: "content.apply") #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + - "action=focus result=\(result ? 1 : 0) fr=\(responderDescription(window.firstResponder))" + "action=focus result=\(result ? 1 : 0) " + + "actual=\(panel.actualFocus(in: window).debugDescription) " + + "fr=\(responderDescription(window.firstResponder))" ) #endif } else if !isPanelFocused && responderChainContains(window.firstResponder, target: webView) { - // Only force-resign WebView focus when this panel itself is not focused. - // If the panel is focused but the omnibar-focus state is briefly stale, aggressively - // clearing first responder here can undo programmatic webview focus (socket tests). - let result = window.makeFirstResponder(nil) + if webView.isHiddenOrHasHiddenAncestor { + let result = panel.resignWebViewFirstResponderIfOwned(reason: "content.apply.hidden") #if DEBUG - cmuxDebugLog( - "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + - "action=resign result=\(result ? 1 : 0) fr=\(responderDescription(window.firstResponder))" - ) + dlog( + "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + + "action=resignHidden result=\(result ? 1 : 0) fr=\(responderDescription(window.firstResponder))" + ) #endif - } - } - - private static func applyWebViewFirstResponderPolicy( - panel: BrowserPanel, - webView: WKWebView, - isPanelFocused: Bool - ) { - guard let cmuxWebView = webView as? CmuxWebView else { return } - let next = isPanelFocused && !panel.shouldSuppressWebViewFocus() - if cmuxWebView.allowsFirstResponderAcquisition != next { + } else { #if DEBUG - cmuxDebugLog( - "browser.focus.policy panel=\(panel.id.uuidString.prefix(5)) " + - "web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " + - "new=\(next ? 1 : 0) isPanelFocused=\(isPanelFocused ? 1 : 0) " + - "suppress=\(panel.shouldSuppressWebViewFocus() ? 1 : 0)" - ) + dlog( + "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + + "action=preserve reason=panel_unfocused fr=\(responderDescription(window.firstResponder))" + ) #endif + } } - cmuxWebView.allowsFirstResponderAcquisition = next } static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { @@ -6764,24 +7011,36 @@ struct WebViewRepresentable: NSViewRepresentable { guard let webView = coordinator.webView else { return } let panel = coordinator.panel - // If we're being torn down while the WKWebView (or one of its subviews) is first responder, - // resign it before detaching. let window = webView.window ?? nsView.window if let window { let state = firstResponderResignState(window.firstResponder, webView: webView) if state.needsResign { - #if DEBUG - if let panel { - logDevToolsState( - panel, - event: "dismantle.resignFirstResponder", - generation: coordinator.attachGeneration, - retryCount: 0, - details: attachContext(webView: webView, host: nsView) + " " + state.flags - ) + if webView.isHiddenOrHasHiddenAncestor { + #if DEBUG + if let panel { + logDevToolsState( + panel, + event: "dismantle.resignFirstResponder", + generation: coordinator.attachGeneration, + retryCount: 0, + details: attachContext(webView: webView, host: nsView) + " " + state.flags + ) + } + #endif + window.makeFirstResponder(nil) + } else { + #if DEBUG + if let panel { + logDevToolsState( + panel, + event: "dismantle.preserveFirstResponder", + generation: coordinator.attachGeneration, + retryCount: 0, + details: attachContext(webView: webView, host: nsView) + " " + state.flags + ) + } + #endif } - #endif - window.makeFirstResponder(nil) } } diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index 6ed3ebd5fb8f..45bdd805f3c8 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -1,5 +1,4 @@ import AppKit -import Bonsplit import ObjectiveC import WebKit @@ -52,7 +51,7 @@ private class BrowserPopupPanel: NSPanel { if flags == .command, KeyboardLayout.normalizedCharacters(for: event) == "w" { #if DEBUG - cmuxDebugLog("popup.panel.cmdW close") + dlog("popup.panel.cmdW close") #endif performClose(nil) return true @@ -67,7 +66,6 @@ final class BrowserPopupWindowController: NSObject, NSWindowDelegate { static let maxNestingDepth = 3 let webView: CmuxWebView - private let browserContext: BrowserPopupBrowserContext private let panel: NSPanel private let urlLabel: NSTextField private weak var openerPanel: BrowserPanel? @@ -86,21 +84,21 @@ final class BrowserPopupWindowController: NSObject, NSWindowDelegate { init( configuration: WKWebViewConfiguration, windowFeatures: WKWindowFeatures, - browserContext: BrowserPopupBrowserContext, openerPanel: BrowserPanel?, parentPopupController: BrowserPopupWindowController? = nil, nestingDepth: Int = 0 ) { - self.browserContext = browserContext self.openerPanel = openerPanel self.parentPopupController = parentPopupController self.nestingDepth = nestingDepth - BrowserPanel.configureWebViewConfiguration( - configuration, - websiteDataStore: browserContext.websiteDataStore, - processPool: browserContext.processPool - ) + let browserContextSource = parentPopupController?.webView.configuration ?? openerPanel?.webView.configuration + if let browserContextSource { + BrowserPanel.configureWebViewConfiguration( + configuration, + websiteDataStore: browserContextSource.websiteDataStore + ) + } // Create popup web view with WebKit's supplied configuration after // overlaying the opener's browser context so OAuth popups keep cmux's @@ -239,7 +237,7 @@ final class BrowserPopupWindowController: NSObject, NSWindowDelegate { panel.delegate = self #if DEBUG - cmuxDebugLog("popup.init depth=\(nestingDepth) size=\(Int(contentRect.width))x\(Int(contentRect.height)) opener=\(openerPanel?.id.uuidString.prefix(5) ?? "nil")") + dlog("popup.init depth=\(nestingDepth) size=\(Int(contentRect.width))x\(Int(contentRect.height)) opener=\(openerPanel?.id.uuidString.prefix(5) ?? "nil")") #endif panel.makeKeyAndOrderFront(self) @@ -281,7 +279,7 @@ final class BrowserPopupWindowController: NSObject, NSWindowDelegate { func windowWillClose(_ notification: Notification) { #if DEBUG - cmuxDebugLog("popup.close depth=\(nestingDepth)") + dlog("popup.close depth=\(nestingDepth)") #endif closeAllChildPopups() @@ -315,14 +313,13 @@ final class BrowserPopupWindowController: NSObject, NSWindowDelegate { let nextDepth = nestingDepth + 1 if nextDepth > Self.maxNestingDepth { #if DEBUG - cmuxDebugLog("popup.nested.blocked depth=\(nextDepth) max=\(Self.maxNestingDepth)") + dlog("popup.nested.blocked depth=\(nextDepth) max=\(Self.maxNestingDepth)") #endif return nil } let child = BrowserPopupWindowController( configuration: configuration, windowFeatures: windowFeatures, - browserContext: browserContext, openerPanel: openerPanel, parentPopupController: self, nestingDepth: nextDepth @@ -331,10 +328,10 @@ final class BrowserPopupWindowController: NSObject, NSWindowDelegate { return child.webView } - func openInOpenerTab(_ request: URLRequest) { + func openInOpenerTab(_ url: URL) { if let openerPanel { - openerPanel.openLinkInNewTab(request: request) - } else if let url = request.url { + openerPanel.openLinkInNewTab(url: url) + } else { NSWorkspace.shared.open(url) } } @@ -398,7 +395,7 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { func webViewDidClose(_ webView: WKWebView) { #if DEBUG - cmuxDebugLog("popup.webViewDidClose") + dlog("popup.webViewDidClose") #endif controller?.closePopup() } @@ -430,8 +427,8 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { ) } - if navigationAction.request.url != nil { - controller?.openInOpenerTab(navigationAction.request) + if let url = navigationAction.request.url { + controller?.openInOpenerTab(url) } return nil } @@ -567,7 +564,7 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { if browserShouldOpenURLExternally(url) { NSWorkspace.shared.open(url) #if DEBUG - cmuxDebugLog("popup.nav.external url=\(url.absoluteString)") + dlog("popup.nav.external url=\(url.absoluteString)") #endif decisionHandler(.cancel) return @@ -576,7 +573,7 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { // Insecure HTTP → show same prompt as main browser if browserShouldBlockInsecureHTTPURL(url) { #if DEBUG - cmuxDebugLog("popup.nav.insecureHTTP url=\(url.absoluteString)") + dlog("popup.nav.insecureHTTP url=\(url.absoluteString)") #endif controller?.presentInsecureHTTPAlert(for: url, in: webView, decisionHandler: decisionHandler) return @@ -629,14 +626,14 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { #if DEBUG - cmuxDebugLog("popup.download.didBecome source=navigationAction") + dlog("popup.download.didBecome source=navigationAction") #endif download.delegate = downloadDelegate } func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { #if DEBUG - cmuxDebugLog("popup.download.didBecome source=navigationResponse") + dlog("popup.download.didBecome source=navigationResponse") #endif download.delegate = downloadDelegate } diff --git a/Sources/Panels/BrowserWebAuthnSupport.swift b/Sources/Panels/BrowserWebAuthnSupport.swift index 6be31bbc7587..53820a14c252 100644 --- a/Sources/Panels/BrowserWebAuthnSupport.swift +++ b/Sources/Panels/BrowserWebAuthnSupport.swift @@ -1,6 +1,5 @@ import AppKit import AuthenticationServices -import Bonsplit import CoreBluetooth import Foundation import ObjectiveC.runtime @@ -792,7 +791,7 @@ private struct BrowserWebAuthnClientDataContext { .lowercased() ?? callerOrigin.host #if DEBUG - cmuxDebugLog("webauthn.resolveRP explicit=\(explicitIdentifier ?? "(nil)") resolved=\(requestedIdentifier) callerHost=\(callerOrigin.host) permitted=\(callerOrigin.permits(relyingPartyIdentifier: requestedIdentifier))") + dlog("webauthn.resolveRP explicit=\(explicitIdentifier ?? "(nil)") resolved=\(requestedIdentifier) callerHost=\(callerOrigin.host) permitted=\(callerOrigin.permits(relyingPartyIdentifier: requestedIdentifier))") #endif guard callerOrigin.permits(relyingPartyIdentifier: requestedIdentifier) else { throw BrowserWebAuthnBridgeError.security("Passkey access is not available.") @@ -1141,12 +1140,6 @@ final class BrowserWebAuthnCoordinator: NSObject, WKScriptMessageHandlerWithRepl super.init() } - #if DEBUG - private func debugHasValue(_ value: String?) -> Int { - value?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? 1 : 0 - } - #endif - func install(on webView: WKWebView) { let controller = webView.configuration.userContentController controller.removeScriptMessageHandler(forName: BrowserWebAuthnBridgeContract.handlerName, contentWorld: .page) @@ -1169,7 +1162,7 @@ final class BrowserWebAuthnCoordinator: NSObject, WKScriptMessageHandlerWithRepl do { let envelope = try BrowserWebAuthnRequestParser.parseEnvelope(from: message.body) #if DEBUG - cmuxDebugLog("webauthn.dispatch kind=\(envelope.kind.rawValue) frame=\(message.frameInfo.isMainFrame ? "main" : "sub") url=\(message.frameInfo.securityOrigin.host)") + dlog("webauthn.dispatch kind=\(envelope.kind.rawValue) frame=\(message.frameInfo.isMainFrame ? "main" : "sub") url=\(message.frameInfo.securityOrigin.host)") #endif switch envelope.kind { case .capabilities: @@ -1180,7 +1173,7 @@ final class BrowserWebAuthnCoordinator: NSObject, WKScriptMessageHandlerWithRepl callerMayPromptForPlatformAuthorization: callerMayPrompt ) #if DEBUG - cmuxDebugLog("webauthn.capabilities reply=\(capReply)") + dlog("webauthn.capabilities reply=\(capReply)") #endif replyHandler(capReply, nil) case .createCredential: @@ -1189,17 +1182,11 @@ final class BrowserWebAuthnCoordinator: NSObject, WKScriptMessageHandlerWithRepl from: envelope ) #if DEBUG - cmuxDebugLog( - "webauthn.createCredential hasRP=\(debugHasValue(request.publicKey.rp?.id)) " + - "hasUserName=\(debugHasValue(request.publicKey.user.name)) " + - "userIDBytes=\(request.publicKey.user.id.data.count) " + - "attachment=\(request.publicKey.authenticatorSelection?.attachment ?? "(nil)") " + - "algorithmCount=\(request.publicKey.requestedAlgorithms.count)" - ) + dlog("webauthn.createCredential rp=\(request.publicKey.rp?.id ?? "(nil)") user=\(request.publicKey.user.name ?? "(nil)") attachment=\(request.publicKey.authenticatorSelection?.attachment ?? "(nil)") algorithms=\(request.publicKey.requestedAlgorithms)") #endif let reply = try await handleCreateCredential(request, message: message) #if DEBUG - cmuxDebugLog("webauthn.createCredential reply.ok=\(reply["ok"] ?? "nil") hasCredential=\(reply["credential"] != nil) fallback=\(reply["useWebKitFallback"] ?? "nil")") + dlog("webauthn.createCredential reply.ok=\(reply["ok"] ?? "nil") hasCredential=\(reply["credential"] != nil) fallback=\(reply["useWebKitFallback"] ?? "nil")") #endif replyHandler(reply, nil) case .getCredential: @@ -1208,26 +1195,22 @@ final class BrowserWebAuthnCoordinator: NSObject, WKScriptMessageHandlerWithRepl from: envelope ) #if DEBUG - cmuxDebugLog( - "webauthn.getCredential hasRPID=\(debugHasValue(request.publicKey.rpId)) " + - "allowCredentials=\(request.publicKey.allowCredentials?.count ?? 0) " + - "mediation=\(request.mediation ?? "(nil)")" - ) + dlog("webauthn.getCredential rpId=\(request.publicKey.rpId ?? "(nil)") allowCredentials=\(request.publicKey.allowCredentials?.count ?? 0) mediation=\(request.mediation ?? "(nil)")") #endif let reply = try await handleGetCredential(request, message: message) #if DEBUG - cmuxDebugLog("webauthn.getCredential reply.ok=\(reply["ok"] ?? "nil") hasCredential=\(reply["credential"] != nil) fallback=\(reply["useWebKitFallback"] ?? "nil")") + dlog("webauthn.getCredential reply.ok=\(reply["ok"] ?? "nil") hasCredential=\(reply["credential"] != nil) fallback=\(reply["useWebKitFallback"] ?? "nil")") #endif replyHandler(reply, nil) } } catch let error as BrowserWebAuthnBridgeError { #if DEBUG - cmuxDebugLog("webauthn.error bridge: \(error.replyObject())") + dlog("webauthn.error bridge: \(error.replyObject())") #endif replyHandler(error.replyObject(), nil) } catch { #if DEBUG - cmuxDebugLog("webauthn.error unknown: \(error.localizedDescription)") + dlog("webauthn.error unknown: \(error.localizedDescription)") #endif replyHandler(BrowserWebAuthnBridgeError.unknown(error.localizedDescription).replyObject(), nil) } @@ -1242,7 +1225,7 @@ extension BrowserWebAuthnCoordinator: ASAuthorizationControllerDelegate, ASAutho didCompleteWithAuthorization authorization: ASAuthorization ) { #if DEBUG - cmuxDebugLog("webauthn.asAuth.didComplete credentialType=\(type(of: authorization.credential))") + dlog("webauthn.asAuth.didComplete credentialType=\(type(of: authorization.credential))") #endif do { finishAuthorization( @@ -1252,7 +1235,7 @@ extension BrowserWebAuthnCoordinator: ASAuthorizationControllerDelegate, ASAutho ) } catch { #if DEBUG - cmuxDebugLog("webauthn.asAuth.didComplete replyMarshalError=\(error)") + dlog("webauthn.asAuth.didComplete replyMarshalError=\(error)") #endif finishAuthorization(with: .failure(error)) } @@ -1264,7 +1247,7 @@ extension BrowserWebAuthnCoordinator: ASAuthorizationControllerDelegate, ASAutho ) { #if DEBUG let nsError = error as NSError - cmuxDebugLog("webauthn.asAuth.didFail domain=\(nsError.domain) code=\(nsError.code)") + dlog("webauthn.asAuth.didFail domain=\(nsError.domain) code=\(nsError.code) desc=\(nsError.localizedDescription)") #endif finishAuthorization(with: .failure(bridgeError(from: error))) } @@ -1272,7 +1255,7 @@ extension BrowserWebAuthnCoordinator: ASAuthorizationControllerDelegate, ASAutho func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { let anchor = activePresentationWindow ?? NSApp.keyWindow ?? NSApp.mainWindow ?? NSWindow() #if DEBUG - cmuxDebugLog("webauthn.asAuth.presentationAnchor hasTitle=\(anchor.title.isEmpty ? 0 : 1) isVisible=\(anchor.isVisible) isKey=\(anchor.isKeyWindow)") + dlog("webauthn.asAuth.presentationAnchor window=\(anchor.title) isVisible=\(anchor.isVisible) isKey=\(anchor.isKeyWindow)") #endif return anchor } @@ -1301,15 +1284,12 @@ private extension BrowserWebAuthnCoordinator { message: WKScriptMessage ) async throws -> [String: Any] { #if DEBUG - cmuxDebugLog( - "webauthn.handleCreate BEGIN origin=\(message.frameInfo.securityOrigin.host) " + - "webViewHost=\(message.webView?.url?.host ?? "(nil)") hasWebViewURL=\(message.webView?.url == nil ? 0 : 1)" - ) + dlog("webauthn.handleCreate BEGIN origin=\(message.frameInfo.securityOrigin.host) webViewURL=\(message.webView?.url?.absoluteString ?? "(nil)")") #endif let clientDataContext = try BrowserWebAuthnClientDataContext.resolve(for: message) guard let plan = try buildCreationPlan(request, clientDataContext: clientDataContext) else { #if DEBUG - cmuxDebugLog("webauthn.handleCreate no plan — returning fallback") + dlog("webauthn.handleCreate no plan — returning fallback") #endif return fallbackReply() } @@ -1317,7 +1297,7 @@ private extension BrowserWebAuthnCoordinator { let requests = try await authorizationRequests(for: plan, message: message) guard !requests.isEmpty else { #if DEBUG - cmuxDebugLog("webauthn.handleCreate authorizationRequests empty — returning fallback") + dlog("webauthn.handleCreate authorizationRequests empty — returning fallback") #endif return fallbackReply() } @@ -1334,15 +1314,12 @@ private extension BrowserWebAuthnCoordinator { message: WKScriptMessage ) async throws -> [String: Any] { #if DEBUG - cmuxDebugLog( - "webauthn.handleGet BEGIN origin=\(message.frameInfo.securityOrigin.host) " + - "webViewHost=\(message.webView?.url?.host ?? "(nil)") hasWebViewURL=\(message.webView?.url == nil ? 0 : 1)" - ) + dlog("webauthn.handleGet BEGIN origin=\(message.frameInfo.securityOrigin.host) webViewURL=\(message.webView?.url?.absoluteString ?? "(nil)")") #endif let clientDataContext = try BrowserWebAuthnClientDataContext.resolve(for: message) guard let plan = try buildAssertionPlan(request, clientDataContext: clientDataContext) else { #if DEBUG - cmuxDebugLog("webauthn.handleGet no plan — returning fallback") + dlog("webauthn.handleGet no plan — returning fallback") #endif return fallbackReply() } @@ -1350,7 +1327,7 @@ private extension BrowserWebAuthnCoordinator { let requests = try await authorizationRequests(for: plan, message: message) guard !requests.isEmpty else { #if DEBUG - cmuxDebugLog("webauthn.handleGet authorizationRequests empty — returning fallback") + dlog("webauthn.handleGet authorizationRequests empty — returning fallback") #endif return fallbackReply() } @@ -1368,23 +1345,23 @@ private extension BrowserWebAuthnCoordinator { ) async throws -> [ASAuthorizationRequest] { var includePlatformRequests = plan.hasPlatformRequests #if DEBUG - cmuxDebugLog("webauthn.authRequests hasPlatform=\(plan.hasPlatformRequests) hasSecurityKey=\(plan.securityKeyRequests.count > 0) order=\(plan.order)") + dlog("webauthn.authRequests hasPlatform=\(plan.hasPlatformRequests) hasSecurityKey=\(plan.securityKeyRequests.count > 0) order=\(plan.order)") #endif if includePlatformRequests { let currentState = BrowserPasskeyAuthorizationGate.shared.currentAuthorizationState() #if DEBUG - cmuxDebugLog("webauthn.authRequests passkeyAuthState=\(currentState.rawValue) callerMayPrompt=\(callerMayPromptForPlatformAuthorization(message))") + dlog("webauthn.authRequests passkeyAuthState=\(currentState.rawValue) callerMayPrompt=\(callerMayPromptForPlatformAuthorization(message))") #endif if currentState == .notDetermined && !callerMayPromptForPlatformAuthorization(message) { #if DEBUG - cmuxDebugLog("webauthn.authRequests skipping platform: cross-origin subframe can't prompt") + dlog("webauthn.authRequests skipping platform: cross-origin subframe can't prompt") #endif includePlatformRequests = false } else { let authorizationState = await BrowserPasskeyAuthorizationGate.shared.authorizeIfNeeded() #if DEBUG - cmuxDebugLog("webauthn.authRequests authorizeIfNeeded result=\(authorizationState.rawValue)") + dlog("webauthn.authRequests authorizeIfNeeded result=\(authorizationState.rawValue)") #endif if authorizationState != .authorized { includePlatformRequests = false @@ -1394,22 +1371,22 @@ private extension BrowserWebAuthnCoordinator { let requests = plan.authorizationRequests(includePlatformRequests: includePlatformRequests) #if DEBUG - cmuxDebugLog("webauthn.authRequests finalCount=\(requests.count) includePlatform=\(includePlatformRequests)") + dlog("webauthn.authRequests finalCount=\(requests.count) includePlatform=\(includePlatformRequests)") #endif guard !requests.isEmpty else { #if DEBUG - cmuxDebugLog("webauthn.authRequests FAIL: no requests available, throwing notAllowed") + dlog("webauthn.authRequests FAIL: no requests available, throwing notAllowed") #endif throw BrowserWebAuthnBridgeError.notAllowed("Passkey access was denied for this browser.") } if plan.needsBluetoothPreparation(includePlatformRequests: includePlatformRequests) { #if DEBUG - cmuxDebugLog("webauthn.authRequests preparing bluetooth") + dlog("webauthn.authRequests preparing bluetooth") #endif let btState = await BrowserBluetoothAuthorizationGate.shared.prepareIfNeeded() #if DEBUG - cmuxDebugLog("webauthn.authRequests bluetooth result=\(btState)") + dlog("webauthn.authRequests bluetooth result=\(btState)") #endif } @@ -1422,14 +1399,9 @@ private extension BrowserWebAuthnCoordinator { prefersImmediatelyAvailableCredentials: Bool ) async throws -> [String: Any] { #if DEBUG - cmuxDebugLog( - "webauthn.performAuth requestCount=\(requests.count) hasWindow=\(window == nil ? 0 : 1) " + - "hasWindowTitle=\((window?.title.isEmpty == false) ? 1 : 0) " + - "prefersImmediate=\(prefersImmediatelyAvailableCredentials) " + - "hasPendingContinuation=\(activeAuthorizationContinuation != nil)" - ) + dlog("webauthn.performAuth requestCount=\(requests.count) window=\(window?.title ?? "(nil)") prefersImmediate=\(prefersImmediatelyAvailableCredentials) hasPendingContinuation=\(activeAuthorizationContinuation != nil)") for (i, req) in requests.enumerated() { - cmuxDebugLog("webauthn.performAuth request[\(i)]=\(type(of: req))") + dlog("webauthn.performAuth request[\(i)]=\(type(of: req))") } #endif guard !requests.isEmpty else { @@ -1437,19 +1409,19 @@ private extension BrowserWebAuthnCoordinator { } guard let window else { #if DEBUG - cmuxDebugLog("webauthn.performAuth FAIL: no window") + dlog("webauthn.performAuth FAIL: no window") #endif throw BrowserWebAuthnBridgeError.notSupported("Native passkey support is unavailable.") } guard activeAuthorizationContinuation == nil else { #if DEBUG - cmuxDebugLog("webauthn.performAuth FAIL: ceremony already in progress") + dlog("webauthn.performAuth FAIL: ceremony already in progress") #endif throw BrowserWebAuthnBridgeError.notAllowed("The passkey request failed.") } #if DEBUG - cmuxDebugLog("webauthn.performAuth launching ASAuthorizationController") + dlog("webauthn.performAuth launching ASAuthorizationController") #endif return try await withCheckedThrowingContinuation { continuation in let controller = ASAuthorizationController(authorizationRequests: requests) @@ -1565,13 +1537,13 @@ private extension BrowserWebAuthnCoordinator { guard !platformRequests.isEmpty || !securityKeyRequests.isEmpty else { #if DEBUG - cmuxDebugLog("webauthn.buildCreationPlan no requests built — returning nil") + dlog("webauthn.buildCreationPlan no requests built — returning nil") #endif return nil } #if DEBUG - cmuxDebugLog("webauthn.buildCreationPlan rp=\(relyingPartyIdentifier) platform=\(platformRequests.count) securityKey=\(securityKeyRequests.count) attachment=\(attachment ?? "(nil)")") + dlog("webauthn.buildCreationPlan rp=\(relyingPartyIdentifier) platform=\(platformRequests.count) securityKey=\(securityKeyRequests.count) attachment=\(attachment ?? "(nil)")") #endif return .init( platformRequests: platformRequests, @@ -1650,7 +1622,7 @@ private extension BrowserWebAuthnCoordinator { guard !platformRequests.isEmpty || !securityKeyRequests.isEmpty else { #if DEBUG - cmuxDebugLog("webauthn.buildAssertionPlan no requests built — returning nil") + dlog("webauthn.buildAssertionPlan no requests built — returning nil") #endif return nil } @@ -1661,7 +1633,7 @@ private extension BrowserWebAuthnCoordinator { allowCredentials.isEmpty ? true : transportSummary.shouldShowHybridTransport #if DEBUG - cmuxDebugLog("webauthn.buildAssertionPlan rp=\(relyingPartyIdentifier) platform=\(platformRequests.count) securityKey=\(securityKeyRequests.count) allowCredentials=\(allowCredentials.count) mediation=\(request.mediation ?? "(nil)") hybridTransport=\(transportSummary.shouldShowHybridTransport)") + dlog("webauthn.buildAssertionPlan rp=\(relyingPartyIdentifier) platform=\(platformRequests.count) securityKey=\(securityKeyRequests.count) allowCredentials=\(allowCredentials.count) mediation=\(request.mediation ?? "(nil)") hybridTransport=\(transportSummary.shouldShowHybridTransport)") #endif return .init( platformRequests: platformRequests, @@ -1871,7 +1843,7 @@ private extension BrowserWebAuthnCoordinator { callerMayPromptForPlatformAuthorization: callerMayPromptForPlatformAuthorization ) #if DEBUG - cmuxDebugLog("webauthn.capability state=\(state.rawValue) authorized=\(authorized) denied=\(denied) canPrompt=\(canPromptForAccess) callerMayPrompt=\(callerMayPromptForPlatformAuthorization) platformSupport=\(platformRequestSupport) securityKeySupport=\(securityKeySupport) deviceConfigured=\(deviceConfiguredForPasskeys as Any) advertisedPlatform=\(platformPasskeyAvailability as Any) btAuth=\(bluetoothState.isAuthorized) btHybrid=\(bluetoothState.canUseHybridTransport)") + dlog("webauthn.capability state=\(state.rawValue) authorized=\(authorized) denied=\(denied) canPrompt=\(canPromptForAccess) callerMayPrompt=\(callerMayPromptForPlatformAuthorization) platformSupport=\(platformRequestSupport) securityKeySupport=\(securityKeySupport) deviceConfigured=\(deviceConfiguredForPasskeys as Any) advertisedPlatform=\(platformPasskeyAvailability as Any) btAuth=\(bluetoothState.isAuthorized) btHybrid=\(bluetoothState.canUseHybridTransport)") #endif var payload: [String: Any] = [ diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index e184996cb5e7..3fc6eeabbf3a 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -1,5 +1,4 @@ import AppKit -import Bonsplit import ObjectiveC import UniformTypeIdentifiers import WebKit @@ -23,6 +22,33 @@ extension WKWebView { } } +enum CmuxWebContentFirstResponderResult: Equatable { + case content + case wrapperOnly + case failed + + var didFocusContent: Bool { + self == .content + } + + var didFocusResponder: Bool { + self != .failed + } + +#if DEBUG + var debugDescription: String { + switch self { + case .content: + return "content" + case .wrapperOnly: + return "wrapper_only" + case .failed: + return "failed" + } + } +#endif +} + struct BrowserImageCopyPasteboardPayload { let imageData: Data let mimeType: String? @@ -104,9 +130,8 @@ enum BrowserImageCopyPasteboardBuilder { /// WKWebView tends to consume some Command-key equivalents (e.g. Cmd+N/Cmd+W), /// preventing the app menu/SwiftUI Commands from receiving them. Route app/menu -/// shortcuts first by default, but allow browser content to try browser-local -/// Find-family shortcuts. Cmd+F stays app-owned so cmux can choose browser find -/// or right-sidebar file search from the current focus owner. +/// shortcuts first by default, but allow browser content to try the Find command +/// family before cmux falls back to its own browser find overlay. final class CmuxWebView: WKWebView { // Some sites/WebKit paths report middle-click link activations as // WKNavigationAction.buttonNumber=4 instead of 2. Track a recent local @@ -118,204 +143,6 @@ final class CmuxWebView: WKWebView { private static var lastMiddleClickIntent: MiddleClickIntent? private static let middleClickIntentMaxAge: TimeInterval = 0.8 - private static let pasteAsPlainTextFocusMessageHandlerName = "cmuxPasteAsPlainTextFocus" - private static var pasteAsPlainTextFocusHandlerInstalledKey: UInt8 = 0 - private static let pasteAsPlainTextSharedHelpersScriptSource = """ - const __cmuxPasteAsPlainTextHelpers = (() => { - const existing = window.__cmuxPasteAsPlainTextHelpers; - if (existing) return existing; - - const supportedTextInputTypes = new Set([ - "", - "text", - "search", - "tel", - "url", - "email", - "password", - "number", - "date", - "datetime-local", - "month", - "time", - "week" - ]); - - const deepestActiveElement = (root) => { - let active = root?.activeElement ?? null; - while (active) { - const shadowActive = active.shadowRoot?.activeElement ?? null; - if (shadowActive && shadowActive !== active) { - active = shadowActive; - continue; - } - - const tagName = typeof active.tagName === "string" ? active.tagName.toUpperCase() : ""; - if (tagName === "IFRAME") { - try { - const frameActive = active.contentDocument?.activeElement ?? null; - if (frameActive && frameActive !== active) { - active = frameActive; - continue; - } - } catch (_) {} - } - - break; - } - return active; - }; - - const isPlainTextTextControl = (el) => { - if (!el || el.disabled || el.readOnly) return false; - - const tagName = typeof el.tagName === "string" ? el.tagName.toUpperCase() : ""; - if (tagName === "TEXTAREA") return true; - if (tagName !== "INPUT") return false; - - const type = typeof el.type === "string" ? el.type.toLowerCase() : "text"; - return supportedTextInputTypes.has(type); - }; - - const isFocusedCrossOriginFrameElement = (el) => { - const tagName = typeof el?.tagName === "string" ? el.tagName.toUpperCase() : ""; - if (tagName !== "IFRAME") return false; - try { - void el.contentDocument; - return false; - } catch (_) { - return true; - } - }; - - const resolvedCandidateElement = (el) => { - if (!el) return deepestActiveElement(document); - - const shadowActive = el.shadowRoot?.activeElement ?? null; - if (shadowActive && shadowActive !== el) { - return deepestActiveElement(el.shadowRoot) ?? shadowActive; - } - - const tagName = typeof el.tagName === "string" ? el.tagName.toUpperCase() : ""; - if (tagName === "IFRAME") { - try { - return deepestActiveElement(el.contentDocument) ?? el; - } catch (_) {} - } - - return el; - }; - - const editableTarget = (el) => { - const candidate = resolvedCandidateElement(el); - if (!candidate) return null; - if (isPlainTextTextControl(candidate)) return candidate; - if (isFocusedCrossOriginFrameElement(candidate)) return candidate; - if (candidate.isContentEditable) return candidate; - return candidate.closest?.('[contenteditable]:not([contenteditable="false"])') ?? null; - }; - - const helpers = { - deepestActiveElement, - isPlainTextTextControl, - isFocusedCrossOriginFrameElement, - resolvedCandidateElement, - editableTarget, - canPasteAsPlainTextInto(el) { - return !!editableTarget(el); - } - }; - window.__cmuxPasteAsPlainTextHelpers = helpers; - return helpers; - })(); - """ - static let pasteAsPlainTextFocusTrackingBootstrapScriptSource = """ - (() => { - try { - if (window.__cmuxPasteAsPlainTextFocusTrackerInstalled) return true; - window.__cmuxPasteAsPlainTextFocusTrackerInstalled = true; - - const handler = (() => { - try { - return window.webkit?.messageHandlers?.\(pasteAsPlainTextFocusMessageHandlerName) ?? null; - } catch (_) { - return null; - } - })(); - - \(pasteAsPlainTextSharedHelpersScriptSource) - - const publishState = { lastCanPaste: null }; - - const publish = (canPaste) => { - if (publishState.lastCanPaste === canPaste) return; - publishState.lastCanPaste = canPaste; - window.__cmuxPasteAsPlainTextTargetAvailable = canPaste; - try { - handler?.postMessage({ canPaste }); - } catch (_) {} - }; - - window.__cmuxCanPasteAsPlainTextIntoCurrentFocus = () => { - return __cmuxPasteAsPlainTextHelpers.canPasteAsPlainTextInto(document.activeElement); - }; - - const publishForElement = (el) => { - publish(__cmuxPasteAsPlainTextHelpers.canPasteAsPlainTextInto(el)); - }; - - document.addEventListener("focusin", (ev) => { - publishForElement(ev && ev.target ? ev.target : document.activeElement); - }, true); - document.addEventListener("focusout", () => { - requestAnimationFrame(() => publishForElement(document.activeElement)); - }, true); - document.addEventListener("selectionchange", () => { - publishForElement(document.activeElement); - }, true); - document.addEventListener("input", () => { - publishForElement(document.activeElement); - }, true); - document.addEventListener("change", () => { - publishForElement(document.activeElement); - }, true); - document.addEventListener("mousedown", (ev) => { - const target = ev && ev.target ? ev.target : null; - if (!__cmuxPasteAsPlainTextHelpers.canPasteAsPlainTextInto(target)) { - publish(false); - } - }, true); - window.addEventListener("beforeunload", () => { - publish(false); - }, true); - - publishForElement(document.activeElement); - return true; - } catch (_) { - return false; - } - })(); - """ - - private final class PasteAsPlainTextFocusMessageHandler: NSObject, WKScriptMessageHandler { - func userContentController( - _ userContentController: WKUserContentController, - didReceive message: WKScriptMessage - ) { - guard let webView = message.webView as? CmuxWebView else { - return - } - guard let body = message.body as? [String: Any], - let canPaste = body["canPaste"] as? Bool else { - return - } - Task { @MainActor [weak webView] in - webView?.updatePasteAsPlainTextTargetAvailable(canPaste) - } - } - } - - private static let sharedPasteAsPlainTextFocusMessageHandler = PasteAsPlainTextFocusMessageHandler() static func hasRecentMiddleClickIntent(for webView: WKWebView) -> Bool { guard let webView = webView as? CmuxWebView else { return false } @@ -349,7 +176,12 @@ final class CmuxWebView: WKWebView { private static var contextMenuFallbackKey: UInt8 = 0 private static let pasteAsPlainTextKeyCode: UInt16 = 9 // V key (hardware position, layout-independent) - + private static let webContentInputSelectors: [Selector] = [ + NSSelectorFromString("insertText:replacementRange:"), + NSSelectorFromString("insertText:"), + NSSelectorFromString("doCommandBySelector:"), + NSSelectorFromString("setMarkedText:selectedRange:replacementRange:"), + ] var onContextMenuDownloadStateChanged: ((Bool) -> Void)? /// Called when "Open Link in New Tab" context menu is selected. /// Bypasses createWebViewWith so the link opens as a tab, not a popup. @@ -357,10 +189,10 @@ final class CmuxWebView: WKWebView { var contextMenuLinkURLProvider: ((CmuxWebView, NSPoint, @escaping (URL?) -> Void) -> Void)? var contextMenuDefaultBrowserOpener: ((URL) -> Bool)? /// Guard against background panes stealing first responder (e.g. page autofocus). - /// BrowserPanelView updates this as pane focus state changes. - var allowsFirstResponderAcquisition: Bool = true + /// BrowserPanel owns this policy and updates it before app-initiated focus handoffs. + var allowsFirstResponderAcquisition: Bool = false private var pointerFocusAllowanceDepth: Int = 0 - private var pasteAsPlainTextTargetAvailable = false + private var spaceContentCandidateForwardingDepth: Int = 0 private var lastPasteAsPlainTextPerformKeyEventTimestamp: TimeInterval? var allowsFirstResponderAcquisitionEffective: Bool { allowsFirstResponderAcquisition || pointerFocusAllowanceDepth > 0 @@ -369,44 +201,10 @@ final class CmuxWebView: WKWebView { override init(frame: NSRect, configuration: WKWebViewConfiguration) { super.init(frame: frame, configuration: configuration) - installPasteAsPlainTextFocusTracking() } required init?(coder: NSCoder) { super.init(coder: coder) - installPasteAsPlainTextFocusTracking() - } - - private func installPasteAsPlainTextFocusTracking() { - let userContentController = configuration.userContentController - if objc_getAssociatedObject( - userContentController, - &Self.pasteAsPlainTextFocusHandlerInstalledKey - ) != nil { - return - } - - userContentController.add( - Self.sharedPasteAsPlainTextFocusMessageHandler, - name: Self.pasteAsPlainTextFocusMessageHandlerName - ) - objc_setAssociatedObject( - userContentController, - &Self.pasteAsPlainTextFocusHandlerInstalledKey, - NSNumber(value: true), - .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } - - private func updatePasteAsPlainTextTargetAvailable(_ available: Bool) { - guard pasteAsPlainTextTargetAvailable != available else { return } - pasteAsPlainTextTargetAvailable = available -#if DEBUG - cmuxDebugLog( - "browser.pasteAsPlainText.target " + - "web=\(ObjectIdentifier(self)) available=\(available ? 1 : 0)" - ) -#endif } override func acceptsFirstMouse(for event: NSEvent?) -> Bool { @@ -417,10 +215,11 @@ final class CmuxWebView: WKWebView { guard allowsFirstResponderAcquisitionEffective else { #if DEBUG let eventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" - cmuxDebugLog( + dlog( "browser.focus.blockedBecome web=\(ObjectIdentifier(self)) " + "policy=\(allowsFirstResponderAcquisition ? 1 : 0) " + - "pointerDepth=\(pointerFocusAllowanceDepth) eventType=\(eventType)" + "pointerDepth=\(pointerFocusAllowanceDepth) " + + "eventType=\(eventType)" ) #endif return false @@ -431,10 +230,11 @@ final class CmuxWebView: WKWebView { } #if DEBUG let eventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" - cmuxDebugLog( + dlog( "browser.focus.become web=\(ObjectIdentifier(self)) result=\(result ? 1 : 0) " + "policy=\(allowsFirstResponderAcquisition ? 1 : 0) " + - "pointerDepth=\(pointerFocusAllowanceDepth) eventType=\(eventType)" + "pointerDepth=\(pointerFocusAllowanceDepth) " + + "eventType=\(eventType)" ) #endif return result @@ -445,7 +245,7 @@ final class CmuxWebView: WKWebView { func withPointerFocusAllowance(_ body: () -> T) -> T { pointerFocusAllowanceDepth += 1 #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.pointerAllowance.enter web=\(ObjectIdentifier(self)) " + "depth=\(pointerFocusAllowanceDepth)" ) @@ -453,7 +253,7 @@ final class CmuxWebView: WKWebView { defer { pointerFocusAllowanceDepth = max(0, pointerFocusAllowanceDepth - 1) #if DEBUG - cmuxDebugLog( + dlog( "browser.focus.pointerAllowance.exit web=\(ObjectIdentifier(self)) " + "depth=\(pointerFocusAllowanceDepth)" ) @@ -462,82 +262,245 @@ final class CmuxWebView: WKWebView { return body() } - private static func isPasteAsPlainTextCommandEquivalent(_ event: NSEvent) -> Bool { - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) - return event.keyCode == pasteAsPlainTextKeyCode && normalizedFlags == [.command, .shift] + @discardableResult + func requestWebContentFirstResponder(in window: NSWindow) -> CmuxWebContentFirstResponderResult { + focusWrapperFirstResponder( + in: window, + event: "browser.focus.acquireWebContent", + reason: nil, + forceWrapperReactivation: false + ) } - private func webKitPasteAsPlainTextFallback(_ sender: Any?) { - let selector = NSSelectorFromString("pasteAsPlainText:") - guard let method = class_getInstanceMethod(WKWebView.self, selector) else { - return + @discardableResult + func reassertWebContentFirstResponder( + in window: NSWindow, + reason: String, + forceWrapperReactivation: Bool = false + ) -> CmuxWebContentFirstResponderResult { + focusWrapperFirstResponder( + in: window, + event: "browser.focus.reassertWebContent", + reason: reason, + forceWrapperReactivation: forceWrapperReactivation + ) + } + + @discardableResult + private func focusWrapperFirstResponder( + in window: NSWindow, + event: String, + reason: String?, + forceWrapperReactivation: Bool + ) -> CmuxWebContentFirstResponderResult { + if let firstResponderView = window.firstResponder as? NSView, + firstResponderView !== self, + firstResponderView.isDescendant(of: self) { + if forceWrapperReactivation { + let focusedWrapper = window.makeFirstResponder(self) + if focusedWrapper { + NotificationCenter.default.post(name: .browserDidBecomeFirstResponderWebView, object: self) + } + return focusedWrapper ? .wrapperOnly : .failed + } +#if DEBUG + dlog( + "\(event) web=\(ObjectIdentifier(self)) " + + "\(reason.map { "reason=\($0) " } ?? "")" + + "mode=already_content " + + "responder=\(String(describing: type(of: firstResponderView))) " + + "candidates=\(debugWebContentFocusCandidateSummary(in: window))" + ) +#endif + NotificationCenter.default.post(name: .browserDidBecomeFirstResponderWebView, object: self) + return .content } - typealias PasteAsPlainTextFn = @convention(c) (AnyObject, Selector, Any?) -> Void - let implementation = method_getImplementation(method) - unsafeBitCast(implementation, to: PasteAsPlainTextFn.self)(self, selector, sender) + if window.firstResponder === self { + if forceWrapperReactivation { + return .wrapperOnly + } +#if DEBUG + dlog( + "\(event) web=\(ObjectIdentifier(self)) " + + "\(reason.map { "reason=\($0) " } ?? "")" + + "mode=already_wrapper " + + "candidates=\(debugWebContentFocusCandidateSummary(in: window))" + ) +#endif + NotificationCenter.default.post(name: .browserDidBecomeFirstResponderWebView, object: self) + return .wrapperOnly + } + +#if DEBUG + let previousResponder = window.firstResponder +#endif + let focusedWrapper = window.makeFirstResponder(self) +#if DEBUG + dlog( + "\(event) web=\(ObjectIdentifier(self)) " + + "\(reason.map { "reason=\($0) " } ?? "")" + + "mode=\(forceWrapperReactivation ? "wrapper_reassert" : "wrapper") " + + "result=\(focusedWrapper ? 1 : 0) " + + "previous=\(previousResponder.map { String(describing: type(of: $0)) } ?? "nil") " + + "candidates=\(debugWebContentFocusCandidateSummary(in: window))" + ) +#endif + if focusedWrapper { + NotificationCenter.default.post(name: .browserDidBecomeFirstResponderWebView, object: self) + } + return focusedWrapper ? .wrapperOnly : .failed } - // Key-equivalent handling is synchronous, so this bounded preflight pumps the main run loop. - // Keep callers limited to fast, side-effect-free reads from page-owned state. - private func evaluateJavaScriptSynchronously( - _ script: String, - timeout: TimeInterval = 0.25 - ) -> (completed: Bool, result: Any?, error: Error?) { - var completed = false - var result: Any? - var error: Error? + private struct WebContentFirstResponderCandidate { + let view: NSView + let depth: Int + let score: Int + let isCurrentFirstResponder: Bool + } + + private func webContentFirstResponderCandidates(in window: NSWindow) -> [NSView] { + let currentFirstResponder = window.firstResponder as? NSView + var candidates: [WebContentFirstResponderCandidate] = [] + + func appendCandidate(_ view: NSView, depth: Int) { + guard view !== self, + view.window === window, + !view.isHiddenOrHasHiddenAncestor, + Self.isWebContentFirstResponderCandidate(view), + !candidates.contains(where: { $0.view === view }) else { + return + } - evaluateJavaScript(script) { jsResult, jsError in - result = jsResult - error = jsError - completed = true + candidates.append( + WebContentFirstResponderCandidate( + view: view, + depth: depth, + score: Self.webContentFirstResponderCandidateScore(view, depth: depth), + isCurrentFirstResponder: currentFirstResponder === view + ) + ) + } + + if let currentFirstResponder, + currentFirstResponder.isDescendant(of: self) { + appendCandidate(currentFirstResponder, depth: Self.webContentFirstResponderDepth(currentFirstResponder, in: self)) } - let deadline = ProcessInfo.processInfo.systemUptime + timeout - while !completed { - let remaining = deadline - ProcessInfo.processInfo.systemUptime - guard remaining > 0 else { break } + var stack: [(NSView, Int)] = subviews.map { ($0, 1) } + while let (view, depth) = stack.popLast() { + appendCandidate(view, depth: depth) + stack.append(contentsOf: view.subviews.map { ($0, depth + 1) }) + } - let sliceEnd = Date(timeIntervalSinceNow: min(remaining, 0.01)) - _ = RunLoop.current.run(mode: .default, before: sliceEnd) - if !completed { - _ = RunLoop.current.run(mode: .eventTracking, before: sliceEnd) + return candidates + .sorted { lhs, rhs in + if lhs.score != rhs.score { return lhs.score > rhs.score } + if lhs.depth != rhs.depth { return lhs.depth > rhs.depth } + if lhs.isCurrentFirstResponder != rhs.isCurrentFirstResponder { + return lhs.isCurrentFirstResponder + } + return String(describing: ObjectIdentifier(lhs.view)) < String(describing: ObjectIdentifier(rhs.view)) } + .map(\.view) + } + + private static func isWebContentFirstResponderCandidate(_ view: NSView) -> Bool { + if webContentInputSelectors.contains(where: { view.responds(to: $0) }) { + return true } - return (completed, result, error) + guard view.acceptsFirstResponder else { return false } + let className = String(describing: type(of: view)) + return className.contains("WKContent") || className == "WKView" } - private func pageCanAcceptPlainTextPaste() -> Bool { - let script = """ - (() => { - try { - const fn = window.__cmuxCanPasteAsPlainTextIntoCurrentFocus; - return typeof fn === 'function' ? !!fn() : false; - } catch (_) { - return false; - } - })(); - """ + private static func webContentFirstResponderDepth(_ view: NSView, in root: NSView) -> Int { + var depth = 0 + var current: NSView? = view + while let candidate = current, candidate !== root { + depth += 1 + current = candidate.superview + } + return depth + } + + private static func webContentFirstResponderCandidateScore(_ view: NSView, depth: Int) -> Int { + let className = String(describing: type(of: view)) + let respondsInput = webContentInputSelectors.contains { view.responds(to: $0) } + let depthScore = min(depth, 50) + + if className.contains("WKContent") { + return 500 + depthScore + } + if className == "WKView" { + return 450 + depthScore + } + if className.contains("Content") { + return 400 + depthScore + } + if respondsInput && !className.contains("Flipped") { + return 350 + depthScore + } + if className.contains("WK") && !className.contains("Flipped") { + return 300 + depthScore + } + if respondsInput { + return 200 + depthScore + } + return depthScore + } - let evaluation = evaluateJavaScriptSynchronously(script) - let canPaste = evaluation.completed && ((evaluation.result as? Bool) ?? false) #if DEBUG - let errorDescription = evaluation.completed - ? (evaluation.error?.localizedDescription ?? "nil") - : "timeout" - cmuxDebugLog( - "browser.pasteAsPlainText.preflight " + - "web=\(ObjectIdentifier(self)) canPaste=\(canPaste ? 1 : 0) " + - "error=\(errorDescription)" - ) + private func debugWebContentFocusCandidateSummary(in window: NSWindow) -> String { + var summaries: [String] = [] + var stack: [(NSView, Int)] = subviews.reversed().map { ($0, 1) } + while let (view, depth) = stack.popLast(), summaries.count < 24 { + let className = String(describing: type(of: view)) + let accepts = view.acceptsFirstResponder + let respondsInput = Self.webContentInputSelectors.contains { view.responds(to: $0) } + let candidate = Self.isWebContentFirstResponderCandidate(view) + if candidate || + accepts || + respondsInput || + className.contains("WK") || + className.contains("Content") { + summaries.append( + "\(depth):\(className)" + + "{win=\(view.window === window ? 1 : 0)," + + "hidden=\(view.isHiddenOrHasHiddenAncestor ? 1 : 0)," + + "accepts=\(accepts ? 1 : 0)," + + "input=\(respondsInput ? 1 : 0)," + + "candidate=\(candidate ? 1 : 0)," + + "score=\(Self.webContentFirstResponderCandidateScore(view, depth: depth))}" + ) + } + stack.append(contentsOf: view.subviews.reversed().map { ($0, depth + 1) }) + } + return summaries.isEmpty ? "none" : summaries.joined(separator: "|") + } #endif - return canPaste + + private static func isPasteAsPlainTextCommandEquivalent(_ event: NSEvent) -> Bool { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) + return event.keyCode == pasteAsPlainTextKeyCode && normalizedFlags == [.command, .shift] } - private func shouldSkipRepeatedPasteAsPlainTextPreflight(for event: NSEvent) -> Bool { + @discardableResult + private func webKitPasteAsPlainTextFallback(_ sender: Any?) -> Bool { + let selector = NSSelectorFromString("pasteAsPlainText:") + guard let method = class_getInstanceMethod(WKWebView.self, selector) else { + return false + } + + typealias PasteAsPlainTextFn = @convention(c) (AnyObject, Selector, Any?) -> Void + let implementation = method_getImplementation(method) + unsafeBitCast(implementation, to: PasteAsPlainTextFn.self)(self, selector, sender) + return true + } + + private func shouldSkipRepeatedPasteAsPlainTextHandling(for event: NSEvent) -> Bool { guard event.timestamp > 0, let lastTimestamp = lastPasteAsPlainTextPerformKeyEventTimestamp else { return false @@ -548,15 +511,15 @@ final class CmuxWebView: WKWebView { @discardableResult private func performPasteAsPlainTextFromPasteboard(_ sender: Any? = nil) -> Bool { - guard pasteAsPlainTextTargetAvailable, - NSPasteboard.general.string(forType: .string) != nil, - pageCanAcceptPlainTextPaste() else { + guard NSPasteboard.general.string(forType: .string) != nil else { return false } - webKitPasteAsPlainTextFallback(sender) + guard webKitPasteAsPlainTextFallback(sender) else { + return false + } #if DEBUG - cmuxDebugLog( + dlog( "browser.pasteAsPlainText " + "web=\(ObjectIdentifier(self)) routedNative=1" ) @@ -573,16 +536,69 @@ final class CmuxWebView: WKWebView { override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { if item.action == #selector(pasteAsPlainText(_:)) { - return pasteAsPlainTextTargetAvailable - && NSPasteboard.general.string(forType: .string) != nil + return NSPasteboard.general.string(forType: .string) != nil } return super.validateUserInterfaceItem(item) } +#if DEBUG + func debugLogSpaceFocusProbe(stage: String, event: NSEvent? = nil, extra: String = "") { + let currentWindow = window + let firstResponder = currentWindow?.firstResponder + let firstResponderDescription = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let firstResponderInWebView: Bool = { + guard let view = firstResponder as? NSView else { return false } + return view === self || view.isDescendant(of: self) + }() + let eventFields: String + if let event { + eventFields = Self.debugEventFields(event) + } else { + eventFields = "keyCode=nil" + } + let extraSuffix = extra.isEmpty ? "" : " \(extra)" + dlog( + "browser.space.native stage=\(stage) web=\(ObjectIdentifier(self)) " + + "win=\(currentWindow?.windowNumber ?? -1) fr=\(firstResponderDescription) " + + "frInWeb=\(firstResponderInWebView ? 1 : 0) " + + "policy=\(allowsFirstResponderAcquisition ? 1 : 0) " + + "pointerDepth=\(pointerFocusAllowanceDepth) " + + "\(eventFields)\(extraSuffix)" + ) + } + + private static func debugHex(_ value: String?) -> String { + guard let value else { return "nil" } + guard !value.isEmpty else { return "empty" } + return value.unicodeScalars + .map { String(format: "%04X", $0.value) } + .joined(separator: ",") + } + + private static func debugEventFields(_ event: NSEvent) -> String { + switch event.type { + case .keyDown, .keyUp: + return "keyCode=\(event.keyCode) charsHex=\(debugHex(event.characters)) " + + "ignoringHex=\(debugHex(event.charactersIgnoringModifiers)) " + + "mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)" + case .flagsChanged: + return "keyCode=\(event.keyCode) charsHex=nil ignoringHex=nil " + + "mods=\(event.modifierFlags.rawValue) repeat=0" + default: + return "eventType=\(event.type.rawValue) keyCode=nil charsHex=nil ignoringHex=nil " + + "mods=\(event.modifierFlags.rawValue) repeat=0" + } + } + +#endif + override func performKeyEquivalent(with event: NSEvent) -> Bool { #if DEBUG let typingTimingStart = CmuxTypingTiming.start() var handled = false + if event.keyCode == 49 { + debugLogSpaceFocusProbe(stage: "web.performKeyEquivalent.enter", event: event) + } defer { CmuxTypingTiming.logDuration( path: "browser.web.performKeyEquivalent", @@ -590,6 +606,13 @@ final class CmuxWebView: WKWebView { event: event, extra: "handled=\(handled ? 1 : 0)" ) + if event.keyCode == 49 { + debugLogSpaceFocusProbe( + stage: "web.performKeyEquivalent.exit", + event: event, + extra: "handled=\(handled ? 1 : 0)" + ) + } } #endif if event.keyCode == 36 || event.keyCode == 76 { @@ -597,6 +620,20 @@ final class CmuxWebView: WKWebView { // receives the keyDown path used by form submission handlers. return false } + if event.keyCode == 49 { + let normalizedFlags = event.modifierFlags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + if normalizedFlags.isEmpty || normalizedFlags == [.shift] { + // Space is text input or page-scroll input in WebKit, not a cmux key equivalent. + // Returning false here covers responder-chain states where NSWindow did not + // classify the current first responder as browser-owned before asking WKWebView. +#if DEBUG + debugLogSpaceFocusProbe(stage: "web.performKeyEquivalent.bypassSpace", event: event) +#endif + return false + } + } let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) // Menu/app shortcut routing is only needed for Command equivalents @@ -649,14 +686,17 @@ final class CmuxWebView: WKWebView { return result } - if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalentBeforeMainMenu(event) == true { + // Let the app menu handle key equivalents first (New Tab, Close Tab, tab switching, etc). + if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) { #if DEBUG handled = true #endif return true } - if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) { + // Handle app-level shortcuts that are not menu-backed (for example split commands). + // Without this, WebKit can consume Cmd-based shortcuts before the app monitor sees them. + if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true { #if DEBUG handled = true #endif @@ -681,6 +721,9 @@ final class CmuxWebView: WKWebView { #if DEBUG let typingTimingStart = CmuxTypingTiming.start() var route = "super" + if event.keyCode == 49 { + debugLogSpaceFocusProbe(stage: "web.keyDown.enter", event: event) + } defer { CmuxTypingTiming.logDuration( path: "browser.web.keyDown", @@ -688,10 +731,13 @@ final class CmuxWebView: WKWebView { event: event, extra: "route=\(route)" ) + if event.keyCode == 49 { + debugLogSpaceFocusProbe(stage: "web.keyDown.exit", event: event, extra: "route=\(route)") + } } #endif if Self.isPasteAsPlainTextCommandEquivalent(event) { - if shouldSkipRepeatedPasteAsPlainTextPreflight(for: event) { + if shouldSkipRepeatedPasteAsPlainTextHandling(for: event) { #if DEBUG route = "super" #endif @@ -706,6 +752,13 @@ final class CmuxWebView: WKWebView { } } + if dispatchPlainSpaceKeyDownToWebContentCandidateIfNeeded(event) { +#if DEBUG + route = "contentCandidate.spaceKeyDown" +#endif + return + } + // Some Cmd-based key paths in WebKit don't consistently invoke performKeyEquivalent. // Route them through the same app-level shortcut handler as a fallback. if event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command), @@ -719,17 +772,68 @@ final class CmuxWebView: WKWebView { super.keyDown(with: event) } + @discardableResult + private func dispatchPlainSpaceKeyDownToWebContentCandidateIfNeeded(_ event: NSEvent) -> Bool { + guard shouldDispatchBrowserSpaceViaFirstResponderKeyDown( + keyCode: event.keyCode, + firstResponderIsBrowser: true, + firstResponderHasMarkedText: browserResponderHasMarkedText(self), + flags: event.modifierFlags + ) else { + return false + } + guard spaceContentCandidateForwardingDepth == 0 else { + return false + } + guard let currentWindow = window, + let candidate = webContentFirstResponderCandidates(in: currentWindow).first else { + return false + } + + spaceContentCandidateForwardingDepth += 1 + defer { + spaceContentCandidateForwardingDepth = max(0, spaceContentCandidateForwardingDepth - 1) + } + + candidate.keyDown(with: event) + return true + } + + override func insertText(_ insertString: Any) { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + if let text = insertString as? String, text == " " { + debugLogSpaceFocusProbe(stage: "web.insertText.space.enter", event: NSApp.currentEvent) + } + defer { + let event = NSApp.currentEvent + if let event { + CmuxTypingTiming.logDuration( + path: "browser.web.insertText", + startedAt: typingTimingStart, + event: event, + extra: "route=super" + ) + } + if let text = insertString as? String, text == " " { + debugLogSpaceFocusProbe(stage: "web.insertText.space.exit", event: event) + } + } +#endif + super.insertText(insertString) + } + // 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. + // WorkspaceSplit focus tracks which pane the user clicked in. override func mouseDown(with event: NSEvent) { #if DEBUG let windowNumber = window?.windowNumber ?? -1 let firstResponderType = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - cmuxDebugLog( + dlog( "browser.focus.mouseDown web=\(ObjectIdentifier(self)) " + "policy=\(allowsFirstResponderAcquisition ? 1 : 0) " + "pointerDepth=\(pointerFocusAllowanceDepth) win=\(windowNumber) fr=\(firstResponderType)" @@ -750,7 +854,7 @@ final class CmuxWebView: WKWebView { #if DEBUG let point = convert(event.locationInWindow, from: nil) let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue - cmuxDebugLog( + dlog( "browser.mouse.otherDown web=\(ObjectIdentifier(self)) button=\(event.buttonNumber) " + "clicks=\(event.clickCount) mods=\(mods) point=(\(Int(point.x)),\(Int(point.y)))" ) @@ -760,13 +864,13 @@ final class CmuxWebView: WKWebView { switch event.buttonNumber { case 3: #if DEBUG - cmuxDebugLog("browser.mouse.otherDown.action web=\(ObjectIdentifier(self)) kind=goBack canGoBack=\(canGoBack ? 1 : 0)") + dlog("browser.mouse.otherDown.action web=\(ObjectIdentifier(self)) kind=goBack canGoBack=\(canGoBack ? 1 : 0)") #endif goBack() return case 4: #if DEBUG - cmuxDebugLog("browser.mouse.otherDown.action web=\(ObjectIdentifier(self)) kind=goForward canGoForward=\(canGoForward ? 1 : 0)") + dlog("browser.mouse.otherDown.action web=\(ObjectIdentifier(self)) kind=goForward canGoForward=\(canGoForward ? 1 : 0)") #endif goForward() return @@ -783,7 +887,7 @@ final class CmuxWebView: WKWebView { #if DEBUG let point = convert(event.locationInWindow, from: nil) let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue - cmuxDebugLog( + dlog( "browser.mouse.otherUp web=\(ObjectIdentifier(self)) button=\(event.buttonNumber) " + "clicks=\(event.clickCount) mods=\(mods) point=(\(Int(point.x)),\(Int(point.y)))" ) @@ -839,115 +943,10 @@ final class CmuxWebView: WKWebView { private func debugContextDownload(_ message: @autoclosure () -> String) { #if DEBUG - cmuxDebugLog(Self.redactedContextDownloadDebugMessage(message())) + dlog(message()) #endif } - #if DEBUG - private static let contextDownloadFieldPattern = try! NSRegularExpression( - pattern: "(^| )([A-Za-z][A-Za-z0-9_-]*)=", - options: [] - ) - - private static func redactedContextDownloadDebugMessage(_ message: String) -> String { - let nsMessage = message as NSString - let fullRange = NSRange(location: 0, length: nsMessage.length) - let matches = contextDownloadFieldPattern.matches(in: message, range: fullRange) - guard !matches.isEmpty else { return message } - - var result = "" - var cursor = 0 - var matchIndex = 0 - - while matchIndex < matches.count { - let match = matches[matchIndex] - let fieldStart = match.range.location - if cursor < fieldStart { - result += nsMessage.substring( - with: NSRange(location: cursor, length: fieldStart - cursor) - ) - } - - let separatorRange = match.range(at: 1) - if separatorRange.length > 0 { - result += " " - } - - let keyRange = match.range(at: 2) - let key = nsMessage.substring(with: keyRange) - let valueStart = match.range.location + match.range.length - let sensitive = shouldRedactContextDownloadField(key) - let valueEnd: Int - - if sensitive && key.lowercased() == "payload" { - valueEnd = nsMessage.length - matchIndex = matches.count - } else { - valueEnd = matchIndex + 1 < matches.count - ? matches[matchIndex + 1].range.location - : nsMessage.length - matchIndex += 1 - } - - let valueLength = max(0, valueEnd - valueStart) - let value = nsMessage.substring(with: NSRange(location: valueStart, length: valueLength)) - - if sensitive { - result += "\(key)=\(redactedContextDownloadValue(key: key, value: value))" - } else { - result += nsMessage.substring( - with: NSRange(location: keyRange.location, length: valueEnd - keyRange.location) - ) - } - - cursor = valueEnd - } - - if cursor < nsMessage.length { - result += nsMessage.substring( - with: NSRange(location: cursor, length: nsMessage.length - cursor) - ) - } - - return result - } - - private static func shouldRedactContextDownloadField(_ key: String) -> Bool { - let normalized = key.lowercased() - return normalized == "referer" || - normalized == "path" || - normalized == "payload" || - normalized.hasSuffix("url") - } - - private static func redactedContextDownloadValue(key: String, value: String) -> String { - guard value != "nil", !value.isEmpty else { return value } - - if shouldTreatContextDownloadFieldAsURL(key), - let url = URL(string: value), - let scheme = url.scheme?.lowercased(), - !scheme.isEmpty { - switch scheme { - case "http", "https": - return "\(scheme)://\(url.host ?? "unknown")" - case "data": - return "data:" - case "file": - return "file:" - default: - return "\(scheme):" - } - } - - return "" - } - - private static func shouldTreatContextDownloadFieldAsURL(_ key: String) -> Bool { - let normalized = key.lowercased() - return normalized == "referer" || normalized.hasSuffix("url") - } - #endif - private static func selectorName(_ selector: Selector?) -> String { guard let selector else { return "nil" } return NSStringFromSelector(selector) @@ -1983,15 +1982,15 @@ final class CmuxWebView: WKWebView { // MARK: - Drag-and-drop passthrough // WKWebView inherently calls registerForDraggedTypes with public.text (and others). - // Bonsplit tab drags use NSString (public.utf8-plain-text) which conforms to public.text, + // WorkspaceSplit tab drags use NSString (public.utf8-plain-text) which conforms to public.text, // so AppKit's view-hierarchy-based drag routing delivers the session to WKWebView instead // of SwiftUI's sibling .onDrop overlays. Rejecting in draggingEntered doesn't help because // AppKit only bubbles up through superviews, not siblings. // - // Fix: filter out text-based types that conflict with bonsplit tab drags, but keep + // Fix: filter out text-based types that conflict with WorkspaceSplit tab drags, but keep // file URL types so Finder file drops and HTML drag-and-drop work. private static let blockedDragTypes: Set = [ - .string, // public.utf8-plain-text — matches bonsplit's NSString tab drags + .string, // public.utf8-plain-text — matches WorkspaceSplit's NSString tab drags NSPasteboard.PasteboardType("public.text"), NSPasteboard.PasteboardType("public.plain-text"), NSPasteboard.PasteboardType("com.splittabbar.tabtransfer"), @@ -1999,7 +1998,7 @@ final class CmuxWebView: WKWebView { ] static func shouldRejectInternalPaneDrag(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { - DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes) + DragOverlayRoutingPolicy.hasSplitTabTransfer(pasteboardTypes) || DragOverlayRoutingPolicy.hasSidebarTabReorder(pasteboardTypes) } diff --git a/Sources/Panels/MarkdownPanelView.swift b/Sources/Panels/MarkdownPanelView.swift index 75367ead1b72..03b23dc36782 100644 --- a/Sources/Panels/MarkdownPanelView.swift +++ b/Sources/Panels/MarkdownPanelView.swift @@ -5,9 +5,7 @@ import MarkdownUI /// SwiftUI view that renders a MarkdownPanel's content using MarkdownUI. struct MarkdownPanelView: View { @ObservedObject var panel: MarkdownPanel - let isFocused: Bool let isVisibleInUI: Bool - let portalPriority: Int let onRequestPanelFocus: () -> Void @State private var focusFlashOpacity: Double = 0.0 diff --git a/Sources/Panels/Panel.swift b/Sources/Panels/Panel.swift index fc0554dd9428..db6650523d16 100644 --- a/Sources/Panels/Panel.swift +++ b/Sources/Panels/Panel.swift @@ -26,6 +26,67 @@ public enum PanelFocusIntent: Equatable { case browser(BrowserPanelFocusIntent) } +enum WorkspaceFocusTarget: Equatable { + case panel(UUID) + case terminalSurface(UUID) + case terminalFindField(UUID) + case browserWebContent(UUID) + case browserAddressBar(UUID, requestId: UUID?) + case browserFindField(UUID, requestId: UUID?) + case none + + var debugDescription: String { + switch self { + case .panel(let id): + return "panel(\(id.uuidString.prefix(5)))" + case .terminalSurface(let id): + return "terminalSurface(\(id.uuidString.prefix(5)))" + case .terminalFindField(let id): + return "terminalFindField(\(id.uuidString.prefix(5)))" + case .browserWebContent(let id): + return "browserWebContent(\(id.uuidString.prefix(5)))" + case .browserAddressBar(let id, let requestId): + return "browserAddressBar(\(id.uuidString.prefix(5)),request=\(requestId?.uuidString.prefix(8) ?? "nil"))" + case .browserFindField(let id, let requestId): + return "browserFindField(\(id.uuidString.prefix(5)),request=\(requestId?.uuidString.prefix(8) ?? "nil"))" + case .none: + return "none" + } + } +} + +enum WorkspaceFocusActual: Equatable { + case panel(UUID) + case terminalSurface(UUID) + case terminalFindField(UUID) + case browserWebViewWrapper(UUID) + case browserWebContent(UUID) + case browserAddressBar(UUID) + case browserFindField(UUID) + case none + + var debugDescription: String { + switch self { + case .panel(let id): + return "panel(\(id.uuidString.prefix(5)))" + case .terminalSurface(let id): + return "terminalSurface(\(id.uuidString.prefix(5)))" + case .terminalFindField(let id): + return "terminalFindField(\(id.uuidString.prefix(5)))" + case .browserWebViewWrapper(let id): + return "browserWebViewWrapper(\(id.uuidString.prefix(5)))" + case .browserWebContent(let id): + return "browserWebContent(\(id.uuidString.prefix(5)))" + case .browserAddressBar(let id): + return "browserAddressBar(\(id.uuidString.prefix(5)))" + case .browserFindField(let id): + return "browserFindField(\(id.uuidString.prefix(5)))" + case .none: + return "none" + } + } +} + public enum WorkspaceAttentionFlashReason: String, Equatable, Sendable { case navigation case notificationArrival diff --git a/Sources/Panels/PanelContentView.swift b/Sources/Panels/PanelContentView.swift deleted file mode 100644 index 2cfb6bf6e981..000000000000 --- a/Sources/Panels/PanelContentView.swift +++ /dev/null @@ -1,60 +0,0 @@ -import SwiftUI -import Foundation -import Bonsplit - -/// View that renders the appropriate panel view based on panel type -struct PanelContentView: View { - let panel: any Panel - let paneId: PaneID - let isFocused: Bool - let isSelectedInPane: Bool - let isVisibleInUI: Bool - let portalPriority: Int - let isSplit: Bool - let appearance: PanelAppearance - let hasUnreadNotification: Bool - let onFocus: () -> Void - let onRequestPanelFocus: () -> Void - let onTriggerFlash: () -> Void - - var body: some View { - switch panel.panelType { - case .terminal: - if let terminalPanel = panel as? TerminalPanel { - TerminalPanelView( - panel: terminalPanel, - paneId: paneId, - isFocused: isFocused, - isVisibleInUI: isVisibleInUI, - portalPriority: portalPriority, - isSplit: isSplit, - appearance: appearance, - hasUnreadNotification: hasUnreadNotification, - onFocus: onFocus, - onTriggerFlash: onTriggerFlash - ) - } - case .browser: - if let browserPanel = panel as? BrowserPanel { - BrowserPanelView( - panel: browserPanel, - paneId: paneId, - isFocused: isFocused, - isVisibleInUI: isVisibleInUI, - portalPriority: portalPriority, - onRequestPanelFocus: onRequestPanelFocus - ) - } - case .markdown: - if let markdownPanel = panel as? MarkdownPanel { - MarkdownPanelView( - panel: markdownPanel, - isFocused: isFocused, - isVisibleInUI: isVisibleInUI, - portalPriority: portalPriority, - onRequestPanelFocus: onRequestPanelFocus - ) - } - } - } -} diff --git a/Sources/Panels/ReactGrab.swift b/Sources/Panels/ReactGrab.swift index e4670b36a91a..cc6da2e3b5ce 100644 --- a/Sources/Panels/ReactGrab.swift +++ b/Sources/Panels/ReactGrab.swift @@ -3,7 +3,6 @@ import Foundation import WebKit #if DEBUG -import Bonsplit #endif // MARK: - Settings @@ -180,18 +179,18 @@ class ReactGrabMessageHandler: NSObject, WKScriptMessageHandler { #if DEBUG switch bridgeMessage { case .stateChange(let isActive): - cmuxDebugLog("reactGrab.messageHandler type=stateChange isActive=\(isActive)") + dlog("reactGrab.messageHandler type=stateChange isActive=\(isActive)") case .copySuccess(let content, _): - cmuxDebugLog("reactGrab.messageHandler type=copySuccess len=\(content.count)") + dlog("reactGrab.messageHandler type=copySuccess len=\(content.count)") } #endif Task { @MainActor in #if DEBUG switch bridgeMessage { case .stateChange(let isActive): - cmuxDebugLog("reactGrab.messageHandler.mainActor type=stateChange isActive=\(isActive)") + dlog("reactGrab.messageHandler.mainActor type=stateChange isActive=\(isActive)") case .copySuccess(let content, _): - cmuxDebugLog("reactGrab.messageHandler.mainActor type=copySuccess len=\(content.count)") + dlog("reactGrab.messageHandler.mainActor type=copySuccess len=\(content.count)") } #endif onMessage(bridgeMessage) @@ -229,7 +228,7 @@ extension BrowserPanel { func armReactGrabRoundTrip(returnTo panelId: UUID) { let token = UUID().uuidString #if DEBUG - cmuxDebugLog( + dlog( "reactGrab.pasteback h3.arm " + "workspace=\(workspaceId.uuidString.prefix(5)) " + "browser=\(id.uuidString.prefix(5)) " + @@ -245,7 +244,7 @@ extension BrowserPanel { let previousTarget = pendingReactGrabReturnTargetPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - cmuxDebugLog( + dlog( "reactGrab.pasteback h3.clear " + "workspace=\(workspaceId.uuidString.prefix(5)) " + "browser=\(id.uuidString.prefix(5)) " + @@ -264,7 +263,7 @@ extension BrowserPanel { let pendingTarget = pendingReactGrabReturnTargetPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - cmuxDebugLog( + dlog( "reactGrab.pasteback h3.stateChange " + "workspace=\(workspaceId.uuidString.prefix(5)) " + "browser=\(id.uuidString.prefix(5)) " + @@ -275,7 +274,7 @@ extension BrowserPanel { guard let returnPanelId = pendingReactGrabReturnTargetPanelId, let expectedToken = pendingReactGrabRoundTripToken else { #if DEBUG - cmuxDebugLog( + dlog( "reactGrab.pasteback h3.copySuccess.drop " + "workspace=\(workspaceId.uuidString.prefix(5)) " + "browser=\(id.uuidString.prefix(5)) reason=noReturnTarget len=\(content.count)" @@ -285,7 +284,7 @@ extension BrowserPanel { } guard token == expectedToken else { #if DEBUG - cmuxDebugLog( + dlog( "reactGrab.pasteback h3.copySuccess.drop " + "workspace=\(workspaceId.uuidString.prefix(5)) " + "browser=\(id.uuidString.prefix(5)) reason=tokenMismatch len=\(content.count)" @@ -295,7 +294,7 @@ extension BrowserPanel { return } #if DEBUG - cmuxDebugLog( + dlog( "reactGrab.pasteback h3.copySuccess " + "workspace=\(workspaceId.uuidString.prefix(5)) " + "browser=\(id.uuidString.prefix(5)) " + @@ -319,16 +318,16 @@ extension BrowserPanel { func injectReactGrab() async { #if DEBUG - cmuxDebugLog("reactGrab.inject.start") + dlog("reactGrab.inject.start") #endif guard let scriptSource = await ReactGrabScriptLoader.fetch() else { #if DEBUG - cmuxDebugLog("reactGrab.inject.fetchFailed") + dlog("reactGrab.inject.fetchFailed") #endif return } #if DEBUG - cmuxDebugLog("reactGrab.inject.fetched len=\(scriptSource.count)") + dlog("reactGrab.inject.fetched len=\(scriptSource.count)") #endif let handlerName = reactGrabMessageHandlerName @@ -395,11 +394,11 @@ extension BrowserPanel { \(scriptSource) """ #if DEBUG - cmuxDebugLog("reactGrab.inject.evalJS len=\(combined.count)") + dlog("reactGrab.inject.evalJS len=\(combined.count)") #endif webView.evaluateJavaScript(combined) { [weak self] _, error in #if DEBUG - cmuxDebugLog("reactGrab.inject.evalJS.done error=\(error?.localizedDescription ?? "none")") + dlog("reactGrab.inject.evalJS.done error=\(error?.localizedDescription ?? "none")") #endif if let error { NSLog("ReactGrab: injection failed: %@", error.localizedDescription) @@ -407,18 +406,18 @@ extension BrowserPanel { } } #if DEBUG - cmuxDebugLog("reactGrab.inject.end") + dlog("reactGrab.inject.end") #endif } func toggleReactGrab() { #if DEBUG - cmuxDebugLog("reactGrab.toggle.start") + dlog("reactGrab.toggle.start") #endif let script = "window.__REACT_GRAB__?.toggle()" webView.evaluateJavaScript(script, completionHandler: nil) #if DEBUG - cmuxDebugLog("reactGrab.toggle.end") + dlog("reactGrab.toggle.end") #endif } @@ -447,7 +446,7 @@ extension BrowserPanel { return (result as? Bool) ?? false } catch { #if DEBUG - cmuxDebugLog("reactGrab.bridgeSessionRefresh.error error=\(error.localizedDescription)") + dlog("reactGrab.bridgeSessionRefresh.error error=\(error.localizedDescription)") #endif return false } @@ -461,7 +460,7 @@ extension BrowserPanel { let pendingTarget = pendingReactGrabReturnTargetPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - cmuxDebugLog( + dlog( "reactGrab.pasteback h3.reset " + "workspace=\(workspaceId.uuidString.prefix(5)) " + "browser=\(id.uuidString.prefix(5)) " + diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index fe53d192873d..b41b383f1d5b 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -1,10 +1,9 @@ import Foundation import Combine import AppKit -import Bonsplit /// TerminalPanel wraps an existing TerminalSurface and conforms to the Panel protocol. -/// This allows TerminalSurface to be used within the bonsplit-based layout system. +/// This allows TerminalSurface to be used within the WorkspaceSplit-based layout system. @MainActor final class TerminalPanel: Panel, ObservableObject { let id: UUID @@ -31,13 +30,6 @@ final class TerminalPanel: Panel, ObservableObject { } } - /// Bump this token to force SwiftUI to call `updateNSView` on `GhosttyTerminalView`, - /// which re-attaches the hosted view after bonsplit close/reparent operations. - /// - /// Without this, certain pane-close sequences can leave terminal views detached - /// (hostedView.window == nil) until the user switches workspaces. - @Published var viewReattachToken: UInt64 = 0 - var onRequestWorkspacePaneFlash: ((WorkspaceAttentionFlashReason) -> Void)? private var cancellables = Set() @@ -51,7 +43,7 @@ final class TerminalPanel: Panel, ObservableObject { } var isDirty: Bool { - // Bonsplit's "dirty" indicator is a very small dot in the tab strip. + // WorkspaceSplit's "dirty" indicator is a very small dot in the tab strip. // // For terminals, `ghostty_surface_needs_confirm_quit` is driven by shell integration // heuristics and can be transiently (or permanently) wrong, which results in a dot @@ -94,7 +86,6 @@ final class TerminalPanel: Panel, ObservableObject { workingDirectory: String? = nil, portOrdinal: Int = 0, initialCommand: String? = nil, - initialInput: String? = nil, initialEnvironmentOverrides: [String: String] = [:], additionalEnvironment: [String: String] = [:] ) { @@ -104,7 +95,6 @@ final class TerminalPanel: Panel, ObservableObject { configTemplate: configTemplate, workingDirectory: workingDirectory, initialCommand: initialCommand, - initialInput: initialInput, initialEnvironmentOverrides: initialEnvironmentOverrides, additionalEnvironment: additionalEnvironment ) @@ -137,18 +127,10 @@ final class TerminalPanel: Panel, ObservableObject { } func focus() { + surface.setFocus(true) // `unfocus()` force-disables active state to stop stale retries from stealing focus. // Re-enable it immediately for explicit focus requests (socket/UI) so ensureFocus can run. hostedView.setActive(true) - guard AppDelegate.shared?.allowsTerminalKeyboardFocus( - workspaceId: workspaceId, - panelId: id, - in: hostedView.window - ) != false else { - surface.setFocus(false) - return - } - surface.setFocus(true) hostedView.ensureFocus(for: workspaceId, surfaceId: id) } @@ -165,36 +147,13 @@ final class TerminalPanel: Panel, ObservableObject { func close() { // The surface will be cleaned up by its deinit - // Detach from the window portal on real close so stale hosted views - // cannot remain above browser panes after split close. - surface.beginPortalCloseLifecycle(reason: "panel.close") -#if DEBUG - let frame = String(format: "%.1fx%.1f", hostedView.frame.width, hostedView.frame.height) - let bounds = String(format: "%.1fx%.1f", hostedView.bounds.width, hostedView.bounds.height) - cmuxDebugLog( - "surface.panel.close.begin panel=\(id.uuidString.prefix(5)) " + - "workspace=\(workspaceId.uuidString.prefix(5)) runtimeSurface=\(surface.surface != nil ? 1 : 0) " + - "inWindow=\(hostedView.window != nil ? 1 : 0) hasSuperview=\(hostedView.superview != nil ? 1 : 0) " + - "hidden=\(hostedView.isHidden ? 1 : 0) frame=\(frame) bounds=\(bounds)" - ) -#endif unfocus() hostedView.setVisibleInUI(false) + hostedView.removeFromSuperview() TerminalWindowPortalRegistry.detach(hostedView: hostedView) -#if DEBUG - cmuxDebugLog( - "surface.panel.close.end panel=\(id.uuidString.prefix(5)) " + - "inWindow=\(hostedView.window != nil ? 1 : 0) hasSuperview=\(hostedView.superview != nil ? 1 : 0) " + - "hidden=\(hostedView.isHidden ? 1 : 0)" - ) -#endif surface.teardownSurface() } - func requestViewReattach() { - viewReattachToken &+= 1 - } - // MARK: - Terminal-specific methods func sendText(_ text: String) { @@ -227,7 +186,7 @@ final class TerminalPanel: Panel, ObservableObject { guard NotificationPaneFlashSettings.isEnabled() else { return } switch TmuxOverlayExperimentSettings.target() { - case .bonsplitPane: + case .workspacePane: if let onRequestWorkspacePaneFlash { onRequestWorkspacePaneFlash(reason) return diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index 32820d282c08..002ac866a326 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -1,49 +1,7 @@ import SwiftUI -import Foundation import AppKit -import Bonsplit - -/// View for rendering a terminal panel -struct TerminalPanelView: View { - @ObservedObject var panel: TerminalPanel - @AppStorage(NotificationPaneRingSettings.enabledKey) - private var notificationPaneRingEnabled = NotificationPaneRingSettings.defaultEnabled - let paneId: PaneID - let isFocused: Bool - let isVisibleInUI: Bool - let portalPriority: Int - let isSplit: Bool - let appearance: PanelAppearance - let hasUnreadNotification: Bool - let onFocus: () -> Void - let onTriggerFlash: () -> Void - - var body: some View { - // Layering contract: terminal find UI is mounted in GhosttySurfaceScrollView (AppKit portal layer) - // via `searchState`. Rendering `SurfaceSearchOverlay` in this SwiftUI container can hide it. - GhosttyTerminalView( - terminalSurface: panel.surface, - paneId: paneId, - isActive: isFocused, - isVisibleInUI: isVisibleInUI, - portalZPriority: portalPriority, - showsInactiveOverlay: isSplit && !isFocused, - showsUnreadNotificationRing: hasUnreadNotification && notificationPaneRingEnabled, - inactiveOverlayColor: appearance.unfocusedOverlayNSColor, - inactiveOverlayOpacity: appearance.unfocusedOverlayOpacity, - searchState: panel.searchState, - reattachToken: panel.viewReattachToken, - onFocus: { _ in onFocus() }, - onTriggerFlash: onTriggerFlash - ) - // Keep the NSViewRepresentable identity stable across bonsplit structural updates. - // This prevents transient teardown/recreate that can momentarily detach the hosted terminal view. - .id(panel.id) - .background(Color.clear) - } -} -/// Shared appearance settings for panels +/// Shared appearance settings for pane-hosted content. struct PanelAppearance { let dividerColor: Color let unfocusedOverlayNSColor: NSColor @@ -57,3 +15,79 @@ struct PanelAppearance { ) } } + +extension GhosttySurfaceScrollView { +#if DEBUG + func debugRequestSurfaceFirstResponderForTesting(in window: NSWindow, reason: String) -> Bool { + requestSurfaceFirstResponder(in: window, reason: reason) + } +#endif + + func foreignTerminalFocusBlockReason(in window: NSWindow) -> String? { + guard let firstResponder = window.firstResponder else { return nil } + if let view = firstResponder as? NSView, + view === surfaceView || view.isDescendant(of: surfaceView) { + return nil + } + if firstResponder is NSText { + return "textEditorFocused" + } + if AppDelegate.shared?.isRightSidebarFocusResponder(firstResponder, in: window) == true { + return "rightSidebarFocused" + } + return nil + } + + func canRequestSurfaceFirstResponder( + in window: NSWindow, + reason: String, + respectForeignFirstResponder: Bool = false + ) -> Bool { + guard let terminalSurface = surfaceView.terminalSurface else { + return true + } + + let allowed = AppDelegate.shared?.allowsTerminalKeyboardFocus( + workspaceId: terminalSurface.tabId, + panelId: terminalSurface.id, + in: window + ) ?? true + +#if DEBUG + if !allowed { + dlog( + "focus.apply.skip surface=\(terminalSurface.id.uuidString.prefix(5)) " + + "reason=\(reason).coordinatorRightSidebar" + ) + } +#endif + + return allowed + } + + @discardableResult + func requestSurfaceFirstResponder( + in window: NSWindow, + reason: String, + respectForeignFirstResponder: Bool = false + ) -> Bool { + guard canRequestSurfaceFirstResponder( + in: window, + reason: reason, + respectForeignFirstResponder: respectForeignFirstResponder + ) else { + return false + } + if respectForeignFirstResponder, + let blockReason = foreignTerminalFocusBlockReason(in: window) { +#if DEBUG + dlog( + "focus.apply.skip surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "reason=\(reason).\(blockReason)" + ) +#endif + return false + } + return window.makeFirstResponder(surfaceView) + } +} diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index 8be47b9a731b..b24603ab102a 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -1,5 +1,4 @@ import AppKit -import Bonsplit import CMUXWorkstream import Observation import SwiftUI diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 23193c815f7f..7edbfe39240b 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -1,5 +1,4 @@ import AppKit -import Bonsplit import Combine import Foundation import SQLite3 @@ -280,7 +279,7 @@ final class ClaudeMetadataCache: @unchecked Sendable { // MARK: - Drag registry /// Process-wide registry that pairs a synthetic drag UUID with a SessionEntry. -/// Used to forward sessions through bonsplit's external-tab-drop hook (which only +/// Used to forward sessions through WorkspaceSplit's external-tab-drop hook (which only /// carries UUIDs in its payload). Workspace.handleExternalTabDrop consults this /// to decide whether a drop should spawn a brand new terminal vs. move an existing tab. final class SessionDragRegistry { diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index ae9aa705718a..dddffd2c92d6 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -1,5 +1,4 @@ import AppKit -import Bonsplit import SwiftUI import UniformTypeIdentifiers @@ -1362,8 +1361,8 @@ private struct RelativeTimestampSchedule: TimelineSchedule { // MARK: - Drag payload -/// Mirrors `Bonsplit.TabItem`'s Codable shape so we can produce a JSON payload -/// that bonsplit's external-drop path will decode and accept. +/// Mirrors WorkspaceSplit's legacy tab item payload so we can produce a JSON +/// payload that the external-drop path will decode and accept. private struct MirrorTabItem: Codable { let id: UUID let title: String @@ -1377,14 +1376,14 @@ private struct MirrorTabItem: Codable { let isPinned: Bool } -/// Mirrors `Bonsplit.TabTransferData` exactly. +/// Mirrors the legacy WorkspaceSplit tab transfer payload. private struct MirrorTabTransferData: Codable { let tab: MirrorTabItem let sourcePaneId: UUID let sourceProcessId: Int32 } -/// Build the encoded payload bonsplit's external-drop decoder accepts. +/// Build the encoded payload WorkspaceSplit's external-drop decoder accepts. private func sessionTabTransferData(for entry: SessionEntry, dragId: UUID) -> Data? { let mirror = MirrorTabTransferData( tab: MirrorTabItem( @@ -1408,12 +1407,12 @@ private func sessionTabTransferData(for entry: SessionEntry, dragId: UUID) -> Da /// NSItemProvider used by `.onDrag {}`. Registers ONLY /// `com.splittabbar.tabtransfer` so the terminal's NSDraggingDestination /// (which accepts `.string` / `public.utf8-plain-text`) is not hit-tested -/// for our drag. With the terminal out of the way, bonsplit's SwiftUI +/// for our drag. With the terminal out of the way, WorkspaceSplit's /// `.onDrop(of: [.tabTransfer])` overlay can render the blue insert/split /// zones across the entire pane (including its center). /// /// Also mirrors the encoded blob onto NSPasteboard(name: .drag) since -/// bonsplit's external-drop decoder reads from that pasteboard directly +/// WorkspaceSplit's external-drop decoder reads from that pasteboard directly /// and SwiftUI's NSItemProvider bridge doesn't always surface custom /// UTTypes there reliably. private func sessionDragItemProvider(for entry: SessionEntry) -> NSItemProvider { diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index f6d939b419ca..9997303eb43f 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1,6 +1,5 @@ import CoreGraphics import Foundation -import Bonsplit enum SessionSnapshotSchema { static let currentVersion = 1 @@ -224,7 +223,6 @@ struct SessionGitBranchSnapshot: Codable, Sendable { struct SessionTerminalPanelSnapshot: Codable, Sendable { var workingDirectory: String? var scrollback: String? - var agent: SessionRestorableAgentSnapshot? } struct SessionBrowserPanelSnapshot: Codable, Sendable { @@ -402,55 +400,9 @@ enum SessionPersistenceStore { try? FileManager.default.removeItem(at: fileURL) } - static func loadReopenSessionSnapshot( - fileURL: URL? = nil, - bundleIdentifier: String? = Bundle.main.bundleIdentifier, - appSupportDirectory: URL? = nil - ) -> AppSessionSnapshot? { - guard let fileURL = fileURL ?? manualRestoreSnapshotFileURL( - bundleIdentifier: bundleIdentifier, - appSupportDirectory: appSupportDirectory - ) else { - return nil - } - return load(fileURL: fileURL) - } - - static func syncManualRestoreSnapshotCache() { - guard let fileURL = manualRestoreSnapshotFileURL() else { return } - guard let snapshot = load() else { - removeSnapshot(fileURL: fileURL) - return - } - _ = save(snapshot, fileURL: fileURL) - } - static func defaultSnapshotFileURL( bundleIdentifier: String? = Bundle.main.bundleIdentifier, appSupportDirectory: URL? = nil - ) -> URL? { - snapshotFileURL( - suffix: "", - bundleIdentifier: bundleIdentifier, - appSupportDirectory: appSupportDirectory - ) - } - - static func manualRestoreSnapshotFileURL( - bundleIdentifier: String? = Bundle.main.bundleIdentifier, - appSupportDirectory: URL? = nil - ) -> URL? { - snapshotFileURL( - suffix: "-previous", - bundleIdentifier: bundleIdentifier, - appSupportDirectory: appSupportDirectory - ) - } - - private static func snapshotFileURL( - suffix: String, - bundleIdentifier: String?, - appSupportDirectory: URL? ) -> URL? { let resolvedAppSupport: URL if let appSupportDirectory { @@ -470,7 +422,7 @@ enum SessionPersistenceStore { ) return resolvedAppSupport .appendingPathComponent("cmux", isDirectory: true) - .appendingPathComponent("session-\(safeBundleId)\(suffix).json", isDirectory: false) + .appendingPathComponent("session-\(safeBundleId).json", isDirectory: false) } } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 8cfb3f569894..9a52587274c1 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1,10 +1,24 @@ import AppKit import SwiftUI import Foundation -import Bonsplit import CoreVideo import Combine +#if DEBUG +private func debugTabManagerPreview(_ text: String?, limit: Int = 120) -> String { + guard let text else { return "nil" } + let escaped = text + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + .replacingOccurrences(of: "\t", with: "\\t") + if escaped.count <= limit { + return escaped + } + return "\(escaped.prefix(limit))..." +} +#endif + // MARK: - Tab Type Alias for Backwards Compatibility // The old Tab class is replaced by Workspace typealias Tab = Workspace @@ -729,7 +743,7 @@ class TabManager: ObservableObject { let pullRequest: WorkspacePullRequestSnapshot } - struct CommandResult: Sendable { + private struct CommandResult { let stdout: String? let stderr: String? let exitStatus: Int32? @@ -737,12 +751,6 @@ class TabManager: ObservableObject { let executionError: String? } -#if DEBUG - nonisolated(unsafe) static var commandRunnerForTesting: ( - @Sendable (String, String, [String], TimeInterval?) -> CommandResult? - )? -#endif - private struct WorkspaceGitProbeKey: Hashable, Sendable { let workspaceId: UUID let panelId: UUID @@ -760,19 +768,6 @@ class TabManager: ObservableObject { let repoSlugs: [String] } - private struct WorkspacePullRequestCandidateSeed: Sendable { - let workspaceId: UUID - let panelId: UUID - let branch: String - let directory: String? - } - - private struct WorkspacePullRequestCandidateResolution: Sendable { - let candidates: [WorkspacePullRequestCandidate] - let candidateBranchesByRepo: [String: Set] - let repoDirectoriesBySlug: [String: String] - } - private struct WorkspacePullRequestResolvedItem: Sendable { let number: Int let urlString: String @@ -836,7 +831,7 @@ class TabManager: ObservableObject { } private struct WorkspacePullRequestRESTItem: Decodable, Sendable { - struct Ref: Decodable, Sendable { + struct Head: Decodable, Sendable { let ref: String } @@ -845,8 +840,7 @@ class TabManager: ObservableObject { let htmlURL: String let updatedAt: String? let mergedAt: String? - let head: Ref - let base: Ref? + let head: Head enum CodingKeys: String, CodingKey { case number @@ -855,7 +849,6 @@ class TabManager: ObservableObject { case updatedAt = "updated_at" case mergedAt = "merged_at" case head - case base } } @@ -864,26 +857,20 @@ class TabManager: ObservableObject { let state: String let url: String let updatedAt: String? - let mergedAt: String? let headRefName: String? - let baseRefName: String? init( number: Int, state: String, url: String, updatedAt: String?, - mergedAt: String? = nil, - headRefName: String? = nil, - baseRefName: String? = nil + headRefName: String? = nil ) { self.number = number self.state = state self.url = url self.updatedAt = updatedAt - self.mergedAt = mergedAt self.headRefName = headRefName - self.baseRefName = baseRefName } } @@ -902,6 +889,7 @@ class TabManager: ObservableObject { private nonisolated static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0] private nonisolated static let backgroundPollInterval: TimeInterval = 60 private nonisolated static let selectedPollInterval: TimeInterval = 10 + private nonisolated static let workspacePullRequestPollTickInterval: TimeInterval = 1 private nonisolated static let workspacePullRequestRepoCacheLifetime: TimeInterval = 15 private nonisolated static let workspacePullRequestRepoCachePruneLifetime: TimeInterval = 60 private nonisolated static let workspacePullRequestRepoPageSize = 100 @@ -909,7 +897,6 @@ class TabManager: ObservableObject { private nonisolated static let workspacePullRequestTerminalStateSweepInterval: TimeInterval = 15 * 60 private nonisolated static let workspacePullRequestPollJitterFraction = 0.10 private nonisolated static let workspacePullRequestProbeTimeout: TimeInterval = 5.0 - private nonisolated static let mergedPullRequestBadgeStaleAfter: TimeInterval = 14 * 24 * 60 * 60 @Published var selectedTabId: UUID? { willSet { #if DEBUG @@ -956,9 +943,10 @@ class TabManager: ObservableObject { let switchDtMs = debugWorkspaceSwitchStartTime > 0 ? (CACurrentMediaTime() - debugWorkspaceSwitchStartTime) * 1000 : 0 - cmuxDebugLog( + dlog( "ws.select.didSet id=\(switchId) from=\(Self.debugShortWorkspaceId(previousTabId)) " + - "to=\(Self.debugShortWorkspaceId(selectedTabId)) dt=\(Self.debugMsText(switchDtMs))" + "to=\(Self.debugShortWorkspaceId(selectedTabId)) dt=\(Self.debugMsText(switchDtMs)) " + + "\(debugSelectedWorkspacePanelSummary(for: selectedTabId))" ) #endif selectionSideEffectsGeneration &+= 1 @@ -974,9 +962,10 @@ class TabManager: ObservableObject { let dtMs = self.debugWorkspaceSwitchStartTime > 0 ? (CACurrentMediaTime() - self.debugWorkspaceSwitchStartTime) * 1000 : 0 - cmuxDebugLog( + dlog( "ws.select.asyncDone id=\(self.debugWorkspaceSwitchId) dt=\(Self.debugMsText(dtMs)) " + - "selected=\(Self.debugShortWorkspaceId(self.selectedTabId))" + "selected=\(Self.debugShortWorkspaceId(self.selectedTabId)) " + + "\(self.debugSelectedWorkspacePanelSummary(for: self.selectedTabId))" ) #endif } @@ -1018,8 +1007,6 @@ class TabManager: ObservableObject { private var workspaceCycleCooldownTask: Task? private var pendingWorkspaceUnfocusTarget: (tabId: UUID, panelId: UUID)? private var sidebarSelectedWorkspaceIds: Set = [] - private var currentWindowTabBarLeadingInset: CGFloat? - private var closeConfirmationInFlight = false var confirmCloseHandler: ((String, String, Bool) -> Bool)? private struct WorkspaceCreationTabSnapshot { let id: UUID @@ -1071,6 +1058,14 @@ class TabManager: ObservableObject { guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID else { return } guard let surfaceId = notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { return } guard let title = notification.userInfo?[GhosttyNotificationKey.title] as? String else { return } +#if DEBUG + dlog( + "dbg.title.tabManager.event tab=\(tabId.uuidString.prefix(8)) " + + "panel=\(surfaceId.uuidString.prefix(8)) " + + "len=\((title as NSString).length) " + + "title=\"\(title.replacingOccurrences(of: "\n", with: "\\n"))\"" + ) +#endif enqueuePanelTitleUpdate(tabId: tabId, panelId: surfaceId, title: title) } }) @@ -1090,7 +1085,7 @@ class TabManager: ObservableObject { startAgentPIDSweepTimer() startWorkspaceGitMetadataPollTimer() startSelectedWorkspaceGitMetadataPollTimer() - updateWorkspacePullRequestPollTimer() + startWorkspacePullRequestPollTimer() #if DEBUG setupUITestFocusShortcutsIfNeeded() setupSplitCloseRightUITestIfNeeded() @@ -1163,37 +1158,18 @@ class TabManager: ObservableObject { selectedWorkspaceGitMetadataPollTimer = timer } - private func updateWorkspacePullRequestPollTimer() { - guard workspacePullRequestRefreshTask == nil else { - workspacePullRequestPollTimer?.cancel() - workspacePullRequestPollTimer = nil - return - } - - guard let nextPollAt = workspacePullRequestNextPollAtByKey.values.min() else { - workspacePullRequestPollTimer?.cancel() - workspacePullRequestPollTimer = nil - return - } - - if workspacePullRequestPollTimer == nil { - let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility)) - timer.setEventHandler { [weak self] in - guard let self else { return } - DispatchQueue.main.async { [weak self] in - self?.refreshTrackedWorkspacePullRequestsIfNeeded(reason: "timer") - } + private func startWorkspacePullRequestPollTimer() { + let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility)) + let interval = Self.workspacePullRequestPollTickInterval + timer.schedule(deadline: .now() + interval, repeating: interval) + timer.setEventHandler { [weak self] in + guard let self else { return } + DispatchQueue.main.async { [weak self] in + self?.refreshTrackedWorkspacePullRequestsIfNeeded(reason: "timer") } - timer.resume() - workspacePullRequestPollTimer = timer } - - let delay = max(0.25, nextPollAt.timeIntervalSinceNow) - workspacePullRequestPollTimer?.schedule( - deadline: .now() + delay, - repeating: .never, - leeway: .milliseconds(250) - ) + timer.resume() + workspacePullRequestPollTimer = timer } private func refreshTrackedWorkspaceGitMetadata() { @@ -1239,17 +1215,23 @@ class TabManager: ObservableObject { allowCachedResultsOverride: Bool? = nil ) { let now = Date() - var candidateSeeds: [WorkspacePullRequestCandidateSeed] = [] + var candidates: [WorkspacePullRequestCandidate] = [] + var candidateBranchesByRepo: [String: Set] = [:] + var repoDirectoriesBySlug: [String: String] = [:] var requestedKeys: [WorkspaceGitProbeKey] = [] var validKeys: Set = [] for workspace in tabs { - for panelId in Set(workspace.panelGitBranches.keys).union(workspace.panelPullRequests.keys) { + let surfaceStates = workspace.surfaceStatesSnapshot() + let trackedPanelIds = Set(surfaceStates.compactMap { panelId, state in + (state.gitBranch != nil || state.pullRequest != nil) ? panelId : nil + }) + for panelId in trackedPanelIds { let key = WorkspaceGitProbeKey(workspaceId: workspace.id, panelId: panelId) validKeys.insert(key) let branch = Self.normalizedBranchName( - workspace.panelGitBranches[panelId]?.branch - ?? workspace.panelPullRequests[panelId]?.branch + surfaceStates[panelId]?.gitBranch?.branch + ?? surfaceStates[panelId]?.pullRequest?.branch ) guard let branch else { clearWorkspacePullRequestTracking(for: key) @@ -1265,7 +1247,7 @@ class TabManager: ObservableObject { guard shouldRefreshWorkspacePullRequest( key: key, now: now, - currentPullRequest: workspace.panelPullRequests[panelId] + currentPullRequest: surfaceStates[panelId]?.pullRequest ) else { continue } @@ -1278,27 +1260,26 @@ class TabManager: ObservableObject { continue } - let candidateSeed = workspacePullRequestCandidateSeed( + let candidate = workspacePullRequestCandidate( workspace: workspace, panelId: panelId, branch: branch ) - candidateSeeds.append(candidateSeed) + candidates.append(candidate) requestedKeys.append(key) + for repoSlug in candidate.repoSlugs { + candidateBranchesByRepo[repoSlug, default: []].insert(candidate.branch) + } + if let directory = gitProbeDirectory(for: workspace, panelId: panelId) { + for repoSlug in candidate.repoSlugs where repoDirectoriesBySlug[repoSlug] == nil { + repoDirectoriesBySlug[repoSlug] = directory + } + } } } pruneWorkspacePullRequestTracking(validKeys: validKeys) - guard workspacePullRequestRefreshTask == nil else { - updateWorkspacePullRequestPollTimer() - return - } - guard !candidateSeeds.isEmpty else { - updateWorkspacePullRequestPollTimer() - return - } - workspacePullRequestPollTimer?.cancel() - workspacePullRequestPollTimer = nil + guard !candidates.isEmpty, workspacePullRequestRefreshTask == nil else { return } for key in requestedKeys { workspacePullRequestProbeStateByKey[key] = .inFlight(rerunPending: false) } @@ -1306,18 +1287,16 @@ class TabManager: ObservableObject { let cacheBySlug = workspacePullRequestRepoCacheBySlug let allowCachedResults = allowCachedResultsOverride ?? Self.workspacePullRequestRefreshAllowsRepoCache(reason: reason) - workspacePullRequestRefreshTask = Task.detached(priority: .utility) { [weak self] in - let candidateResolution = await Self.resolveWorkspacePullRequestCandidateSeeds(candidateSeeds) - guard !Task.isCancelled else { return } + workspacePullRequestRefreshTask = Task { [weak self] in let repoResults = await Self.fetchWorkspacePullRequestRepoResults( - repoDirectoriesBySlug: candidateResolution.repoDirectoriesBySlug, - candidateBranchesByRepo: candidateResolution.candidateBranchesByRepo, + repoDirectoriesBySlug: repoDirectoriesBySlug, + candidateBranchesByRepo: candidateBranchesByRepo, cacheBySlug: cacheBySlug, now: now, allowCachedResults: allowCachedResults ) let results = Self.resolveWorkspacePullRequestRefreshResults( - candidates: candidateResolution.candidates, + candidates: candidates, repoResults: repoResults ) guard !Task.isCancelled else { return } @@ -1349,65 +1328,18 @@ class TabManager: ObservableObject { ) } - private func workspacePullRequestCandidateSeed( + private func workspacePullRequestCandidate( workspace: Workspace, panelId: UUID, branch: String - ) -> WorkspacePullRequestCandidateSeed { + ) -> WorkspacePullRequestCandidate { let directory = gitProbeDirectory(for: workspace, panelId: panelId) - return WorkspacePullRequestCandidateSeed( + let repoSlugs = directory.map(Self.githubRepositorySlugs(directory:)) ?? [] + return WorkspacePullRequestCandidate( workspaceId: workspace.id, panelId: panelId, branch: branch, - directory: directory - ) - } - - private nonisolated static func resolveWorkspacePullRequestCandidateSeeds( - _ seeds: [WorkspacePullRequestCandidateSeed] - ) async -> WorkspacePullRequestCandidateResolution { - var candidates: [WorkspacePullRequestCandidate] = [] - candidates.reserveCapacity(seeds.count) - var candidateBranchesByRepo: [String: Set] = [:] - var repoDirectoriesBySlug: [String: String] = [:] - var repoSlugsByDirectory: [String: [String]] = [:] - - for seed in seeds { - let repoSlugs: [String] - if let directory = seed.directory { - if let cachedRepoSlugs = repoSlugsByDirectory[directory] { - repoSlugs = cachedRepoSlugs - } else { - let resolvedRepoSlugs = await githubRepositorySlugs(directory: directory) - repoSlugsByDirectory[directory] = resolvedRepoSlugs - repoSlugs = resolvedRepoSlugs - } - } else { - repoSlugs = [] - } - - candidates.append( - WorkspacePullRequestCandidate( - workspaceId: seed.workspaceId, - panelId: seed.panelId, - branch: seed.branch, - repoSlugs: repoSlugs - ) - ) - for repoSlug in repoSlugs { - candidateBranchesByRepo[repoSlug, default: []].insert(seed.branch) - } - if let directory = seed.directory { - for repoSlug in repoSlugs where repoDirectoriesBySlug[repoSlug] == nil { - repoDirectoriesBySlug[repoSlug] = directory - } - } - } - - return WorkspacePullRequestCandidateResolution( - candidates: candidates, - candidateBranchesByRepo: candidateBranchesByRepo, - repoDirectoriesBySlug: repoDirectoriesBySlug + repoSlugs: repoSlugs ) } @@ -1430,7 +1362,7 @@ class TabManager: ObservableObject { workspacePullRequestNextPollAtByKey[key] = .distantPast } #if DEBUG - cmuxDebugLog( + dlog( "workspace.prRefresh.schedule workspace=\(workspaceId.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) reason=\(reason)" ) @@ -1497,7 +1429,7 @@ class TabManager: ObservableObject { continue } - let priorPullRequest = workspace.panelPullRequests[result.panelId] + let priorPullRequest = workspace.surfaceStateSnapshot(panelId: result.panelId).pullRequest let countsAsTerminalSweep = priorPullRequest.map { $0.status != .open } ?? false switch result.resolution { @@ -1519,20 +1451,20 @@ class TabManager: ObservableObject { case .notFound: workspacePullRequestTransientFailureCountByKey[key] = 0 workspacePullRequestLastTerminalStateRefreshAtByKey.removeValue(forKey: key) - if workspace.panelPullRequests[result.panelId] != nil { + if workspace.surfaceStateSnapshot(panelId: result.panelId).pullRequest != nil { workspace.clearPanelPullRequest(panelId: result.panelId) } case .unsupportedRepository: workspacePullRequestTransientFailureCountByKey[key] = 0 workspacePullRequestLastTerminalStateRefreshAtByKey.removeValue(forKey: key) - if workspace.panelPullRequests[result.panelId] != nil { + if workspace.surfaceStateSnapshot(panelId: result.panelId).pullRequest != nil { workspace.clearPanelPullRequest(panelId: result.panelId) } case .transientFailure: let nextFailureCount = (workspacePullRequestTransientFailureCountByKey[key] ?? 0) + 1 workspacePullRequestTransientFailureCountByKey[key] = nextFailureCount if nextFailureCount >= 3, - let currentPullRequest = workspace.panelPullRequests[result.panelId] { + let currentPullRequest = workspace.surfaceStateSnapshot(panelId: result.panelId).pullRequest { workspace.updatePanelPullRequest( panelId: result.panelId, number: currentPullRequest.number, @@ -1570,14 +1502,12 @@ class TabManager: ObservableObject { return "#\(resolvedPullRequest.number):\(resolvedPullRequest.statusRawValue)" } }() - cmuxDebugLog( + dlog( "workspace.prRefresh.apply workspace=\(result.workspaceId.uuidString.prefix(5)) " + "panel=\(result.panelId.uuidString.prefix(5)) result=\(label) reason=\(reason)" ) #endif } - - updateWorkspacePullRequestPollTimer() } private func scheduleNextWorkspacePullRequestPoll( @@ -1628,7 +1558,6 @@ class TabManager: ObservableObject { workspacePullRequestRepoCacheBySlug = workspacePullRequestRepoCacheBySlug.filter { $0.value.fetchedAt >= repoCacheCutoff } - updateWorkspacePullRequestPollTimer() } private func clearWorkspacePullRequestTracking(for key: WorkspaceGitProbeKey) { @@ -1636,7 +1565,6 @@ class TabManager: ObservableObject { workspacePullRequestProbeStateByKey.removeValue(forKey: key) workspacePullRequestLastTerminalStateRefreshAtByKey.removeValue(forKey: key) workspacePullRequestTransientFailureCountByKey.removeValue(forKey: key) - updateWorkspacePullRequestPollTimer() } private func clearWorkspacePullRequestTracking(workspaceId: UUID) { @@ -1644,7 +1572,6 @@ class TabManager: ObservableObject { workspacePullRequestProbeStateByKey = workspacePullRequestProbeStateByKey.filter { $0.key.workspaceId != workspaceId } workspacePullRequestLastTerminalStateRefreshAtByKey = workspacePullRequestLastTerminalStateRefreshAtByKey.filter { $0.key.workspaceId != workspaceId } workspacePullRequestTransientFailureCountByKey = workspacePullRequestTransientFailureCountByKey.filter { $0.key.workspaceId != workspaceId } - updateWorkspacePullRequestPollTimer() } private func resetWorkspacePullRequestRefreshState() { @@ -1656,7 +1583,6 @@ class TabManager: ObservableObject { workspacePullRequestTransientFailureCountByKey.removeAll() workspacePullRequestRepoCacheBySlug.removeAll() workspacePullRequestFollowUpShouldBypassRepoCache = false - updateWorkspacePullRequestPollTimer() } private var activeWorkspaceGitProbeKeys: Set { @@ -1746,7 +1672,22 @@ class TabManager: ObservableObject { } func refreshTrackedWorkspaceGitMetadataForTesting() { - refreshTrackedWorkspaceGitMetadata() + for workspace in tabs { + for panelId in trackedWorkspaceGitMetadataPollCandidatePanelIds( + in: workspace, + activeProbeKeys: [] + ) { + let key = WorkspaceGitProbeKey(workspaceId: workspace.id, panelId: panelId) + clearWorkspaceGitProbe(key) + scheduleWorkspaceGitMetadataRefresh( + workspaceId: workspace.id, + panelId: panelId, + directory: gitProbeDirectory(for: workspace, panelId: panelId) ?? workspace.currentDirectory, + delays: [0], + reason: "testing" + ) + } + } } func trackedWorkspaceGitMetadataPollCandidatePanelIdsForTesting(workspaceId: UUID) -> Set { @@ -1770,8 +1711,10 @@ class TabManager: ObservableObject { in workspace: Workspace, activeProbeKeys: Set ) -> Set { - var candidatePanelIds = Set(workspace.panelGitBranches.keys) - candidatePanelIds.formUnion(workspace.panelPullRequests.keys) + let surfaceStates = workspace.surfaceStatesSnapshot() + var candidatePanelIds = Set(surfaceStates.compactMap { panelId, state in + (state.gitBranch != nil || state.pullRequest != nil) ? panelId : nil + }) // Only keep background polling panels whose current directory has already // proven to yield sidebar git metadata. Initial multi-attempt probes handle // startup races; this avoids polling non-repo directories forever. @@ -1834,7 +1777,7 @@ class TabManager: ObservableObject { private func gitProbeDirectory(for workspace: Workspace, panelId: UUID) -> String? { // Match the sidebar directory fallback chain so hidden/background panels can // still probe git metadata before OSC 7 has reported a live cwd. - let rawDirectory = workspace.panelDirectories[panelId] + let rawDirectory = workspace.panelDirectory(panelId: panelId) ?? workspace.terminalPanel(for: panelId)?.requestedWorkingDirectory ?? (workspace.focusedPanelId == panelId ? workspace.currentDirectory : nil) return rawDirectory.flatMap(normalizedWorkingDirectory) @@ -1918,30 +1861,15 @@ class TabManager: ObservableObject { func startSearch() { if let panel = selectedTerminalPanel { - if panel.searchState == nil { - panel.searchState = TerminalSurface.SearchState() - } + _ = startOrFocusTerminalSearch(panel.surface) NSLog("Find: startSearch workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString) - NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface) - _ = panel.performBindingAction("start_search") return } - if let panel = selectedTerminalPanel { - let hadExistingSearch = panel.searchState != nil - let handled = startOrFocusTerminalSearch(panel.surface) - NSLog("Find: startSearch workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString) -#if DEBUG - cmuxDebugLog( - "find.startSearch workspace=\(panel.workspaceId.uuidString.prefix(5)) " + - "panel=\(panel.id.uuidString.prefix(5)) existing=\(hadExistingSearch ? "yes" : "no") " + - "handled=\(handled ? 1 : 0) " + - "firstResponder=\(String(describing: panel.surface.hostedView.window?.firstResponder))" - ) -#endif + + if let panel = focusedBrowserPanel { + panel.startFind() return } - - focusedBrowserPanel?.startFind() } func searchSelection() { @@ -1960,7 +1888,10 @@ class TabManager: ObservableObject { return } - focusedBrowserPanel?.findNext() + if let panel = focusedBrowserPanel { + panel.findNext() + return + } } func findPrevious() { @@ -1969,7 +1900,10 @@ class TabManager: ObservableObject { return } - focusedBrowserPanel?.findPrevious() + if let panel = focusedBrowserPanel { + panel.findPrevious() + return + } } @discardableResult @@ -1984,7 +1918,10 @@ class TabManager: ObservableObject { return } - focusedBrowserPanel?.hideFind() + if let panel = focusedBrowserPanel { + panel.hideFind() + return + } } func makeWorkspaceForCreation( @@ -1993,7 +1930,6 @@ class TabManager: ObservableObject { portOrdinal: Int, configTemplate: CmuxSurfaceConfigTemplate?, initialTerminalCommand: String?, - initialTerminalInput: String? = nil, initialTerminalEnvironment: [String: String] ) -> Workspace { Workspace( @@ -2002,7 +1938,6 @@ class TabManager: ObservableObject { portOrdinal: portOrdinal, configTemplate: configTemplate, initialTerminalCommand: initialTerminalCommand, - initialTerminalInput: initialTerminalInput, initialTerminalEnvironment: initialTerminalEnvironment ) } @@ -2011,27 +1946,11 @@ class TabManager: ObservableObject { to newWorkspace: Workspace, from sourceWorkspace: Workspace? ) { - // Sidebar-toggle relayout updates the live Bonsplit leading inset so minimal-mode + guard let sourceWorkspace else { return } + // Sidebar-toggle relayout updates the live split-host leading inset so minimal-mode // workspaces reserve traffic-light space. New workspaces need that same inset // copied immediately because creation itself does not trigger the resync path. - let inheritedLeadingInset = currentWindowTabBarLeadingInset - ?? sourceWorkspace?.bonsplitController.configuration.appearance.tabBarLeadingInset - guard let inheritedLeadingInset else { return } - applyTabBarLeadingInset(inheritedLeadingInset, to: newWorkspace) - } - - func syncWorkspaceTabBarLeadingInset(_ inset: CGFloat) { - let normalizedInset = max(0, inset) - currentWindowTabBarLeadingInset = normalizedInset - for tab in tabs { - applyTabBarLeadingInset(normalizedInset, to: tab) - } - } - - private func applyTabBarLeadingInset(_ inset: CGFloat, to workspace: Workspace) { - if workspace.bonsplitController.configuration.appearance.tabBarLeadingInset != inset { - workspace.bonsplitController.configuration.appearance.tabBarLeadingInset = inset - } + newWorkspace.tabBarLeadingInset = sourceWorkspace.tabBarLeadingInset } /// Test seam for mutating live workspace state after the creation snapshot is captured. @@ -2054,7 +1973,7 @@ class TabManager: ObservableObject { tabs.contains(where: { $0.id == targetId }) else { return } - cmuxDebugLog( + dlog( "workspace.create.devSelectionMutation from=\(selectedTabId.uuidString.prefix(5)) " + "to=\(targetId.uuidString.prefix(5))" ) @@ -2067,7 +1986,6 @@ class TabManager: ObservableObject { title: String? = nil, workingDirectory overrideWorkingDirectory: String? = nil, initialTerminalCommand: String? = nil, - initialTerminalInput: String? = nil, initialTerminalEnvironment: [String: String] = [:], select: Bool = true, eagerLoadTerminal: Bool = false, @@ -2115,7 +2033,6 @@ class TabManager: ObservableObject { portOrdinal: ordinal, configTemplate: inheritedConfig, initialTerminalCommand: initialTerminalCommand, - initialTerminalInput: initialTerminalInput, initialTerminalEnvironment: initialTerminalEnvironment ) applyCreationChromeInheritance( @@ -2191,18 +2108,54 @@ class TabManager: ObservableObject { } var resolved = false - var readyObserver: NSObjectProtocol? var panelsCancellable: AnyCancellable? + var readyToken: UUID? + var readyPanelId: UUID? + var focusObserver: NSObjectProtocol? + + func cancelReadyCallback() { + if let readyPanelId, + let terminalPanel = workspace.terminalPanel(for: readyPanelId) { + terminalPanel.surface.cancelRuntimeReadyCallback(readyToken) + } + readyToken = nil + readyPanelId = nil + } + + func cleanupObservers() { + cancelReadyCallback() + panelsCancellable?.cancel() + if let focusObserver { + NotificationCenter.default.removeObserver(focusObserver) + } + focusObserver = nil + } + + func armFocusedTerminalReadiness() { + guard !resolved else { return } + guard let terminalPanel = workspace.focusedTerminalPanel else { + cancelReadyCallback() + return + } + guard readyPanelId != terminalPanel.id else { return } + + cancelReadyCallback() + readyPanelId = terminalPanel.id + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + readyToken = terminalPanel.surface.onRuntimeReady { + finishIfReady() + } + } func finishIfReady() { guard !resolved, let terminalPanel = workspace.focusedTerminalPanel, - terminalPanel.surface.surface != nil else { return } - resolved = true - if let readyObserver { - NotificationCenter.default.removeObserver(readyObserver) + terminalPanel.surface.surface != nil else { + armFocusedTerminalReadiness() + return } - panelsCancellable?.cancel() + resolved = true + cleanupObservers() DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey) terminalPanel.sendText("cmux welcome\n") @@ -2213,27 +2166,27 @@ class TabManager: ObservableObject { .map { _ in () } .sink { _ in Task { @MainActor in + armFocusedTerminalReadiness() finishIfReady() } } - readyObserver = NotificationCenter.default.addObserver( - forName: .terminalSurfaceDidBecomeReady, + focusObserver = NotificationCenter.default.addObserver( + forName: .ghosttyDidFocusSurface, object: nil, queue: .main ) { note in - guard let workspaceId = note.userInfo?["workspaceId"] as? UUID, - workspaceId == workspace.id else { return } + guard let tabId = note.userInfo?[GhosttyNotificationKey.tabId] as? UUID, + tabId == workspace.id else { return } Task { @MainActor in + armFocusedTerminalReadiness() finishIfReady() } } + armFocusedTerminalReadiness() DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { Task { @MainActor in - if let readyObserver, !resolved { - NotificationCenter.default.removeObserver(readyObserver) - } if !resolved { - panelsCancellable?.cancel() + cleanupObservers() } } } @@ -2268,7 +2221,7 @@ class TabManager: ObservableObject { } #if DEBUG - cmuxDebugLog( + dlog( "workspace.gitProbe.schedule workspace=\(workspaceId.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) dir=\(normalizedDirectory) reason=\(reason)" ) @@ -2307,10 +2260,9 @@ class TabManager: ObservableObject { return } - Task.detached(priority: .utility) { [weak self] in - let snapshot = await Self.initialWorkspaceGitMetadataSnapshot(for: expectedDirectory) - guard !Task.isCancelled else { return } - await MainActor.run { [weak self] in + initialWorkspaceGitProbeQueue.async { [weak self] in + let snapshot = Self.initialWorkspaceGitMetadataSnapshot(for: expectedDirectory) + Task { @MainActor [weak self] in self?.applyWorkspaceGitMetadataSnapshot( snapshot, probeKey: probeKey, @@ -2402,7 +2354,7 @@ class TabManager: ObservableObject { clearWorkspaceGitProbe(probeKey) didClearProbe = true #if DEBUG - cmuxDebugLog( + dlog( "workspace.gitProbe.skip workspace=\(probeKey.workspaceId.uuidString.prefix(5)) " + "panel=\(probeKey.panelId.uuidString.prefix(5)) reason=directoryChanged " + "expected=\(expectedDirectory) current=\(currentDirectory)" @@ -2447,7 +2399,7 @@ class TabManager: ObservableObject { isStale: false ) case .notFound: - if workspace.panelPullRequests[probeKey.panelId] != nil { + if workspace.surfaceStateSnapshot(panelId: probeKey.panelId).pullRequest != nil { workspace.clearPanelPullRequest(panelId: probeKey.panelId) } case .deferred, .unsupportedRepository, .transientFailure: @@ -2478,7 +2430,7 @@ class TabManager: ObservableObject { return "#\(pullRequest.number):\(pullRequest.status.rawValue)" } }() - cmuxDebugLog( + dlog( "workspace.gitProbe.apply workspace=\(probeKey.workspaceId.uuidString.prefix(5)) " + "panel=\(probeKey.panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0) " + "pr=\(prLabel)" @@ -2499,9 +2451,8 @@ class TabManager: ObservableObject { private nonisolated static func initialWorkspaceGitMetadataSnapshot( for directory: String - ) async -> InitialWorkspaceGitMetadataSnapshot { - let branchOutput = await runGitCommand(directory: directory, arguments: ["branch", "--show-current"]) - let branch = normalizedBranchName(branchOutput) + ) -> InitialWorkspaceGitMetadataSnapshot { + let branch = normalizedBranchName(runGitCommand(directory: directory, arguments: ["branch", "--show-current"])) guard let branch else { return InitialWorkspaceGitMetadataSnapshot( branch: nil, @@ -2510,7 +2461,7 @@ class TabManager: ObservableObject { ) } - let statusOutput = await runGitCommand(directory: directory, arguments: ["status", "--porcelain", "-uno"]) + let statusOutput = runGitCommand(directory: directory, arguments: ["status", "--porcelain", "-uno"]) let isDirty = !(statusOutput?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) return InitialWorkspaceGitMetadataSnapshot( branch: branch, @@ -2519,8 +2470,8 @@ class TabManager: ObservableObject { ) } - private nonisolated static func runGitCommand(directory: String, arguments: [String]) async -> String? { - await runCommand( + private nonisolated static func runGitCommand(directory: String, arguments: [String]) -> String? { + runCommand( directory: directory, executable: "git", arguments: arguments @@ -2540,7 +2491,7 @@ class TabManager: ObservableObject { configuration.timeoutIntervalForRequest = max(Self.workspacePullRequestProbeTimeout, 8) configuration.timeoutIntervalForResource = max(Self.workspacePullRequestProbeTimeout, 8) let session = URLSession(configuration: configuration) - let authHeader = await workspacePullRequestAuthHeaderValue() + let authHeader = workspacePullRequestAuthHeaderValue() var results: [String: WorkspacePullRequestRepoFetchResult] = [:] let fetchedResults = await withTaskGroup( @@ -2664,7 +2615,7 @@ class TabManager: ObservableObject { ) if unresolvedBranches.isEmpty { #if DEBUG - cmuxDebugLog( + dlog( "workspace.prRefresh.repo.cache repo=\(repoSlug) " + "branches=\(cachedEntry.pullRequestsByBranch.count)" ) @@ -2681,7 +2632,7 @@ class TabManager: ObservableObject { authHeader: authHeader ) #if DEBUG - cmuxDebugLog( + dlog( "workspace.prRefresh.repo.cache.miss repo=\(repoSlug) " + "branchLookups=\(unresolvedBranches.count) transient=\(lookupOutcome.transientBranches.count)" ) @@ -2706,7 +2657,7 @@ class TabManager: ObservableObject { authHeader: authHeader ) else { #if DEBUG - cmuxDebugLog("workspace.prRefresh.repo.fail repo=\(repoSlug) page=\(page) status=nil") + dlog("workspace.prRefresh.repo.fail repo=\(repoSlug) page=\(page) status=nil") #endif return .transientFailure } @@ -2714,7 +2665,7 @@ class TabManager: ObservableObject { guard response.statusCode == 200, let pullRequests = decodeJSON([WorkspacePullRequestRESTItem].self, from: response.data) else { #if DEBUG - cmuxDebugLog("workspace.prRefresh.repo.fail repo=\(repoSlug) page=\(page) status=\(response.statusCode)") + dlog("workspace.prRefresh.repo.fail repo=\(repoSlug) page=\(page) status=\(response.statusCode)") #endif return .transientFailure } @@ -2752,7 +2703,7 @@ class TabManager: ObservableObject { ) } #if DEBUG - cmuxDebugLog( + dlog( "workspace.prRefresh.repo.success repo=\(repoSlug) pages=\(fetchedPageCount) " + "branches=\(lookupOutcome.cacheEntry.pullRequestsByBranch.count) " + "branchLookups=\(unresolvedBranches.count) transient=\(lookupOutcome.transientBranches.count)" @@ -2860,7 +2811,7 @@ class TabManager: ObservableObject { authHeader: authHeader ) else { #if DEBUG - cmuxDebugLog("workspace.prRefresh.branch.fail repo=\(repoSlug) branch=\(branch) status=nil") + dlog("workspace.prRefresh.branch.fail repo=\(repoSlug) branch=\(branch) status=nil") #endif return .transientFailure } @@ -2868,7 +2819,7 @@ class TabManager: ObservableObject { guard response.statusCode == 200, let pullRequests = decodeJSON([WorkspacePullRequestRESTItem].self, from: response.data) else { #if DEBUG - cmuxDebugLog( + dlog( "workspace.prRefresh.branch.fail repo=\(repoSlug) " + "branch=\(branch) status=\(response.statusCode)" ) @@ -2919,9 +2870,7 @@ class TabManager: ObservableObject { state: rawState, url: pullRequest.htmlURL, updatedAt: pullRequest.updatedAt, - mergedAt: pullRequest.mergedAt, - headRefName: pullRequest.head.ref, - baseRefName: pullRequest.base?.ref + headRefName: pullRequest.head.ref ) } @@ -2956,7 +2905,7 @@ class TabManager: ObservableObject { } } - private nonisolated static func workspacePullRequestAuthHeaderValue() async -> String? { + private nonisolated static func workspacePullRequestAuthHeaderValue() -> String? { let environment = ProcessInfo.processInfo.environment if let envToken = environment["GH_TOKEN"] ?? environment["GITHUB_TOKEN"] { let trimmed = envToken.trimmingCharacters(in: .whitespacesAndNewlines) @@ -2966,7 +2915,7 @@ class TabManager: ObservableObject { } let directory = FileManager.default.currentDirectoryPath - let token = await runCommand( + let token = runCommand( directory: directory, executable: "gh", arguments: ["auth", "token"], @@ -2976,30 +2925,20 @@ class TabManager: ObservableObject { return "Bearer \(token)" } - nonisolated static func pullRequestMapByNormalizedBranchForTesting( - from pullRequests: [GitHubPullRequestProbeItem], - now: Date - ) -> [String: GitHubPullRequestProbeItem] { - pullRequestMapByNormalizedBranch(from: pullRequests, now: now) - } - private nonisolated static func pullRequestMapByNormalizedBranch( - from pullRequests: [GitHubPullRequestProbeItem], - now: Date = Date() + from pullRequests: [GitHubPullRequestProbeItem] ) -> [String: GitHubPullRequestProbeItem] { var pullRequestsByBranch: [String: GitHubPullRequestProbeItem] = [:] for pullRequest in pullRequests { guard let branch = normalizedBranchName(pullRequest.headRefName), - isSidebarPullRequestCandidate(pullRequest, now: now) else { + pullRequestStatus(from: pullRequest.state) != nil, + URL(string: pullRequest.url) != nil else { continue } if let currentBest = pullRequestsByBranch[branch] { - pullRequestsByBranch[branch] = preferredPullRequest( - from: [currentBest, pullRequest], - now: now - ) ?? currentBest + pullRequestsByBranch[branch] = preferredPullRequest(from: [currentBest, pullRequest]) ?? currentBest } else { pullRequestsByBranch[branch] = pullRequest } @@ -3009,8 +2948,7 @@ class TabManager: ObservableObject { } nonisolated static func preferredPullRequest( - from pullRequests: [GitHubPullRequestProbeItem], - now: Date = Date() + from pullRequests: [GitHubPullRequestProbeItem] ) -> GitHubPullRequestProbeItem? { func statusPriority(_ status: SidebarPullRequestStatus) -> Int { switch status { @@ -3049,7 +2987,8 @@ class TabManager: ObservableObject { var best: GitHubPullRequestProbeItem? for pullRequest in pullRequests { - guard isSidebarPullRequestCandidate(pullRequest, now: now) else { + guard pullRequestStatus(from: pullRequest.state) != nil, + isValidPullRequestURL(pullRequest.url) else { continue } guard let currentBest = best else { @@ -3063,39 +3002,15 @@ class TabManager: ObservableObject { return best } - private nonisolated static func isSidebarPullRequestCandidate( - _ pullRequest: GitHubPullRequestProbeItem, - now: Date - ) -> Bool { - guard pullRequestStatus(from: pullRequest.state) != nil, - URL(string: pullRequest.url) != nil else { - return false - } - return !isStaleMergedPullRequest(pullRequest, now: now) - } - - private nonisolated static func isStaleMergedPullRequest( - _ pullRequest: GitHubPullRequestProbeItem, - now: Date - ) -> Bool { - guard pullRequestStatus(from: pullRequest.state) == .merged, - let mergedAt = githubTimestampDate(from: pullRequest.mergedAt) else { + private nonisolated static func isValidPullRequestURL(_ rawURL: String) -> Bool { + guard let components = URLComponents(string: rawURL), + let scheme = components.scheme?.lowercased(), + scheme == "http" || scheme == "https", + let host = components.host, + !host.isEmpty else { return false } - return now.timeIntervalSince(mergedAt) > mergedPullRequestBadgeStaleAfter - } - - private nonisolated static func githubTimestampDate(from rawTimestamp: String?) -> Date? { - let timestamp = rawTimestamp?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !timestamp.isEmpty else { return nil } - - let formatter = ISO8601DateFormatter() - if let date = formatter.date(from: timestamp) { - return date - } - - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter.date(from: timestamp) + return true } private nonisolated static func pullRequestStatus( @@ -3185,122 +3100,13 @@ class TabManager: ObservableObject { return nil } - private final class CommandRunState: @unchecked Sendable { - fileprivate typealias Continuation = CheckedContinuation - - private let lock = NSLock() - private var continuation: Continuation? - private var stdoutData: Data? - private var stderrData: Data? - private var exitStatus: Int32? - private var didTerminate = false - private var didResume = false - private var timeoutWorkItem: DispatchWorkItem? - - fileprivate init(continuation: Continuation) { - self.continuation = continuation - } - - func setTimeoutWorkItem(_ item: DispatchWorkItem?) { - guard let item else { return } - lock.lock() - if didResume { - lock.unlock() - item.cancel() - return - } - timeoutWorkItem = item - lock.unlock() - } - - func hasResumed() -> Bool { - lock.lock() - defer { lock.unlock() } - return didResume - } - - func completeStdout(_ data: Data) { - complete { - stdoutData = data - } - } - - func completeStderr(_ data: Data) { - complete { - stderrData = data - } - } - - func completeTermination(exitStatus: Int32) { - complete { - self.exitStatus = exitStatus - didTerminate = true - } - } - - func resume(returning result: CommandResult?) { - var continuationToResume: Continuation? - var timeoutToCancel: DispatchWorkItem? - lock.lock() - guard !didResume else { - lock.unlock() - return - } - didResume = true - continuationToResume = continuation - continuation = nil - timeoutToCancel = timeoutWorkItem - timeoutWorkItem = nil - lock.unlock() - - timeoutToCancel?.cancel() - continuationToResume?.resume(returning: result) - } - - private func complete(_ mutate: () -> Void) { - var continuationToResume: Continuation? - var timeoutToCancel: DispatchWorkItem? - var resultToResume: CommandResult? - - lock.lock() - guard !didResume else { - lock.unlock() - return - } - - mutate() - if let stdoutData, - let stderrData, - didTerminate { - didResume = true - resultToResume = CommandResult( - stdout: String(data: stdoutData, encoding: .utf8), - stderr: String(data: stderrData, encoding: .utf8), - exitStatus: exitStatus, - timedOut: false, - executionError: nil - ) - continuationToResume = continuation - continuation = nil - timeoutToCancel = timeoutWorkItem - timeoutWorkItem = nil - } - lock.unlock() - - timeoutToCancel?.cancel() - if let resultToResume { - continuationToResume?.resume(returning: resultToResume) - } - } - } - private nonisolated static func runCommand( directory: String, executable: String, arguments: [String], timeout: TimeInterval? = nil - ) async -> String? { - let result = await runCommandResult( + ) -> String? { + let result = runCommandResult( directory: directory, executable: executable, arguments: arguments, @@ -3319,92 +3125,65 @@ class TabManager: ObservableObject { executable: String, arguments: [String], timeout: TimeInterval? = nil - ) async -> CommandResult? { -#if DEBUG - assert(!Thread.isMainThread, "TabManager.runCommandResult must not run on the main thread") - if let commandRunnerForTesting { - return commandRunnerForTesting(directory, executable, arguments, timeout) + ) -> CommandResult? { + let process = Process() + let stdout = Pipe() + let stderr = Pipe() + if let resolvedExecutable = resolvedCommandPath(executable: executable) { + process.executableURL = URL(fileURLWithPath: resolvedExecutable) + process.arguments = arguments + } else { + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [executable] + arguments } -#endif - return await withCheckedContinuation { continuation in - let process = Process() - let stdout = Pipe() - let stderr = Pipe() - if let resolvedExecutable = resolvedCommandPath(executable: executable) { - process.executableURL = URL(fileURLWithPath: resolvedExecutable) - process.arguments = arguments - } else { - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = [executable] + arguments - } - process.currentDirectoryURL = URL(fileURLWithPath: directory) - process.standardInput = FileHandle.nullDevice - process.standardOutput = stdout - process.standardError = stderr - - let state = CommandRunState(continuation: continuation) - let timeoutWorkItem = timeout.map { _ in - DispatchWorkItem { [state, process] in - guard !state.hasResumed() else { return } - guard process.isRunning else { return } - process.terminate() - DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.2) { - if process.isRunning { - kill(process.processIdentifier, SIGKILL) - } - } - state.resume( - returning: CommandResult( - stdout: nil, - stderr: nil, - exitStatus: nil, - timedOut: true, - executionError: nil - ) - ) - } - } - state.setTimeoutWorkItem(timeoutWorkItem) - process.terminationHandler = { terminatedProcess in - state.completeTermination(exitStatus: terminatedProcess.terminationStatus) - } + process.currentDirectoryURL = URL(fileURLWithPath: directory) + process.standardOutput = stdout + process.standardError = stderr - do { - try process.run() - } catch { - try? stdout.fileHandleForWriting.close() - try? stderr.fileHandleForWriting.close() - state.resume( - returning: CommandResult( - stdout: nil, - stderr: nil, - exitStatus: nil, - timedOut: false, - executionError: String(describing: error) - ) - ) - return - } + let completion = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in + completion.signal() + } - try? stdout.fileHandleForWriting.close() - try? stderr.fileHandleForWriting.close() + do { + try process.run() + } catch { + return CommandResult( + stdout: nil, + stderr: nil, + exitStatus: nil, + timedOut: false, + executionError: String(describing: error) + ) + } - DispatchQueue.global(qos: .utility).async { - let data = stdout.fileHandleForReading.readDataToEndOfFile() - state.completeStdout(data) - } - DispatchQueue.global(qos: .utility).async { - let data = stderr.fileHandleForReading.readDataToEndOfFile() - state.completeStderr(data) - } - if let timeout, - let timeoutWorkItem { - DispatchQueue.global(qos: .utility).asyncAfter( - deadline: .now() + timeout, - execute: timeoutWorkItem - ) + if let timeout, + completion.wait(timeout: .now() + timeout) == .timedOut { + process.terminate() + if completion.wait(timeout: .now() + 0.2) == .timedOut { + kill(process.processIdentifier, SIGKILL) + _ = completion.wait(timeout: .now() + 0.2) } - } + return CommandResult( + stdout: nil, + stderr: nil, + exitStatus: nil, + timedOut: true, + executionError: nil + ) + } else if timeout == nil { + completion.wait() + } + + let stdoutData = stdout.fileHandleForReading.readDataToEndOfFile() + let stderrData = stderr.fileHandleForReading.readDataToEndOfFile() + return CommandResult( + stdout: String(data: stdoutData, encoding: .utf8), + stderr: String(data: stderrData, encoding: .utf8), + exitStatus: process.terminationStatus, + timedOut: false, + executionError: nil + ) } nonisolated static func githubRepositorySlugs(fromGitRemoteVOutput output: String) -> [String] { @@ -3448,8 +3227,8 @@ class TabManager: ObservableObject { return orderedSlugs } - private nonisolated static func githubRepositorySlugs(directory: String) async -> [String] { - guard let output = await runGitCommand(directory: directory, arguments: ["remote", "-v"]) else { + private nonisolated static func githubRepositorySlugs(directory: String) -> [String] { + guard let output = runGitCommand(directory: directory, arguments: ["remote", "-v"]) else { return [] } return githubRepositorySlugs(fromGitRemoteVOutput: output) @@ -3638,7 +3417,7 @@ class TabManager: ObservableObject { for workspace in currentTabs { guard let tabSnapshot = snapshotTabsById[workspace.id] else { #if DEBUG - cmuxDebugLog( + dlog( "workspace.create.reentrantSnapshotFallback " + "snapshotCount=\(snapshot.tabs.count) liveCount=\(currentTabs.count)" ) @@ -3655,7 +3434,7 @@ class TabManager: ObservableObject { workspace: Workspace? ) -> TerminalPanel? { guard let workspace else { return nil } - // Prefer cached/published panel state here instead of walking live Bonsplit focus + // Prefer cached/published panel state here instead of walking live WorkspaceSplit focus // during Cmd+N; rapid workspace creation can observe transient pane/tab selection. let panels = workspace.panels var candidates: [TerminalPanel] = [] @@ -3788,12 +3567,12 @@ class TabManager: ObservableObject { return nil } // Use cached directory state only; avoiding live focus traversal keeps workspace - // creation resilient when Bonsplit is in the middle of a rapid Cmd+N churn. + // creation resilient when WorkspaceSplit is in the middle of a rapid Cmd+N churn. if let currentDirectory = normalizedWorkingDirectory(workspace.currentDirectory) { return currentDirectory } - return workspace.panelDirectories.values.lazy.compactMap { directory in + return workspace.surfaceStatesSnapshot().values.lazy.compactMap(\.directory).compactMap { directory in self.normalizedWorkingDirectory(directory) }.first } @@ -3923,8 +3702,18 @@ class TabManager: ObservableObject { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } let previousDirectory = gitProbeDirectory(for: tab, panelId: surfaceId) let normalized = normalizeDirectory(directory) - tab.updatePanelDirectory(panelId: surfaceId, directory: normalized) let nextDirectory = normalizedWorkingDirectory(normalized) +#if DEBUG + dlog( + "dbg.title.tabManager.dir tab=\(tabId.uuidString.prefix(8)) " + + "panel=\(surfaceId.uuidString.prefix(8)) " + + "prev=\"\(debugTabManagerPreview(previousDirectory))\" " + + "input=\"\(debugTabManagerPreview(directory))\" " + + "normalized=\"\(debugTabManagerPreview(normalized))\" " + + "next=\"\(debugTabManagerPreview(nextDirectory))\"" + ) +#endif + tab.updatePanelDirectory(panelId: surfaceId, directory: normalized) if previousDirectory != nextDirectory { scheduleWorkspacePullRequestRefresh( workspaceId: tabId, @@ -3946,7 +3735,7 @@ class TabManager: ObservableObject { isDirty: Bool ) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } - let current = tab.panelGitBranches[surfaceId] + let current = tab.surfaceStateSnapshot(panelId: surfaceId).gitBranch let normalizedBranch = Self.normalizedBranchName(branch) ?? branch guard current?.branch != normalizedBranch || current?.isDirty != isDirty else { return } tab.updatePanelGitBranch(panelId: surfaceId, branch: normalizedBranch, isDirty: isDirty) @@ -3968,8 +3757,9 @@ class TabManager: ObservableObject { func clearSurfaceGitBranch(tabId: UUID, surfaceId: UUID) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } - let hadBranch = tab.panelGitBranches[surfaceId] != nil - let hadPullRequest = tab.panelPullRequests[surfaceId] != nil + let surfaceState = tab.surfaceStateSnapshot(panelId: surfaceId) + let hadBranch = surfaceState.gitBranch != nil + let hadPullRequest = surfaceState.pullRequest != nil guard hadBranch || hadPullRequest else { return } clearWorkspacePullRequestTracking( for: WorkspaceGitProbeKey(workspaceId: tabId, panelId: surfaceId) @@ -4025,7 +3815,7 @@ class TabManager: ObservableObject { action: String, target: String? ) { - guard let currentPullRequest = workspace.panelPullRequests[panelId], + guard let currentPullRequest = workspace.surfaceStateSnapshot(panelId: panelId).pullRequest, pullRequestCommandTargetMatchesCurrentPullRequest( target, currentPullRequest: currentPullRequest @@ -4101,29 +3891,27 @@ class TabManager: ObservableObject { } func closeWorkspace(_ workspace: Workspace) { - guard tabs.count > 1 else { return } + guard tabs.count > 1, + let index = tabs.firstIndex(where: { $0.id == workspace.id }) else { return } sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1]) clearWorkspaceGitProbes(workspaceId: workspace.id) clearWorkspacePullRequestTracking(workspaceId: workspace.id) sidebarSelectedWorkspaceIds.remove(workspace.id) - AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) - workspace.teardownAllPanels() - workspace.teardownRemoteConnection() - unwireClosedBrowserTracking(for: workspace) - workspace.owningTabManager = nil - - if let index = tabs.firstIndex(where: { $0.id == workspace.id }) { - tabs.remove(at: index) - - if selectedTabId == workspace.id { - // Keep the "focused index" stable when possible: - // - If we closed workspace i and there is still a workspace at index i, focus it (the one that moved up). - // - Otherwise (we closed the last workspace), focus the new last workspace (i-1). - let newIndex = min(index, max(0, tabs.count - 1)) - selectedTabId = tabs[newIndex].id + if selectedTabId == workspace.id { + let replacementId: UUID? + if index + 1 < tabs.count { + replacementId = tabs[index + 1].id + } else if index > 0 { + replacementId = tabs[index - 1].id + } else { + replacementId = nil } + selectedTabId = replacementId } + + let removed = tabs.remove(at: index) + releaseClosedWorkspace(removed) } /// Detach a workspace from this window without closing its panels. @@ -4181,7 +3969,6 @@ class TabManager: ObservableObject { #if DEBUG UITestRecorder.incrementInt("closePanelInvocations") #endif - guard !closeConfirmationInFlight else { return } guard let selectedId = selectedTabId, let tab = tabs.first(where: { $0.id == selectedId }) else { return } reconcileFocusedPanelFromFirstResponderForKeyboard() @@ -4194,7 +3981,6 @@ class TabManager: ObservableObject { } func closeOtherTabsInFocusedPaneWithConfirmation() { - guard !closeConfirmationInFlight else { return } guard let plan = closeOtherTabsInFocusedPanePlan() else { return } let count = plan.panelIds.count @@ -4215,7 +4001,6 @@ class TabManager: ObservableObject { #if DEBUG UITestRecorder.incrementInt("closeTabInvocations") #endif - guard !closeConfirmationInFlight else { return } let sidebarSelectionIds = orderedSidebarSelectedWorkspaceIds() if sidebarSelectionIds.count > 1 { closeWorkspacesWithConfirmation(sidebarSelectionIds, allowPinned: true) @@ -4292,24 +4077,7 @@ class TabManager: ObservableObject { // Keep selectTab as convenience alias func selectTab(_ tab: Workspace) { selectWorkspace(tab) } - var isCloseConfirmationInFlight: Bool { closeConfirmationInFlight } - - func beginCloseConfirmationSession() -> Bool { - guard !closeConfirmationInFlight else { return false } - closeConfirmationInFlight = true - return true - } - - func endCloseConfirmationSession() { - DispatchQueue.main.async { [weak self] in - self?.closeConfirmationInFlight = false - } - } - - func confirmClose(title: String, message: String, acceptCmdD: Bool) -> Bool { - guard beginCloseConfirmationSession() else { return false } - defer { endCloseConfirmationSession() } - + private func confirmClose(title: String, message: String, acceptCmdD: Bool) -> Bool { if let confirmCloseHandler { return confirmCloseHandler(title, message, acceptCmdD) } @@ -4332,66 +4100,11 @@ class TabManager: ObservableObject { cancelButton.keyEquivalent = "\u{1b}" } - #if DEBUG - UITestRecorder.record([ - "closeConfirmationTitle": title, - "closeConfirmationMessage": message, - ]) - #endif - - return runCloseConfirmationAlert(alert) == .alertFirstButtonReturn - } - - private func runCloseConfirmationAlert(_ alert: NSAlert) -> NSApplication.ModalResponse { if NSApp.activationPolicy() == .regular { NSApp.activate(ignoringOtherApps: true) } - let presentingWindow = closeConfirmationPresentingWindow() - let hasAttachedSheet = presentingWindow?.attachedSheet != nil - guard let presentingWindow, !hasAttachedSheet else { - #if DEBUG - UITestRecorder.record([ - "closeConfirmationPresentation": "appModal", - "closeConfirmationAttachedSheet": hasAttachedSheet ? "1" : "0", - ]) - #endif - - return alert.runModal() - } - - alert.beginSheetModal(for: presentingWindow) { result in - NSApp.stopModal(withCode: result) - } - #if DEBUG - DispatchQueue.main.async { - UITestRecorder.record([ - "closeConfirmationPresentation": "sheet", - "closeConfirmationAttachedSheet": presentingWindow.attachedSheet == nil ? "0" : "1", - ]) - } - #endif - return NSApp.runModal(for: alert.window) - } - - private func closeConfirmationPresentingWindow() -> NSWindow? { - if let window, window.isVisible, isCloseConfirmationMainWindow(window) { - return window - } - if let keyWindow = NSApp.keyWindow, keyWindow.isVisible, isCloseConfirmationMainWindow(keyWindow) { - return keyWindow - } - if let mainWindow = NSApp.mainWindow, mainWindow.isVisible, isCloseConfirmationMainWindow(mainWindow) { - return mainWindow - } - return NSApp.windows.first { candidate in - candidate.isVisible && isCloseConfirmationMainWindow(candidate) - } - } - - private func isCloseConfirmationMainWindow(_ candidate: NSWindow) -> Bool { - guard let raw = candidate.identifier?.rawValue else { return false } - return raw == "cmux.main" || raw.hasPrefix("cmux.main.") + return alert.runModal() == .alertFirstButtonReturn } private struct CloseOtherTabsInFocusedPanePlan { @@ -4409,20 +4122,21 @@ class TabManager: ObservableObject { private func closeOtherTabsInFocusedPanePlan() -> CloseOtherTabsInFocusedPanePlan? { guard let workspace = selectedWorkspace else { return nil } - guard let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first else { + guard let paneId = workspace.focusedPaneId ?? workspace.paneIds.first else { return nil } - let tabsInPane = workspace.bonsplitController.tabs(inPane: paneId) + let tabsInPane = workspace.surfaceIds(inPane: paneId) guard !tabsInPane.isEmpty else { return nil } - guard let selectedTabId = workspace.bonsplitController.selectedTab(inPane: paneId)?.id ?? tabsInPane.first?.id else { + guard let selectedTabId = workspace.selectedSurfaceId(inPane: paneId) ?? tabsInPane.first else { return nil } var targetPanelIds: [UUID] = [] var targetTitles: [String] = [] - for tab in tabsInPane where tab.id != selectedTabId { - guard let panelId = workspace.panelIdFromSurfaceId(tab.id) else { continue } + for tabId in tabsInPane where tabId != selectedTabId { + guard workspace.panels[tabId] != nil else { continue } + let panelId = tabId if workspace.isPanelPinned(panelId) { continue } @@ -4533,7 +4247,7 @@ class TabManager: ObservableObject { private func closePanelWithConfirmation(tab: Workspace, panelId: UUID) { guard tab.panels[panelId] != nil else { #if DEBUG - cmuxDebugLog( + dlog( "surface.close.shortcut.skip tab=\(tab.id.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) reason=missingPanel" ) @@ -4541,8 +4255,8 @@ class TabManager: ObservableObject { return } - let bonsplitTabCount = tab.bonsplitController.allPaneIds.reduce(0) { partial, paneId in - partial + tab.bonsplitController.tabs(inPane: paneId).count + let splitTabCount = tab.paneIds.reduce(0) { partial, paneId in + partial + tab.surfaceIds(inPane: paneId).count } let panelKind: String = { guard let panel = tab.panels[panelId] else { return "missing" } @@ -4552,10 +4266,10 @@ class TabManager: ObservableObject { }() let closesWorkspaceOnLastSurfaceShortcut = shouldCloseWorkspaceOnLastSurfaceShortcut(tab, panelId: panelId) #if DEBUG - cmuxDebugLog( + dlog( "surface.close.shortcut.begin tab=\(tab.id.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) kind=\(panelKind) " + - "panelCount=\(tab.panels.count) bonsplitTabs=\(bonsplitTabCount) " + + "panelCount=\(tab.panels.count) WorkspaceSplitTabs=\(splitTabCount) " + "closeWorkspaceOnLastSurface=\(closesWorkspaceOnLastSurfaceShortcut ? 1 : 0)" ) #endif @@ -4568,7 +4282,7 @@ class TabManager: ObservableObject { } let closed = tab.closePanel(panelId) #if DEBUG - cmuxDebugLog( + dlog( "surface.close.shortcut tab=\(tab.id.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) closed=\(closed ? 1 : 0) " + "panelsAfterCall=\(tab.panels.count)" @@ -4586,11 +4300,11 @@ class TabManager: ObservableObject { return workspace.panels.keys.first } - let candidatePane = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first + let candidatePane = workspace.focusedPaneId ?? workspace.paneIds.first if let candidatePane, - let selectedTabId = workspace.bonsplitController.selectedTab(inPane: candidatePane)?.id - ?? workspace.bonsplitController.tabs(inPane: candidatePane).first?.id, - let panelId = workspace.panelIdFromSurfaceId(selectedTabId), + let selectedTabId = workspace.selectedSurfaceId(inPane: candidatePane) + ?? workspace.surfaceIds(inPane: candidatePane).first, + let panelId = workspace.panels[selectedTabId]?.id, workspace.panels[panelId] != nil { return panelId } @@ -4627,9 +4341,19 @@ class TabManager: ObservableObject { func closeRuntimeSurface(tabId: UUID, surfaceId: UUID) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } guard tab.panels[surfaceId] != nil else { return } +#if DEBUG + latencyLog( + "ctrl_d.closeRuntimeSurface.begin", + data: [ + "panelsBefore": String(tab.panels.count), + "surface": surfaceId.uuidString.prefix(5).description, + "tab": tabId.uuidString.prefix(5).description, + ] + ) +#endif #if DEBUG - cmuxDebugLog( + dlog( "surface.close.runtime tab=\(tabId.uuidString.prefix(5)) " + "surface=\(surfaceId.uuidString.prefix(5)) panelsBefore=\(tab.panels.count)" ) @@ -4641,7 +4365,16 @@ class TabManager: ObservableObject { reconcileFocusedPanelFromFirstResponderForKeyboard() let closed = tab.closePanel(surfaceId, force: true) #if DEBUG - cmuxDebugLog( + latencyLog( + "ctrl_d.closeRuntimeSurface.end", + data: [ + "closed": closed ? "1" : "0", + "panelsAfter": String(tab.panels.count), + "surface": surfaceId.uuidString.prefix(5).description, + "tab": tabId.uuidString.prefix(5).description, + ] + ) + dlog( "surface.close.runtime.done tab=\(tabId.uuidString.prefix(5)) " + "surface=\(surfaceId.uuidString.prefix(5)) closed=\(closed ? 1 : 0) panelsAfter=\(tab.panels.count)" ) @@ -4660,7 +4393,20 @@ class TabManager: ObservableObject { tab.panels.count <= 1 && tab.shouldDemoteWorkspaceAfterChildExit(surfaceId: surfaceId) #if DEBUG - cmuxDebugLog( + latencyLog( + "ctrl_d.closePanelAfterChildExited.begin", + data: [ + "keepRemote": keepsRemoteWorkspaceOpen ? "1" : "0", + "panels": String(tab.panels.count), + "surface": surfaceId.uuidString.prefix(5).description, + "tab": tabId.uuidString.prefix(5).description, + "workspaces": String(tabs.count), + ] + ) +#endif + +#if DEBUG + dlog( "surface.close.childExited tab=\(tabId.uuidString.prefix(5)) " + "surface=\(surfaceId.uuidString.prefix(5)) panels=\(tab.panels.count) workspaces=\(tabs.count) " + "remoteWorkspace=\(tab.isRemoteWorkspace ? 1 : 0) keepRemote=\(keepsRemoteWorkspaceOpen ? 1 : 0)" @@ -4778,7 +4524,7 @@ class TabManager: ObservableObject { let didRequestExplicitWebViewFocus = browserPanel.requestExplicitWebViewFocus() #if DEBUG - cmuxDebugLog( + dlog( "reactGrab.pasteback h1.focusRequestResult " + "workspace=\(workspace.id.uuidString.prefix(5)) " + "browser=\(browserPanel.id.uuidString.prefix(5)) " + @@ -4845,8 +4591,6 @@ class TabManager: ObservableObject { return } - // Defer unfocusing the previous workspace's panel until ContentView confirms handoff - // completion (new workspace has focus or timeout fallback), to avoid a visible freeze gap. if let previousTabId, let previousTab = tabs.first(where: { $0.id == previousTabId }), let previousPanelId = previousTab.focusedPanelId, @@ -4863,15 +4607,13 @@ class TabManager: ObservableObject { func completePendingWorkspaceUnfocus(reason: String) { guard let pending = pendingWorkspaceUnfocusTarget else { return } - // If this tab became selected again before handoff completion, drop the stale - // pending entry so it cannot be flushed later and deactivate the selected workspace. guard Self.shouldUnfocusPendingWorkspace( pendingTabId: pending.tabId, selectedTabId: selectedTabId ) else { pendingWorkspaceUnfocusTarget = nil #if DEBUG - cmuxDebugLog( + dlog( "ws.unfocus.drop tab=\(Self.debugShortWorkspaceId(pending.tabId)) panel=\(String(pending.panelId.uuidString.prefix(5))) reason=selected_again" ) #endif @@ -4882,12 +4624,12 @@ class TabManager: ObservableObject { #if DEBUG if let snapshot = debugCurrentWorkspaceSwitchSnapshot() { let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 - cmuxDebugLog( + dlog( "ws.unfocus.complete id=\(snapshot.id) dt=\(Self.debugMsText(dtMs)) " + "tab=\(Self.debugShortWorkspaceId(pending.tabId)) panel=\(String(pending.panelId.uuidString.prefix(5))) reason=\(reason)" ) } else { - cmuxDebugLog( + dlog( "ws.unfocus.complete id=none tab=\(Self.debugShortWorkspaceId(pending.tabId)) " + "panel=\(String(pending.panelId.uuidString.prefix(5))) reason=\(reason)" ) @@ -4903,20 +4645,19 @@ class TabManager: ObservableObject { } if let current = pendingWorkspaceUnfocusTarget { - // Never unfocus the currently selected workspace when replacing stale pending state. if Self.shouldUnfocusPendingWorkspace( pendingTabId: current.tabId, selectedTabId: selectedTabId ) { unfocusWorkspacePanel(tabId: current.tabId, panelId: current.panelId) #if DEBUG - cmuxDebugLog( + dlog( "ws.unfocus.flush tab=\(Self.debugShortWorkspaceId(current.tabId)) panel=\(String(current.panelId.uuidString.prefix(5))) reason=replaced" ) #endif } else { #if DEBUG - cmuxDebugLog( + dlog( "ws.unfocus.drop tab=\(Self.debugShortWorkspaceId(current.tabId)) panel=\(String(current.panelId.uuidString.prefix(5))) reason=replaced_selected" ) #endif @@ -4927,12 +4668,12 @@ class TabManager: ObservableObject { #if DEBUG if let snapshot = debugCurrentWorkspaceSwitchSnapshot() { let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 - cmuxDebugLog( + dlog( "ws.unfocus.defer id=\(snapshot.id) dt=\(Self.debugMsText(dtMs)) " + "tab=\(Self.debugShortWorkspaceId(next.tabId)) panel=\(String(next.panelId.uuidString.prefix(5)))" ) } else { - cmuxDebugLog( + dlog( "ws.unfocus.defer id=none tab=\(Self.debugShortWorkspaceId(next.tabId)) panel=\(String(next.panelId.uuidString.prefix(5)))" ) } @@ -4989,6 +4730,18 @@ class TabManager: ObservableObject { guard !trimmed.isEmpty else { return } let key = PanelTitleUpdateKey(tabId: tabId, panelId: panelId) pendingPanelTitleUpdates[key] = trimmed +#if DEBUG + dlog( + "dbg.title.tabManager.enqueue tab=\(tabId.uuidString.prefix(8)) " + + "panel=\(panelId.uuidString.prefix(8)) " + + "title=\"\(trimmed.replacingOccurrences(of: "\n", with: "\\n"))\"" + ) + startupLog( + "rt.title.tabManager.enqueue tab=\(tabId.uuidString.prefix(8)) " + + "panel=\(panelId.uuidString.prefix(8)) " + + "title=\"\(trimmed.replacingOccurrences(of: "\n", with: "\\n"))\"" + ) +#endif panelTitleUpdateCoalescer.signal { [weak self] in self?.flushPendingPanelTitleUpdates() } @@ -4998,6 +4751,9 @@ class TabManager: ObservableObject { guard !pendingPanelTitleUpdates.isEmpty else { return } let updates = pendingPanelTitleUpdates pendingPanelTitleUpdates.removeAll(keepingCapacity: true) +#if DEBUG + dlog("dbg.title.tabManager.flush count=\(updates.count)") +#endif for (key, title) in updates { updatePanelTitle(tabId: key.tabId, panelId: key.panelId, title: title) } @@ -5005,7 +4761,27 @@ class TabManager: ObservableObject { private func updatePanelTitle(tabId: UUID, panelId: UUID, title: String) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + let previousTitle = tab.surfaceStateSnapshot(panelId: panelId).title let didChange = tab.updatePanelTitle(panelId: panelId, title: title) +#if DEBUG + let nextTitle = tab.surfaceStateSnapshot(panelId: panelId).title + dlog( + "dbg.title.tabManager.apply tab=\(tabId.uuidString.prefix(8)) " + + "panel=\(panelId.uuidString.prefix(8)) " + + "didChange=\(didChange ? 1 : 0) " + + "old=\"\(debugTabManagerPreview(previousTitle))\" " + + "input=\"\(debugTabManagerPreview(title))\" " + + "new=\"\(debugTabManagerPreview(nextTitle))\"" + ) + startupLog( + "rt.title.tabManager.apply tab=\(tabId.uuidString.prefix(8)) " + + "panel=\(panelId.uuidString.prefix(8)) " + + "didChange=\(didChange ? 1 : 0) " + + "old=\"\(debugTabManagerPreview(previousTitle))\" " + + "input=\"\(debugTabManagerPreview(title))\" " + + "new=\"\(debugTabManagerPreview(nextTitle))\"" + ) +#endif guard didChange else { return } // Update window title if this is the selected tab and focused panel @@ -5017,7 +4793,7 @@ class TabManager: ObservableObject { func focusedSurfaceTitleDidChange(tabId: UUID) { guard let tab = tabs.first(where: { $0.id == tabId }), let focusedPanelId = tab.focusedPanelId, - let title = tab.panelTitles[focusedPanelId] else { return } + let title = tab.surfaceStateSnapshot(panelId: focusedPanelId).title else { return } tab.applyProcessTitle(title) if selectedTabId == tabId { updateWindowTitle(for: tab) @@ -5091,13 +4867,13 @@ class TabManager: ObservableObject { func focusTabFromNotification(_ tabId: UUID, surfaceId: UUID? = nil) -> Bool { guard let tab = tabs.first(where: { $0.id == tabId }) else { #if DEBUG - cmuxDebugLog("notification.focus.fail tab=\(tabId.uuidString.prefix(5)) reason=missingTab") + dlog("notification.focus.fail tab=\(tabId.uuidString.prefix(5)) reason=missingTab") #endif return false } if let surfaceId, tab.panels[surfaceId] == nil { #if DEBUG - cmuxDebugLog( + dlog( "notification.focus.fail tab=\(tabId.uuidString.prefix(5)) " + "panel=\(surfaceId.uuidString.prefix(5)) reason=missingPanel" ) @@ -5165,7 +4941,7 @@ class TabManager: ObservableObject { if !isWorkspaceCycleHot { isWorkspaceCycleHot = true #if DEBUG - cmuxDebugLog( + dlog( "ws.hot.on id=\(switchId) gen=\(generation) dt=\(Self.debugMsText(switchDtMs))" ) #endif @@ -5175,7 +4951,7 @@ class TabManager: ObservableObject { workspaceCycleCooldownTask?.cancel() #if DEBUG if hadPendingCooldown { - cmuxDebugLog( + dlog( "ws.hot.cancelPrev id=\(switchId) gen=\(generation) dt=\(Self.debugMsText(switchDtMs))" ) } @@ -5190,7 +4966,7 @@ class TabManager: ObservableObject { let dtMs = self.debugWorkspaceSwitchStartTime > 0 ? (CACurrentMediaTime() - self.debugWorkspaceSwitchStartTime) * 1000 : 0 - cmuxDebugLog( + dlog( "ws.hot.cooldownCanceled id=\(self.debugWorkspaceSwitchId) gen=\(generation) dt=\(Self.debugMsText(dtMs))" ) } @@ -5204,7 +4980,7 @@ class TabManager: ObservableObject { let dtMs = self.debugWorkspaceSwitchStartTime > 0 ? (CACurrentMediaTime() - self.debugWorkspaceSwitchStartTime) * 1000 : 0 - cmuxDebugLog( + dlog( "ws.hot.off id=\(self.debugWorkspaceSwitchId) gen=\(generation) dt=\(Self.debugMsText(dtMs))" ) #endif @@ -5247,13 +5023,39 @@ class TabManager: ObservableObject { debugWorkspaceSwitchCounter &+= 1 debugWorkspaceSwitchId = debugWorkspaceSwitchCounter debugWorkspaceSwitchStartTime = CACurrentMediaTime() - cmuxDebugLog( + dlog( "ws.switch.begin id=\(debugWorkspaceSwitchId) trigger=\(trigger) " + "from=\(Self.debugShortWorkspaceId(from)) to=\(Self.debugShortWorkspaceId(to)) " + "hot=\(isWorkspaceCycleHot ? 1 : 0) tabs=\(tabs.count)" ) } + private func debugSelectedWorkspacePanelSummary(for workspaceId: UUID?) -> String { + guard let workspaceId, + let workspace = tabs.first(where: { $0.id == workspaceId }) else { + return "panel=nil kind=nil" + } + + guard let panelId = workspace.focusedPanelId, + let panel = workspace.panels[panelId] else { + return "panel=nil kind=nil" + } + + let kind: String + switch panel { + case is BrowserPanel: + kind = "browser" + case is TerminalPanel: + kind = "terminal" + case is MarkdownPanel: + kind = "markdown" + default: + kind = String(describing: type(of: panel)) + } + + return "panel=\(Self.debugShortWorkspaceId(panelId)) kind=\(kind)" + } + private static func debugShortWorkspaceId(_ id: UUID?) -> String { guard let id else { return "nil" } return String(id.uuidString.prefix(5)) @@ -5303,12 +5105,10 @@ class TabManager: ObservableObject { func newSurface() { // Cmd+T should always focus the newly created surface. selectedWorkspace?.clearSplitZoom() - selectedWorkspace?.newTerminalSurfaceInFocusedPane(focus: true) - } - - func newSurface(initialInput: String) { - selectedWorkspace?.clearSplitZoom() - selectedWorkspace?.newTerminalSurfaceInFocusedPane(focus: true, initialInput: initialInput) + if let workspace = selectedWorkspace, + let paneId = workspace.focusedPaneId { + _ = workspace.performLayoutCommand(.createTerminal(inPane: paneId, focus: true)) + } } // MARK: - Split Creation @@ -5319,6 +5119,17 @@ class TabManager: ObservableObject { guard let selectedTabId, let tab = tabs.first(where: { $0.id == selectedTabId }), let focusedPanelId = tab.focusedPanelId else { return nil } +#if DEBUG + if direction == .right { + latencyLog( + "cmd_d.tabManager.createSplit.focused", + data: [ + "tab": selectedTabId.uuidString.prefix(5).description, + "surface": focusedPanelId.uuidString.prefix(5).description, + ] + ) + } +#endif return createSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction) } @@ -5327,9 +5138,33 @@ class TabManager: ObservableObject { func createSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection, focus: Bool = true) -> UUID? { guard let tab = tabs.first(where: { $0.id == tabId }), tab.panels[surfaceId] != nil else { return nil } +#if DEBUG + if direction == .right { + latencyLog( + "cmd_d.tabManager.createSplit.begin", + data: [ + "focus": focus ? "1" : "0", + "tab": tabId.uuidString.prefix(5).description, + "surface": surfaceId.uuidString.prefix(5).description, + ] + ) + } +#endif tab.clearSplitZoom() sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)]) - return newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction, focus: focus) + let result = newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction, focus: focus) +#if DEBUG + if direction == .right { + latencyLog( + "cmd_d.tabManager.createSplit.end", + data: [ + "createdSurface": result?.uuidString.prefix(5).description ?? "nil", + "tab": tabId.uuidString.prefix(5).description, + ] + ) + } +#endif + return result } /// Create a new browser split from the currently focused panel. @@ -5339,40 +5174,23 @@ class TabManager: ObservableObject { let tab = tabs.first(where: { $0.id == selectedTabId }), let focusedPanelId = tab.focusedPanelId else { return nil } tab.clearSplitZoom() - return newBrowserSplit( - tabId: selectedTabId, - fromPanelId: focusedPanelId, - orientation: direction.orientation, - insertFirst: direction.insertFirst, - url: url - ) + return tab.performLayoutCommand( + .splitBrowser( + fromPanelId: focusedPanelId, + orientation: direction.orientation, + insertFirst: direction.insertFirst, + url: url + ) + ).browserPanel?.id } - /// Refresh Bonsplit right-side action button tooltips for all workspaces. + /// Refresh WorkspaceSplit right-side action button tooltips for all workspaces. func refreshSplitButtonTooltips() { for workspace in tabs { workspace.refreshSplitButtonTooltips() } } - func applySurfaceTabBarButtons( - _ buttons: [CmuxSurfaceTabBarButton], - sourcePath: String?, - globalConfigPath: String, - terminalCommandSourcePaths: [String: String], - workspaceCommands: [String: CmuxResolvedCommand] - ) { - for workspace in tabs { - workspace.applySurfaceTabBarButtons( - buttons, - sourcePath: sourcePath, - globalConfigPath: globalConfigPath, - terminalCommandSourcePaths: terminalCommandSourcePaths, - workspaceCommands: workspaceCommands - ) - } - } - // MARK: - Pane Focus Navigation /// Move focus to an adjacent pane in the specified direction @@ -5464,12 +5282,26 @@ class TabManager: ObservableObject { /// Returns the new panel's ID (which is also the surface ID for terminals) 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, - focus: focus - )?.id + let result = tab.performLayoutCommand( + .splitTerminal( + fromPanelId: surfaceId, + orientation: direction.orientation, + insertFirst: direction.insertFirst, + focus: focus + ) + ).terminalPanel?.id +#if DEBUG + if direction == .right { + latencyLog( + "cmd_d.tabManager.newSplit.end", + data: [ + "createdSurface": result?.uuidString.prefix(5).description ?? "nil", + "tab": tabId.uuidString.prefix(5).description, + ] + ) + } +#endif + return result } /// Move focus in the specified direction @@ -5479,20 +5311,20 @@ class TabManager: ObservableObject { return true } - /// Resize split - not directly supported by bonsplit, but we can adjust divider positions + /// Resize split - not directly supported by WorkspaceSplit, but we can adjust divider positions func resizeSplit(tabId: UUID, surfaceId: UUID, direction: ResizeDirection, amount: UInt16) -> Bool { guard amount > 0, let tab = tabs.first(where: { $0.id == tabId }), let paneId = tab.paneId(forPanelId: surfaceId) else { return false } let paneUUID = paneId.id - guard tab.bonsplitController.allPaneIds.contains(where: { $0.id == paneUUID }) else { + guard tab.paneIds.contains(where: { $0.id == paneUUID }) else { return false } var candidates: [ResizeSplitCandidate] = [] let trace = resizeSplitCollectCandidates( - node: tab.bonsplitController.treeSnapshot(), + node: tab.treeSnapshot(), targetPaneId: paneUUID.uuidString, candidates: &candidates ) @@ -5510,22 +5342,13 @@ class TabManager: ObservableObject { let delta = CGFloat(amount) / candidate.axisPixels let requested = candidate.dividerPosition + (direction.dividerDeltaSign * delta) let clamped = min(max(requested, 0.1), 0.9) - return tab.bonsplitController.setDividerPosition(clamped, forSplit: candidate.splitId, fromExternal: true) + return tab.setDividerPosition(clamped, forSplit: candidate.splitId, fromExternal: true) } - /// Equalize splits - not directly supported by bonsplit + /// Equalize splits - not directly supported by WorkspaceSplit func equalizeSplits(tabId: UUID) -> Bool { guard let tab = tabs.first(where: { $0.id == tabId }) else { return false } - - var foundSplit = false - var allSucceeded = true - equalizeSplits( - in: tab.bonsplitController.treeSnapshot(), - controller: tab.bonsplitController, - foundSplit: &foundSplit, - allSucceeded: &allSucceeded - ) - return foundSplit && allSucceeded + return tab.equalizeSplits() } /// Toggle zoom on a panel. @@ -5542,41 +5365,6 @@ class TabManager: ObservableObject { return tab.toggleSplitZoom(panelId: focusedPanelId) } - private func equalizeSplits( - in node: ExternalTreeNode, - controller: BonsplitController, - foundSplit: inout Bool, - allSucceeded: inout Bool - ) { - switch node { - case .pane: - return - case .split(let splitNode): - foundSplit = true - guard let splitId = UUID(uuidString: splitNode.id) else { - allSucceeded = false - return - } - - if !controller.setDividerPosition(0.5, forSplit: splitId) { - allSucceeded = false - } - - equalizeSplits( - in: splitNode.first, - controller: controller, - foundSplit: &foundSplit, - allSucceeded: &allSucceeded - ) - equalizeSplits( - in: splitNode.second, - controller: controller, - foundSplit: &foundSplit, - allSucceeded: &allSucceeded - ) - } - } - private struct ResizeSplitCandidate { let splitId: UUID let orientation: String @@ -5653,42 +5441,6 @@ class TabManager: ObservableObject { // MARK: - Browser Panel Operations - /// Create a new browser panel in a split - func newBrowserSplit( - tabId: UUID, - fromPanelId: UUID, - orientation: SplitOrientation, - insertFirst: Bool = false, - url: URL? = nil, - preferredProfileID: UUID? = 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, - preferredProfileID: preferredProfileID, - focus: focus - )?.id - } - - /// Create a new browser surface in a pane - func newBrowserSurface( - tabId: UUID, - inPane paneId: PaneID, - url: URL? = nil, - preferredProfileID: UUID? = nil - ) -> UUID? { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } - return tab.newBrowserSurface( - inPane: paneId, - url: url, - preferredProfileID: preferredProfileID - )?.id - } - /// Get a browser panel by ID func browserPanel(tabId: UUID, panelId: UUID) -> BrowserPanel? { guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } @@ -5711,7 +5463,7 @@ class TabManager: ObservableObject { if preferSplitRight { if let targetPaneId = workspace.topRightBrowserReusePane(), - let browserPanel = workspace.newBrowserSurface( + let browserPanel = workspace.createBrowserPanel( inPane: targetPaneId, url: url, focus: true, @@ -5738,8 +5490,8 @@ class TabManager: ObservableObject { }() if let splitSourcePanelId, - let browserPanel = workspace.newBrowserSplit( - from: splitSourcePanelId, + let browserPanel = workspace.splitBrowserPanel( + fromPanelId: splitSourcePanelId, orientation: .horizontal, url: url, preferredProfileID: preferredProfileID, @@ -5750,8 +5502,8 @@ class TabManager: ObservableObject { } } - guard let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first, - let browserPanel = workspace.newBrowserSurface( + guard let paneId = workspace.focusedPaneId ?? workspace.paneIds.first, + let browserPanel = workspace.createBrowserPanel( inPane: paneId, url: url, focus: true, @@ -5871,14 +5623,14 @@ class TabManager: ObservableObject { _ snapshot: ClosedBrowserPanelRestoreSnapshot, in workspace: Workspace ) -> UUID? { - if let originalPane = workspace.bonsplitController.allPaneIds.first(where: { $0.id == snapshot.originalPaneId }), - let browserPanel = workspace.newBrowserSurface( + if let originalPane = workspace.paneIds.first(where: { $0.id == snapshot.originalPaneId }), + let browserPanel = workspace.createBrowserPanel( inPane: originalPane, url: snapshot.url, focus: true, preferredProfileID: snapshot.profileID ) { - let tabCount = workspace.bonsplitController.tabs(inPane: originalPane).count + let tabCount = workspace.surfaceIds(inPane: originalPane).count let maxIndex = max(0, tabCount - 1) let targetIndex = min(max(snapshot.originalTabIndex, 0), maxIndex) _ = workspace.reorderSurface(panelId: browserPanel.id, toIndex: targetIndex) @@ -5887,11 +5639,11 @@ class TabManager: ObservableObject { if let orientation = snapshot.fallbackSplitOrientation, let fallbackAnchorPaneId = snapshot.fallbackAnchorPaneId, - let anchorPane = workspace.bonsplitController.allPaneIds.first(where: { $0.id == fallbackAnchorPaneId }), - let anchorTab = workspace.bonsplitController.selectedTab(inPane: anchorPane) ?? workspace.bonsplitController.tabs(inPane: anchorPane).first, - let anchorPanelId = workspace.panelIdFromSurfaceId(anchorTab.id), - let browserPanelId = workspace.newBrowserSplit( - from: anchorPanelId, + let anchorPane = workspace.paneIds.first(where: { $0.id == fallbackAnchorPaneId }), + let anchorTabId = workspace.selectedSurfaceId(inPane: anchorPane) ?? workspace.surfaceIds(inPane: anchorPane).first, + let anchorPanelId = workspace.panels[anchorTabId]?.id, + let browserPanelId = workspace.splitBrowserPanel( + fromPanelId: anchorPanelId, orientation: orientation, insertFirst: snapshot.fallbackSplitInsertFirst, url: snapshot.url, @@ -5900,10 +5652,10 @@ class TabManager: ObservableObject { return browserPanelId } - guard let focusedPane = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first else { + guard let focusedPane = workspace.focusedPaneId ?? workspace.paneIds.first else { return nil } - return workspace.newBrowserSurface( + return workspace.createBrowserPanel( inPane: focusedPane, url: snapshot.url, focus: true, @@ -5920,7 +5672,7 @@ class TabManager: ObservableObject { /// Ensure AppKit first responder matches the currently focused terminal panel. /// This keeps real keyboard events (including Ctrl+D) on the same panel as the - /// bonsplit focus indicator after rapid split topology changes. + /// WorkspaceSplit focus indicator after rapid split topology changes. func ensureFocusedTerminalFirstResponder() { guard let tab = selectedWorkspace, let panelId = tab.focusedPanelId, @@ -5930,7 +5682,7 @@ class TabManager: ObservableObject { /// Reconcile keyboard routing before terminal control shortcuts (e.g. Ctrl+D). /// - /// Source of truth for pane focus is bonsplit's focused pane + selected tab. + /// Source of truth for pane focus is WorkspaceSplit's focused pane + selected tab. /// Keyboard delivery must converge AppKit first responder to that model state, not mutate /// the model from whatever first responder happened to be during reparenting transitions. func reconcileFocusedPanelFromFirstResponderForKeyboard() { @@ -6001,17 +5753,18 @@ class TabManager: ObservableObject { return await withCheckedContinuation { (cont: CheckedContinuation) in var resolved = false var panelsCancellable: AnyCancellable? - var readyObserver: NSObjectProtocol? var hostedViewObserver: NSObjectProtocol? + var readyToken: UUID? @MainActor func finish(_ value: Bool) { guard !resolved else { return } resolved = true panelsCancellable?.cancel() - if let readyObserver { - NotificationCenter.default.removeObserver(readyObserver) + if let panel = tab.terminalPanel(for: panelId) { + panel.surface.cancelRuntimeReadyCallback(readyToken) } + readyToken = nil if let hostedViewObserver { NotificationCenter.default.removeObserver(hostedViewObserver) } @@ -6037,14 +5790,8 @@ class TabManager: ObservableObject { evaluate() } } - readyObserver = NotificationCenter.default.addObserver( - forName: .terminalSurfaceDidBecomeReady, - object: nil, - queue: .main - ) { note in - guard let readySurfaceId = note.userInfo?["surfaceId"] as? UUID, - readySurfaceId == panelId else { return } - Task { @MainActor in + if let panel = tab.terminalPanel(for: panelId) { + readyToken = panel.surface.onRuntimeReady { evaluate() } } @@ -6201,16 +5948,16 @@ class TabManager: ObservableObject { // Layout goal: 2x2 grid (2 top, 2 bottom), then close both right panels. // Order matters: split down first, then split right in each row (matches UI shortcut repro). - guard let bottomLeft = tab.newTerminalSplit(from: topLeftPanelId, orientation: .vertical) else { + guard let bottomLeft = tab.splitTerminalPanel(fromPanelId: topLeftPanelId, orientation: .vertical) else { self.writeSplitCloseRightTestData(["setupError": "Failed to create bottom-left split"], at: path) return } - guard let bottomRight = tab.newTerminalSplit(from: bottomLeft.id, orientation: .horizontal) else { + guard let bottomRight = tab.splitTerminalPanel(fromPanelId: bottomLeft.id, orientation: .horizontal) else { self.writeSplitCloseRightTestData(["setupError": "Failed to create bottom-right split"], at: path) return } tab.focusPanel(topLeftPanelId) - guard let topRight = tab.newTerminalSplit(from: topLeftPanelId, orientation: .horizontal) else { + guard let topRight = tab.splitTerminalPanel(fromPanelId: topLeftPanelId, orientation: .horizontal) else { self.writeSplitCloseRightTestData(["setupError": "Failed to create top-right split"], at: path) return } @@ -6221,7 +5968,7 @@ class TabManager: ObservableObject { "bottomLeftPanelId": bottomLeft.id.uuidString, "topRightPanelId": topRight.id.uuidString, "bottomRightPanelId": bottomRight.id.uuidString, - "createdPaneCount": String(tab.bonsplitController.allPaneIds.count), + "createdPaneCount": String(tab.paneCount), "createdPanelCount": String(tab.panels.count) ], at: path) @@ -6234,11 +5981,11 @@ class TabManager: ObservableObject { tab.closePanel(bottomRight.id, force: true) - // Capture final state after Bonsplit/AppKit/Ghostty geometry reconciliation. + // Capture final state after WorkspaceSplit/AppKit/Ghostty geometry reconciliation. // We avoid sleep-based timing and converge over a few main-actor turns. @MainActor func collectSplitCloseRightState() -> (data: [String: String], settled: Bool) { - let paneIds = tab.bonsplitController.allPaneIds - let bonsplitTabCount = tab.bonsplitController.allTabIds.count + let paneIds = tab.paneIds + let splitTabCount = tab.allSurfaceIds.count let panelCount = tab.panels.count var missingSelectedTabCount = 0 @@ -6249,11 +5996,11 @@ class TabManager: ObservableObject { var selectedTerminalSurfaceNilCount = 0 for paneId in paneIds { - guard let selected = tab.bonsplitController.selectedTab(inPane: paneId) else { + guard let selected = tab.selectedSurfaceId(inPane: paneId) else { missingSelectedTabCount += 1 continue } - guard let panel = tab.panel(for: selected.id) else { + guard let panel = tab.panels[selected] else { missingPanelMappingCount += 1 continue } @@ -6285,7 +6032,7 @@ class TabManager: ObservableObject { return ( data: [ "finalPaneCount": String(paneIds.count), - "finalBonsplitTabCount": String(bonsplitTabCount), + "finalSplitTabCount": String(splitTabCount), "finalPanelCount": String(panelCount), "missingSelectedTabCount": String(missingSelectedTabCount), "missingPanelMappingCount": String(missingPanelMappingCount), @@ -6303,9 +6050,9 @@ class TabManager: ObservableObject { window.contentView?.layoutSubtreeIfNeeded() window.contentView?.displayIfNeeded() } - for paneId in tab.bonsplitController.allPaneIds { - guard let selected = tab.bonsplitController.selectedTab(inPane: paneId), - let terminal = tab.panel(for: selected.id) as? TerminalPanel else { + for paneId in tab.paneIds { + guard let selected = tab.selectedSurfaceId(inPane: paneId), + let terminal = tab.panels[selected] as? TerminalPanel else { continue } terminal.hostedView.reconcileGeometryNow() @@ -6368,22 +6115,22 @@ class TabManager: ObservableObject { switch pattern { case "close_right_single": - guard let tr = tab.newTerminalSplit(from: topLeftId, orientation: .horizontal) else { + guard let tr = tab.splitTerminalPanel(fromPanelId: topLeftId, orientation: .horizontal) else { writeSplitCloseRightTestData(["setupError": "Failed to split right from top-left (iteration \(i))"], at: path) return } topRight = tr case "close_right_lrtd", "close_right_lrtd_bottom_first", "close_right_bottom_first", "close_right_lrtd_unfocused": // User repro: split left/right first, then split top/down in each column. - guard let tr = tab.newTerminalSplit(from: topLeftId, orientation: .horizontal) else { + guard let tr = tab.splitTerminalPanel(fromPanelId: topLeftId, orientation: .horizontal) else { writeSplitCloseRightTestData(["setupError": "Failed to split right from top-left (iteration \(i))"], at: path) return } - guard let bl = tab.newTerminalSplit(from: topLeftId, orientation: .vertical) else { + guard let bl = tab.splitTerminalPanel(fromPanelId: topLeftId, orientation: .vertical) else { writeSplitCloseRightTestData(["setupError": "Failed to split down from left (iteration \(i))"], at: path) return } - guard let br = tab.newTerminalSplit(from: tr.id, orientation: .vertical) else { + guard let br = tab.splitTerminalPanel(fromPanelId: tr.id, orientation: .vertical) else { writeSplitCloseRightTestData(["setupError": "Failed to split down from right (iteration \(i))"], at: path) return } @@ -6392,15 +6139,15 @@ class TabManager: ObservableObject { bottomRight = br default: // Default: split top/down first, then split left/right in each row. - guard let bl = tab.newTerminalSplit(from: topLeftId, orientation: .vertical) else { + guard let bl = tab.splitTerminalPanel(fromPanelId: topLeftId, orientation: .vertical) else { writeSplitCloseRightTestData(["setupError": "Failed to split down from top-left (iteration \(i))"], at: path) return } - guard let br = tab.newTerminalSplit(from: bl.id, orientation: .horizontal) else { + guard let br = tab.splitTerminalPanel(fromPanelId: bl.id, orientation: .horizontal) else { writeSplitCloseRightTestData(["setupError": "Failed to split right from bottom-left (iteration \(i))"], at: path) return } - guard let tr = tab.newTerminalSplit(from: topLeftId, orientation: .horizontal) else { + guard let tr = tab.splitTerminalPanel(fromPanelId: topLeftId, orientation: .horizontal) else { writeSplitCloseRightTestData(["setupError": "Failed to split right from top-left (iteration \(i))"], at: path) return } @@ -6528,16 +6275,16 @@ class TabManager: ObservableObject { ) let paneStateTrace: String = { - tab.bonsplitController.allPaneIds.map { paneId in - let tabs = tab.bonsplitController.tabs(inPane: paneId) - let selected = tab.bonsplitController.selectedTab(inPane: paneId) - let selectedId = selected.map { String(describing: $0.id) } ?? "nil" - let selectedPanelId = selected.flatMap { tab.panelIdFromSurfaceId($0.id) } + tab.paneIds.map { paneId in + let tabs = tab.surfaceIds(inPane: paneId) + let selected = tab.selectedSurfaceId(inPane: paneId) + let selectedId = selected.map { String(describing: $0) } ?? "nil" + let selectedPanelId = selected.flatMap { tab.panels[$0]?.id } let selectedPanelLive: String = { guard let selected else { return "0" } - return tab.panel(for: selected.id) != nil ? "1" : "0" + return tab.panels[selected] != nil ? "1" : "0" }() - let mappedCount = tabs.filter { tab.panelIdFromSurfaceId($0.id) != nil }.count + let mappedCount = tabs.filter { tab.panels[$0] != nil }.count let selectedPanel = selectedPanelId?.uuidString.prefix(8) ?? "nil" return "pane=\(paneId.id.uuidString.prefix(8)):tabs=\(tabs.count):mapped=\(mappedCount):selected=\(selectedId.prefix(8)):selectedPanel=\(selectedPanel):selectedLive=\(selectedPanelLive)" }.joined(separator: ";") @@ -6708,7 +6455,7 @@ class TabManager: ObservableObject { } } - guard let rightPanel = tab.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + guard let rightPanel = tab.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .horizontal) else { write(["setupError": "Failed to create right split at iteration \(i)", "done": "1"]) return } @@ -6838,7 +6585,7 @@ class TabManager: ObservableObject { write(["setupError": "Missing initial focused panel", "done": "1"]) return } - guard let rightPanel = tab.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + guard let rightPanel = tab.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .horizontal) else { write(["setupError": "Failed to create right split", "done": "1"]) return } @@ -6849,17 +6596,17 @@ class TabManager: ObservableObject { var exitPanelId = rightPanel.id if layout == "lr_left_vertical" { - guard let bottomLeft = tab.newTerminalSplit(from: leftPanelId, orientation: .vertical) else { + guard let bottomLeft = tab.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .vertical) else { write(["setupError": "Failed to create bottom-left split", "done": "1"]) return } bottomLeftPanelId = bottomLeft.id.uuidString } else if layout == "lrtd_close_right_then_exit_top_left" { - guard let bottomLeft = tab.newTerminalSplit(from: leftPanelId, orientation: .vertical) else { + guard let bottomLeft = tab.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .vertical) else { write(["setupError": "Failed to create bottom-left split", "done": "1"]) return } - guard let bottomRight = tab.newTerminalSplit(from: rightPanel.id, orientation: .vertical) else { + guard let bottomRight = tab.splitTerminalPanel(fromPanelId: rightPanel.id, orientation: .vertical) else { write(["setupError": "Failed to create bottom-right split", "done": "1"]) return } @@ -6893,15 +6640,15 @@ class TabManager: ObservableObject { // 2) split left/right for each row (2x2) // 3) close both bottom panes // 4) trigger Ctrl+D in top-left - guard let bottomLeft = tab.newTerminalSplit(from: leftPanelId, orientation: .vertical) else { + guard let bottomLeft = tab.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .vertical) else { write(["setupError": "Failed to create bottom-left split", "done": "1"]) return } - guard let topRight = tab.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + guard let topRight = tab.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .horizontal) else { write(["setupError": "Failed to create top-right split", "done": "1"]) return } - guard let bottomRight = tab.newTerminalSplit(from: bottomLeft.id, orientation: .horizontal) else { + guard let bottomRight = tab.splitTerminalPanel(fromPanelId: bottomLeft.id, orientation: .horizontal) else { write(["setupError": "Failed to create bottom-right split", "done": "1"]) return } @@ -7155,14 +6902,13 @@ class TabManager: ObservableObject { } extension TabManager { - func sessionAutosaveFingerprint( - restorableAgentIndex: RestorableAgentSessionIndex = .empty - ) -> Int { + func sessionAutosaveFingerprint() -> Int { var hasher = Hasher() hasher.combine(selectedTabId) hasher.combine(tabs.count) for workspace in tabs.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) { + let surfaceStates = workspace.surfaceStatesSnapshot() hasher.combine(workspace.id) hasher.combine(workspace.focusedPanelId) hasher.combine(workspace.currentDirectory) @@ -7175,24 +6921,11 @@ extension TabManager { hasher.combine(workspace.statusEntries.count) hasher.combine(workspace.metadataBlocks.count) hasher.combine(workspace.logEntries.count) - hasher.combine(workspace.panelDirectories.count) - hasher.combine(workspace.panelTitles.count) - hasher.combine(workspace.panelPullRequests.count) - hasher.combine(workspace.panelGitBranches.count) - hasher.combine(workspace.surfaceListeningPorts.count) - - let panelIds = workspace.panels.keys.sorted { $0.uuidString < $1.uuidString } - hasher.combine(panelIds.count) - for panelId in panelIds { - hasher.combine(panelId) - Self.hashRestorableAgentSnapshot( - restorableAgentIndex.snapshot( - workspaceId: workspace.id, - panelId: panelId - ), - into: &hasher - ) - } + hasher.combine(surfaceStates.values.lazy.filter { $0.directory != nil }.count) + hasher.combine(surfaceStates.values.lazy.filter { $0.title != nil }.count) + hasher.combine(surfaceStates.values.lazy.filter { $0.pullRequest != nil }.count) + hasher.combine(surfaceStates.values.lazy.filter { $0.gitBranch != nil }.count) + hasher.combine(surfaceStates.values.lazy.filter { !$0.listeningPorts.isEmpty }.count) if let progress = workspace.progress { hasher.combine(Int((progress.value * 1000).rounded())) @@ -7213,90 +6946,12 @@ extension TabManager { return hasher.finalize() } - nonisolated static func restorableAgentSnapshotFingerprint( - _ snapshot: SessionRestorableAgentSnapshot? - ) -> Int { - var hasher = Hasher() - hashRestorableAgentSnapshot(snapshot, into: &hasher) - return hasher.finalize() - } - - nonisolated private static func hashRestorableAgentSnapshot( - _ snapshot: SessionRestorableAgentSnapshot?, - into hasher: inout Hasher - ) { - guard let snapshot else { - hasher.combine(false) - return - } - - hasher.combine(true) - hasher.combine(snapshot.kind.rawValue) - hasher.combine(snapshot.sessionId) - hashOptionalString(snapshot.workingDirectory, into: &hasher) - hashAgentLaunchCommand(snapshot.launchCommand, into: &hasher) - } - - nonisolated private static func hashAgentLaunchCommand( - _ launchCommand: AgentLaunchCommandSnapshot?, - into hasher: inout Hasher - ) { - guard let launchCommand else { - hasher.combine(false) - return - } - - hasher.combine(true) - hashOptionalString(launchCommand.launcher, into: &hasher) - hashOptionalString(launchCommand.executablePath, into: &hasher) - hasher.combine(launchCommand.arguments) - hashOptionalString(launchCommand.workingDirectory, into: &hasher) - if let environment = launchCommand.environment { - hasher.combine(true) - hasher.combine(environment.count) - for key in environment.keys.sorted() { - hasher.combine(key) - hasher.combine(environment[key]) - } - } else { - hasher.combine(false) - } - hashOptionalDouble(launchCommand.capturedAt, into: &hasher) - hashOptionalString(launchCommand.source, into: &hasher) - } - - nonisolated private static func hashOptionalString(_ value: String?, into hasher: inout Hasher) { - if let value { - hasher.combine(true) - hasher.combine(value) - } else { - hasher.combine(false) - } - } - - nonisolated private static func hashOptionalDouble(_ value: Double?, into hasher: inout Hasher) { - if let value { - hasher.combine(true) - hasher.combine(value) - } else { - hasher.combine(false) - } - } - - func sessionSnapshot( - includeScrollback: Bool, - restorableAgentIndex: RestorableAgentSessionIndex = .empty - ) -> SessionTabManagerSnapshot { + func sessionSnapshot(includeScrollback: Bool) -> SessionTabManagerSnapshot { let restorableTabs = tabs .filter { !$0.isRemoteWorkspace } .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) let workspaceSnapshots = restorableTabs - .map { - $0.sessionSnapshot( - includeScrollback: includeScrollback, - restorableAgentIndex: restorableAgentIndex - ) - } + .map { $0.sessionSnapshot(includeScrollback: includeScrollback) } let selectedWorkspaceIndex = selectedTabId.flatMap { selectedTabId in restorableTabs.firstIndex(where: { $0.id == selectedTabId }) } @@ -7316,7 +6971,22 @@ extension TabManager { workspace.owningTabManager = nil } + private func releaseClosedWorkspace(_ workspace: Workspace) { + AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) + workspace.teardownAllPanels() + workspace.teardownRemoteConnection() + unwireClosedBrowserTracking(for: workspace) + workspace.owningTabManager = nil + lastFocusedPanelByTab.removeValue(forKey: workspace.id) + } + func restoreSessionSnapshot(_ snapshot: SessionTabManagerSnapshot) { +#if DEBUG + startupLog( + "startup.restore.tabManager.begin workspaces=\(snapshot.workspaces.count) " + + "existingTabs=\(tabs.count)" + ) +#endif let previousTabs = tabs for tab in previousTabs { unwireClosedBrowserTracking(for: tab) @@ -7335,16 +7005,15 @@ extension TabManager { tabHistory.removeAll() historyIndex = -1 isNavigatingHistory = false - pendingWorkspaceUnfocusTarget = nil workspaceCycleCooldownTask?.cancel() workspaceCycleCooldownTask = nil + pendingWorkspaceUnfocusTarget = nil isWorkspaceCycleHot = false selectionSideEffectsGeneration &+= 1 recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20) // Build the new workspace list locally to avoid intermediate @Published - // emissions (empty tabs, nil selectedTabId) that can leave SwiftUI's - // mountedWorkspaceIds empty and cause a frozen blank launch state (#399). + // emissions while restoring the window state. var newTabs: [Workspace] = [] let workspaceSnapshots = snapshot.workspaces .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) @@ -7360,6 +7029,13 @@ extension TabManager { workspace.restoreSessionSnapshot(workspaceSnapshot) wireClosedBrowserTracking(for: workspace) newTabs.append(workspace) +#if DEBUG + startupLog( + "startup.restore.tabManager.workspace workspace=\(workspace.id.uuidString.prefix(5)) " + + "panels=\(workspace.panels.count) panes=\(workspace.paneCount) " + + "focusedPanel=\(workspace.focusedPanelId?.uuidString.prefix(5) ?? "nil")" + ) +#endif } if newTabs.isEmpty { @@ -7407,6 +7083,12 @@ extension TabManager { userInfo: [GhosttyNotificationKey.tabId: selectedTabId] ) } +#if DEBUG + startupLog( + "startup.restore.tabManager.end tabs=\(tabs.count) " + + "selectedWorkspace=\(selectedTabId?.uuidString.prefix(5) ?? "nil")" + ) +#endif } } @@ -7467,6 +7149,7 @@ extension Notification.Name { static let commandPaletteSwitcherRequested = Notification.Name("cmux.commandPaletteSwitcherRequested") static let commandPaletteSubmitRequested = Notification.Name("cmux.commandPaletteSubmitRequested") static let commandPaletteDismissRequested = Notification.Name("cmux.commandPaletteDismissRequested") + static let commandPaletteVisibilityDidChange = Notification.Name("cmux.commandPaletteVisibilityDidChange") static let commandPaletteRenameTabRequested = Notification.Name("cmux.commandPaletteRenameTabRequested") static let commandPaletteRenameWorkspaceRequested = Notification.Name("cmux.commandPaletteRenameWorkspaceRequested") static let commandPaletteEditWorkspaceDescriptionRequested = Notification.Name("cmux.commandPaletteEditWorkspaceDescriptionRequested") @@ -7486,5 +7169,4 @@ extension Notification.Name { static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar") static let webViewDidReceiveClick = Notification.Name("webViewDidReceiveClick") static let terminalPortalVisibilityDidChange = Notification.Name("cmux.terminalPortalVisibilityDidChange") - static let browserPortalRegistryDidChange = Notification.Name("cmux.browserPortalRegistryDidChange") } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index eb1900c5c00d..ac2c847ca929 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1,8 +1,6 @@ import AppKit import Carbon.HIToolbox -import CMUXWorkstream import Foundation -import Bonsplit import WebKit extension Notification.Name { @@ -142,8 +140,7 @@ class TerminalController { "browser.tab.switch", "debug.command_palette.toggle", "debug.notification.focus", - "debug.app.activate", - "feed.jump" + "debug.app.activate" ] private enum V2HandleKind: String, CaseIterable { @@ -1027,7 +1024,7 @@ class TerminalController { guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return } let validSurfaceIds = Set(workspace.panels.keys) guard validSurfaceIds.contains(panelId) else { return } - workspace.surfaceListeningPorts[panelId] = ports.isEmpty ? nil : ports + workspace.setSurfaceListeningPorts(panelId: panelId, ports: ports) workspace.recomputeListeningPorts() } PortScanner.shared.onAgentPortsUpdated = { [weak self] workspaceId, ports in @@ -1651,14 +1648,6 @@ class TerminalController { } } - /// Public entry point mirroring the socket's `processCommand` path so - /// in-process callers (e.g. the Feed coordinator's `feed.jump` focus - /// request) can reuse the full V1/V2 dispatcher without duplicating - /// its auth/policy wrappers. - func handleSocketLine(_ line: String) -> String { - return processCommand(line) - } - private func processCommand(_ command: String) -> String { let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return "ERROR: Empty command" } @@ -1918,11 +1907,11 @@ class TerminalController { case "layout_debug": return layoutDebug() - case "bonsplit_underflow_count": - return bonsplitUnderflowCount() + case "WorkspaceSplit_underflow_count", "bonsplit_underflow_count": + return splitUnderflowCount() - case "reset_bonsplit_underflow_count": - return resetBonsplitUnderflowCount() + case "reset_WorkspaceSplit_underflow_count", "reset_bonsplit_underflow_count": + return resetSplitUnderflowCount() case "empty_panel_count": return emptyPanelCount() @@ -2262,8 +2251,6 @@ class TerminalController { return v2Result(id: id, self.v2WorkspaceRemoteStatus(params: params)) case "workspace.remote.terminal_session_end": return v2Result(id: id, self.v2WorkspaceRemoteTerminalSessionEnd(params: params)) - case "session.restore_previous": - return v2Result(id: id, self.v2SessionRestorePrevious()) // Settings case "settings.open": @@ -2275,20 +2262,6 @@ class TerminalController { case "feedback.submit": return v2Result(id: id, self.v2FeedbackSubmit(params: params)) - // Feed (workstream) - case "feed.push": - return v2Result(id: id, self.v2FeedPush(params: params)) - case "feed.permission.reply": - return v2Result(id: id, self.v2FeedPermissionReply(params: params)) - case "feed.question.reply": - return v2Result(id: id, self.v2FeedQuestionReply(params: params)) - case "feed.exit_plan.reply": - return v2Result(id: id, self.v2FeedExitPlanReply(params: params)) - case "feed.jump": - return v2Result(id: id, self.v2FeedJump(params: params)) - case "feed.list": - return v2Result(id: id, self.v2FeedList(params: params)) - // Surfaces / input case "surface.list": @@ -2592,10 +2565,14 @@ class TerminalController { return v2Result(id: id, self.v2DebugLayout()) case "debug.portal.stats": return v2Result(id: id, self.v2DebugPortalStats()) - case "debug.bonsplit_underflow.count": - return v2Result(id: id, self.v2DebugBonsplitUnderflowCount()) - case "debug.bonsplit_underflow.reset": - return v2Result(id: id, self.v2DebugResetBonsplitUnderflowCount()) + case "debug.WorkspaceSplit_underflow.count", + "debug.bonsplit_underflow.count", + "debug.bonsplit.underflow.count": + return v2Result(id: id, self.v2DebugSplitUnderflowCount()) + case "debug.WorkspaceSplit_underflow.reset", + "debug.bonsplit_underflow.reset", + "debug.bonsplit.underflow.reset": + return v2Result(id: id, self.v2DebugResetSplitUnderflowCount()) case "debug.empty_panel.count": return v2Result(id: id, self.v2DebugEmptyPanelCount()) case "debug.empty_panel.reset": @@ -2660,16 +2637,9 @@ class TerminalController { "workspace.remote.disconnect", "workspace.remote.status", "workspace.remote.terminal_session_end", - "session.restore_previous", "settings.open", "feedback.open", "feedback.submit", - "feed.push", - "feed.permission.reply", - "feed.question.reply", - "feed.exit_plan.reply", - "feed.jump", - "feed.list", "surface.list", "surface.current", "surface.focus", @@ -2816,8 +2786,12 @@ class TerminalController { "debug.terminal.render_stats", "debug.layout", "debug.portal.stats", + "debug.WorkspaceSplit_underflow.count", + "debug.WorkspaceSplit_underflow.reset", "debug.bonsplit_underflow.count", "debug.bonsplit_underflow.reset", + "debug.bonsplit.underflow.count", + "debug.bonsplit.underflow.reset", "debug.empty_panel.count", "debug.empty_panel.reset", "debug.notification.focus", @@ -2852,7 +2826,7 @@ class TerminalController { let windowId = v2ResolveWindowId(tabManager: tabManager) if let wsId = tabManager.selectedTabId, let ws = tabManager.tabs.first(where: { $0.id == wsId }) { - let paneUUID = ws.bonsplitController.focusedPaneId?.id + let paneUUID = ws.focusedPaneId?.id let surfaceUUID = ws.focusedPanelId focused = [ "window_id": v2OrNull(windowId?.uuidString), @@ -2882,8 +2856,9 @@ class TerminalController { let wsId = v2UUIDAny(callerObj["workspace_id"]) { let surfaceId = v2UUIDAny(callerObj["surface_id"]) ?? v2UUIDAny(callerObj["tab_id"]) v2MainSync { - let callerTabManager = AppDelegate.shared?.tabManagerFor(tabId: wsId) ?? tabManager - if let ws = callerTabManager.tabs.first(where: { $0.id == wsId }) { + if let owner = self.resolvedWorkspaceOwner(workspaceId: wsId) { + let callerTabManager = owner.tabManager + let ws = owner.workspace let callerWindowId = v2ResolveWindowId(tabManager: callerTabManager) var payload: [String: Any] = [ "window_id": v2OrNull(callerWindowId?.uuidString), @@ -3039,15 +3014,14 @@ class TerminalController { var indexInPaneByPanelId: [UUID: Int] = [:] var selectedInPaneByPanelId: [UUID: Bool] = [:] - let paneIds = workspace.bonsplitController.allPaneIds + let paneIds = workspace.paneIds for paneId in paneIds { - let tabs = workspace.bonsplitController.tabs(inPane: paneId) - let selectedTab = workspace.bonsplitController.selectedTab(inPane: paneId) - for (tabIndex, tab) in tabs.enumerated() { - guard let panelId = workspace.panelIdFromSurfaceId(tab.id) else { continue } + let surfaceIds = workspace.surfaceIds(inPane: paneId) + let selectedSurfaceId = workspace.selectedSurfaceId(inPane: paneId) + for (tabIndex, panelId) in surfaceIds.enumerated() { paneByPanelId[panelId] = paneId.id indexInPaneByPanelId[panelId] = tabIndex - selectedInPaneByPanelId[panelId] = (tab.id == selectedTab?.id) + selectedInPaneByPanelId[panelId] = (panelId == selectedSurfaceId) } } @@ -3069,7 +3043,7 @@ class TerminalController { "pane_id": v2OrNull(paneUUID?.uuidString), "pane_ref": v2Ref(kind: .pane, uuid: paneUUID), "index_in_pane": v2OrNull(indexInPaneByPanelId[panel.id]), - "tty": v2OrNull(workspace.surfaceTTYNames[panel.id]) + "tty": v2OrNull(workspace.surfaceTTYName(panelId: panel.id)) ] if panel.panelType == .browser, let browserPanel = panel as? BrowserPanel { @@ -3090,12 +3064,10 @@ class TerminalController { } } - let focusedPaneId = workspace.bonsplitController.focusedPaneId + let focusedPaneId = workspace.focusedPaneId let panes: [[String: Any]] = paneIds.enumerated().map { paneIndex, paneId in - let tabs = workspace.bonsplitController.tabs(inPane: paneId) - let surfaceUUIDs: [UUID] = tabs.compactMap { workspace.panelIdFromSurfaceId($0.id) } - let selectedTab = workspace.bonsplitController.selectedTab(inPane: paneId) - let selectedSurfaceUUID = selectedTab.flatMap { workspace.panelIdFromSurfaceId($0.id) } + let surfaceUUIDs = workspace.surfaceIds(inPane: paneId) + let selectedSurfaceUUID = workspace.selectedSurfaceId(inPane: paneId) return [ "id": paneId.id.uuidString, @@ -3123,7 +3095,6 @@ class TerminalController { ] } - // MARK: - V2 Helpers (encoding + result plumbing) // MARK: - V2 Helpers (encoding + result plumbing) private func v2AuthStatusPayload(timedOut: Bool) -> [String: Any] { @@ -3336,7 +3307,7 @@ class TerminalController { if let tm = app.tabManagerFor(windowId: item.windowId) { for ws in tm.tabs { _ = v2EnsureHandleRef(kind: .workspace, uuid: ws.id) - for paneId in ws.bonsplitController.allPaneIds { + for paneId in ws.paneIds { _ = v2EnsureHandleRef(kind: .pane, uuid: paneId.id) } for panelId in ws.panels.keys { @@ -3439,7 +3410,7 @@ class TerminalController { for item in windows { guard let tm = app.tabManagerFor(windowId: item.windowId) else { continue } for ws in tm.tabs { - if let paneId = ws.bonsplitController.allPaneIds.first(where: { $0.id == paneUUID }) { + if let paneId = ws.paneIds.first(where: { $0.id == paneUUID }) { return (item.windowId, tm, ws, paneId) } } @@ -3494,6 +3465,40 @@ class TerminalController { // MARK: - V2 Context Resolution + private func resolvedWorkspaceOwner( + workspaceId: UUID, + preferredTabManager: TabManager? = nil, + allowFallbackOutsidePreferred: Bool = true + ) -> (tabManager: TabManager, workspace: Workspace)? { + if let preferredTabManager, + let workspace = preferredTabManager.tabs.first(where: { $0.id == workspaceId }) { + return (preferredTabManager, workspace) + } + if preferredTabManager != nil, !allowFallbackOutsidePreferred { + return nil + } + if let tabManager, + preferredTabManager !== tabManager, + let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) { + return (tabManager, workspace) + } + if let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), + owner !== preferredTabManager, + owner !== tabManager, + let workspace = owner.tabs.first(where: { $0.id == workspaceId }) { + return (owner, workspace) + } + return nil + } + + private func resolvedTabManager(surfaceId: UUID) -> TabManager? { + if let tabManager, + tabManager.tabs.contains(where: { $0.panels[surfaceId] != nil }) { + return tabManager + } + return AppDelegate.shared?.locateSurface(surfaceId: surfaceId)?.tabManager + } + private func v2ResolveTabManager(params: [String: Any]) -> TabManager? { // Prefer explicit window_id routing. Fall back to global lookup by workspace_id/surface_id/tab_id, // and finally to the active window's TabManager. @@ -3501,12 +3506,12 @@ class TerminalController { return v2MainSync { AppDelegate.shared?.tabManagerFor(windowId: windowId) } } if let wsId = v2UUID(params, "workspace_id") { - if let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(tabId: wsId) }) { + if let tm = v2MainSync({ self.resolvedWorkspaceOwner(workspaceId: wsId)?.tabManager }) { return tm } } if let surfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "tab_id") { - if let tm = v2MainSync({ AppDelegate.shared?.locateSurface(surfaceId: surfaceId)?.tabManager }) { + if let tm = v2MainSync({ self.resolvedTabManager(surfaceId: surfaceId) }) { return tm } } @@ -3847,7 +3852,7 @@ class TerminalController { var result: V2CallResult = .err(code: "internal_error", message: "Failed to move workspace", data: nil) v2MainSync { - guard let srcTM = AppDelegate.shared?.tabManagerFor(tabId: wsId) else { + guard let srcTM = self.resolvedWorkspaceOwner(workspaceId: wsId)?.tabManager else { result = .err(code: "not_found", message: "Workspace not found", data: ["workspace_id": wsId.uuidString]) return } @@ -4039,8 +4044,7 @@ class TerminalController { var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: nil) v2MainSync { guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } - let tree = ws.bonsplitController.treeSnapshot() - let success = v2ProportionalEqualize(node: tree, controller: ws.bonsplitController, orientationFilter: orientationFilter) + let success = ws.equalizeSplits(orientationFilter: orientationFilter) result = .ok([ "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), @@ -4050,46 +4054,6 @@ class TerminalController { return result } - /// Count leaf panes in a tree node. - private func v2CountLeaves(_ node: ExternalTreeNode) -> Int { - switch node { - case .pane: - return 1 - case .split(let s): - return v2CountLeaves(s.first) + v2CountLeaves(s.second) - } - } - - /// Proportionally equalize splits so each leaf pane gets equal space. - /// For a split with N1 leaves on the left and N2 on the right, - /// the divider is set to N1/(N1+N2). - /// When orientationFilter is set (e.g. "vertical"), only splits matching - /// that orientation are equalized. This lets main-vertical layout equalize - /// the agent column without squishing the main pane. - @discardableResult - private func v2ProportionalEqualize( - node: ExternalTreeNode, - controller: BonsplitController, - orientationFilter: String? = nil - ) -> Bool { - guard case .split(let s) = node else { return false } - guard let splitId = UUID(uuidString: s.id) else { return false } - - var didEqualize = false - if orientationFilter == nil || s.orientation == orientationFilter { - let leftLeaves = v2CountLeaves(s.first) - let rightLeaves = v2CountLeaves(s.second) - let total = leftLeaves + rightLeaves - let position = CGFloat(leftLeaves) / CGFloat(total) - controller.setDividerPosition(position, forSplit: splitId, fromExternal: true) - didEqualize = true - } - - let l = v2ProportionalEqualize(node: s.first, controller: controller, orientationFilter: orientationFilter) - let r = v2ProportionalEqualize(node: s.second, controller: controller, orientationFilter: orientationFilter) - return didEqualize || l || r - } - private func v2WorkspaceRemoteConfigure(params: [String: Any]) -> V2CallResult { let requestedWorkspaceId = v2UUID(params, "workspace_id") if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { @@ -4191,7 +4155,7 @@ class TerminalController { } #if DEBUG - cmuxDebugLog( + dlog( "workspace.remote.configure.request workspace=\(workspaceId.uuidString.prefix(8)) " + "target=\(destination) transport=\(transport.rawValue) port=\(sshPort.map(String.init) ?? "nil") " + "autoConnect=\(autoConnect ? 1 : 0) relayPort=\(relayPort.map(String.init) ?? "nil") " + @@ -4206,10 +4170,10 @@ class TerminalController { // Must run on main for v2MainSync because Workspace.configureRemoteConnection mutates TabManager/UI-owned workspace state. v2MainSync { - guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), - let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + guard let owner = self.resolvedWorkspaceOwner(workspaceId: workspaceId) else { return } + let workspace = owner.workspace let config = WorkspaceRemoteConfiguration( transport: transport, @@ -4229,7 +4193,7 @@ class TerminalController { ) workspace.configureRemoteConnection(config, autoConnect: autoConnect) - let windowId = v2ResolveWindowId(tabManager: owner) + let windowId = v2ResolveWindowId(tabManager: owner.tabManager) result = .ok([ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), @@ -4261,13 +4225,13 @@ class TerminalController { // Must run on main for v2MainSync because disconnect mutates TabManager/UI-owned workspace state. v2MainSync { - guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), - let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + guard let owner = self.resolvedWorkspaceOwner(workspaceId: workspaceId) else { return } + let workspace = owner.workspace workspace.disconnectRemoteConnection(clearConfiguration: clearConfiguration) - let windowId = v2ResolveWindowId(tabManager: owner) + let windowId = v2ResolveWindowId(tabManager: owner.tabManager) result = .ok([ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), @@ -4298,10 +4262,10 @@ class TerminalController { // Must run on main for v2MainSync because reconnect mutates TabManager/UI-owned workspace state. v2MainSync { - guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), - let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + guard let owner = self.resolvedWorkspaceOwner(workspaceId: workspaceId) else { return } + let workspace = owner.workspace guard workspace.remoteConfiguration != nil else { result = .err(code: "invalid_state", message: "Remote workspace is not configured", data: [ @@ -4312,7 +4276,7 @@ class TerminalController { } workspace.reconnectRemoteConnection() - let windowId = v2ResolveWindowId(tabManager: owner) + let windowId = v2ResolveWindowId(tabManager: owner.tabManager) result = .ok([ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), @@ -4345,13 +4309,13 @@ class TerminalController { // Must run on main for v2MainSync because this may arm a pending connect or start reconnecting immediately. v2MainSync { - guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), - let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + guard let owner = self.resolvedWorkspaceOwner(workspaceId: workspaceId) else { return } + let workspace = owner.workspace workspace.notifyRemoteForegroundAuthenticationReady(token: foregroundAuthToken) - let windowId = v2ResolveWindowId(tabManager: owner) + let windowId = v2ResolveWindowId(tabManager: owner.tabManager) result = .ok([ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), @@ -4382,11 +4346,11 @@ class TerminalController { // Must run on main for v2MainSync because Workspace.remoteStatusPayload reads TabManager/UI-owned state. v2MainSync { - guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), - let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + guard let owner = self.resolvedWorkspaceOwner(workspaceId: workspaceId) else { return } - let windowId = v2ResolveWindowId(tabManager: owner) + let workspace = owner.workspace + let windowId = v2ResolveWindowId(tabManager: owner.tabManager) result = .ok([ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), @@ -4421,12 +4385,12 @@ class TerminalController { ]) v2MainSync { - guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), - let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + guard let owner = self.resolvedWorkspaceOwner(workspaceId: workspaceId) else { return } + let workspace = owner.workspace workspace.markRemoteTerminalSessionEnded(surfaceId: surfaceId, relayPort: relayPort) - let windowId = v2ResolveWindowId(tabManager: owner) + let windowId = v2ResolveWindowId(tabManager: owner.tabManager) result = .ok([ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), @@ -4504,7 +4468,7 @@ class TerminalController { return } - tab.surfaceTTYNames[surfaceId] = ttyName + tab.setSurfaceTTYName(panelId: surfaceId, ttyName: ttyName) if tab.isRemoteWorkspace { tab.syncRemotePortScanTTYs() _ = tab.applyPendingRemoteSurfacePortKickIfNeeded(to: surfaceId) @@ -4913,15 +4877,14 @@ class TerminalController { @MainActor func insertionIndexToRight(anchorTabId: TabID, inPane paneId: PaneID) -> Int { - let tabs = workspace.bonsplitController.tabs(inPane: paneId) - guard let anchorIndex = tabs.firstIndex(where: { $0.id == anchorTabId }) else { return tabs.count } - let pinnedCount = tabs.reduce(into: 0) { count, tab in - if let panelId = workspace.panelIdFromSurfaceId(tab.id), - workspace.isPanelPinned(panelId) { + let surfaceIds = workspace.surfaceIds(inPane: paneId) + guard let anchorIndex = surfaceIds.firstIndex(of: anchorTabId.id) else { return surfaceIds.count } + let pinnedCount = surfaceIds.reduce(into: 0) { count, panelId in + if workspace.isPanelPinned(panelId) { count += 1 } } - let rawTarget = min(anchorIndex + 1, tabs.count) + let rawTarget = min(anchorIndex + 1, surfaceIds.count) return max(rawTarget, pinnedCount) } @@ -4930,7 +4893,7 @@ class TerminalController { var closed = 0 var skippedPinned = 0 for tabId in tabIds { - guard let panelId = workspace.panelIdFromSurfaceId(tabId) else { continue } + guard let panelId = workspace.panel(for: tabId)?.id else { continue } if workspace.isPanelPinned(panelId) { skippedPinned += 1 continue @@ -4993,7 +4956,7 @@ class TerminalController { } let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) - guard let newPanel = workspace.newBrowserSurface( + guard let newPanel = workspace.createBrowserPanel( inPane: paneId, url: browserPanel.currentURL, focus: true @@ -5017,7 +4980,7 @@ class TerminalController { } let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) - guard let newPanel = workspace.newTerminalSurface(inPane: paneId, focus: true) else { + guard let newPanel = workspace.createTerminalPanel(inPane: paneId, focus: true) else { result = .err(code: "internal_error", message: "Failed to create tab", data: nil) return } @@ -5044,7 +5007,7 @@ class TerminalController { } let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) - guard let newPanel = workspace.newBrowserSurface(inPane: paneId, url: url, focus: true) else { + guard let newPanel = workspace.createBrowserPanel(inPane: paneId, url: url, focus: true) else { result = .err(code: "internal_error", message: "Failed to create tab", data: nil) return } @@ -5062,12 +5025,12 @@ class TerminalController { result = .err(code: "not_found", message: "Tab pane not found", data: nil) return } - let tabs = workspace.bonsplitController.tabs(inPane: paneId) - guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }) else { + let surfaceIds = workspace.surfaceIds(inPane: paneId) + guard let index = surfaceIds.firstIndex(of: anchorTabId.id) else { result = .err(code: "not_found", message: "Tab not found in pane", data: nil) return } - let targetIds = Array(tabs.prefix(index).map(\.id)) + let targetIds = Array(surfaceIds.prefix(index).map(TabID.init(id:))) let closeResult = closeTabs(targetIds) finish(["closed": closeResult.closed, "skipped_pinned": closeResult.skippedPinned]) @@ -5077,12 +5040,14 @@ class TerminalController { result = .err(code: "not_found", message: "Tab pane not found", data: nil) return } - let tabs = workspace.bonsplitController.tabs(inPane: paneId) - guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }) else { + let surfaceIds = workspace.surfaceIds(inPane: paneId) + guard let index = surfaceIds.firstIndex(of: anchorTabId.id) else { result = .err(code: "not_found", message: "Tab not found in pane", data: nil) return } - let targetIds = (index + 1 < tabs.count) ? Array(tabs.suffix(from: index + 1).map(\.id)) : [] + let targetIds = (index + 1 < surfaceIds.count) + ? Array(surfaceIds.suffix(from: index + 1).map(TabID.init(id:))) + : [] let closeResult = closeTabs(targetIds) finish(["closed": closeResult.closed, "skipped_pinned": closeResult.skippedPinned]) @@ -5092,9 +5057,9 @@ class TerminalController { result = .err(code: "not_found", message: "Tab pane not found", data: nil) return } - let targetIds = workspace.bonsplitController.tabs(inPane: paneId) - .map(\.id) - .filter { $0 != anchorTabId } + let targetIds = workspace.surfaceIds(inPane: paneId) + .filter { $0 != anchorTabId.id } + .map(TabID.init(id:)) let closeResult = closeTabs(targetIds) finish(["closed": closeResult.closed, "skipped_pinned": closeResult.skippedPinned]) @@ -5113,10 +5078,21 @@ class TerminalController { private func v2ResolveWorkspace(params: [String: Any], tabManager: TabManager) -> Workspace? { if let wsId = v2UUID(params, "workspace_id") { - return tabManager.tabs.first(where: { $0.id == wsId }) + return resolvedWorkspaceOwner( + workspaceId: wsId, + preferredTabManager: tabManager, + allowFallbackOutsidePreferred: false + )?.workspace } if let surfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "tab_id") { - return tabManager.tabs.first(where: { $0.panels[surfaceId] != nil }) + if let workspace = tabManager.tabs.first(where: { $0.panels[surfaceId] != nil }) { + return workspace + } + if let located = AppDelegate.shared?.locateSurface(surfaceId: surfaceId), + located.tabManager === tabManager { + return located.tabManager.tabs.first(where: { $0.id == located.workspaceId }) + } + return nil } guard let wsId = tabManager.selectedTabId else { return nil } return tabManager.tabs.first(where: { $0.id == wsId }) @@ -5135,14 +5111,13 @@ class TerminalController { var paneByPanelId: [UUID: UUID] = [:] var indexInPaneByPanelId: [UUID: Int] = [:] var selectedInPaneByPanelId: [UUID: Bool] = [:] - for paneId in ws.bonsplitController.allPaneIds { - let tabs = ws.bonsplitController.tabs(inPane: paneId) - let selected = ws.bonsplitController.selectedTab(inPane: paneId) - for (idx, tab) in tabs.enumerated() { - guard let panelId = ws.panelIdFromSurfaceId(tab.id) else { continue } + for paneId in ws.paneIds { + let surfaceIds = ws.surfaceIds(inPane: paneId) + let selected = ws.selectedSurfaceId(inPane: paneId) + for (idx, panelId) in surfaceIds.enumerated() { paneByPanelId[panelId] = paneId.id indexInPaneByPanelId[panelId] = idx - selectedInPaneByPanelId[panelId] = (tab.id == selected?.id) + selectedInPaneByPanelId[panelId] = (panelId == selected) } } @@ -5323,9 +5298,9 @@ class TerminalController { let paneUUID = v2UUID(params, "pane_id") let paneId: PaneID? = { if let paneUUID { - return ws.bonsplitController.allPaneIds.first(where: { $0.id == paneUUID }) + return ws.paneIds.first(where: { $0.id == paneUUID }) } - return ws.bonsplitController.focusedPaneId + return ws.focusedPaneId }() guard let paneId else { @@ -5335,9 +5310,9 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = ws.newBrowserSurface(inPane: paneId, url: url, focus: v2FocusAllowed())?.id + newPanelId = ws.createBrowserPanel(inPane: paneId, url: url, focus: v2FocusAllowed())?.id } else { - newPanelId = ws.newTerminalSurface(inPane: paneId, focus: v2FocusAllowed())?.id + newPanelId = ws.createTerminalPanel(inPane: paneId, focus: v2FocusAllowed())?.id } guard let newPanelId else { @@ -5417,13 +5392,9 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - guard let bonsplitTabId = ws.surfaceIdFromPanelId(surfaceId) else { - result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) - return - } - guard let newPaneId = ws.bonsplitController.splitPane( + guard let newPaneId = ws.splitSurface( + panelId: surfaceId, orientation: orientation, - movingTab: bonsplitTabId, insertFirst: insertFirst ) else { result = .err(code: "internal_error", message: "Failed to split pane", data: nil) @@ -5481,7 +5452,7 @@ class TerminalController { var targetWindowId = source.windowId var targetTabManager = source.tabManager var targetWorkspace = sourceWorkspace - var targetPane = sourcePane ?? sourceWorkspace.bonsplitController.focusedPaneId ?? sourceWorkspace.bonsplitController.allPaneIds.first + var targetPane = sourcePane ?? sourceWorkspace.focusedPaneId ?? sourceWorkspace.paneIds.first var targetIndex = explicitIndex if let anchorSurfaceId = beforeSurfaceId ?? afterSurfaceId { @@ -5515,7 +5486,7 @@ class TerminalController { targetTabManager = tm targetWorkspace = ws targetWindowId = app.windowId(for: tm) ?? targetWindowId - targetPane = ws.bonsplitController.focusedPaneId ?? ws.bonsplitController.allPaneIds.first + targetPane = ws.focusedPaneId ?? ws.paneIds.first } else if let windowUUID = requestedWindowUUID { guard let tm = app.tabManagerFor(windowId: windowUUID) else { result = .err(code: "not_found", message: "Window not found", data: ["window_id": windowUUID.uuidString]) @@ -5529,7 +5500,7 @@ class TerminalController { return } targetWorkspace = ws - targetPane = ws.bonsplitController.focusedPaneId ?? ws.bonsplitController.allPaneIds.first + targetPane = ws.focusedPaneId ?? ws.paneIds.first } guard let destinationPane = targetPane else { @@ -5562,9 +5533,9 @@ class TerminalController { if targetWorkspace.attachDetachedSurface(transfer, inPane: destinationPane, atIndex: targetIndex, focus: focus) == nil { // Roll back to source workspace if attach fails. - let rollbackPane = sourcePane.flatMap { sp in sourceWorkspace.bonsplitController.allPaneIds.first(where: { $0 == sp }) } - ?? sourceWorkspace.bonsplitController.focusedPaneId - ?? sourceWorkspace.bonsplitController.allPaneIds.first + let rollbackPane = sourcePane.flatMap { sp in sourceWorkspace.paneIds.first(where: { $0 == sp }) } + ?? sourceWorkspace.focusedPaneId + ?? sourceWorkspace.paneIds.first if let rollbackPane { _ = sourceWorkspace.attachDetachedSurface(transfer, inPane: rollbackPane, atIndex: sourceIndex, focus: focus) } @@ -5740,7 +5711,7 @@ class TerminalController { let paneIndex: Int? let surfaceIndex: Int let selectedInPane: Bool? - let bonsplitTabId: TabID? + let splitTabId: TabID? } func nonEmpty(_ raw: String?) -> String? { @@ -5830,16 +5801,15 @@ class TerminalController { let tabManager = state.tabManager for (workspaceIndex, workspace) in tabManager.tabs.enumerated() { let paneIndexById = Dictionary( - uniqueKeysWithValues: workspace.bonsplitController.allPaneIds.enumerated().map { + uniqueKeysWithValues: workspace.paneIds.enumerated().map { ($0.element.id, $0.offset) } ) var selectedInPaneByPanelId: [UUID: Bool] = [:] - for paneId in workspace.bonsplitController.allPaneIds { - let selectedTab = workspace.bonsplitController.selectedTab(inPane: paneId) - for tab in workspace.bonsplitController.tabs(inPane: paneId) { - guard let panelId = workspace.panelIdFromSurfaceId(tab.id) else { continue } - selectedInPaneByPanelId[panelId] = (tab.id == selectedTab?.id) + for paneId in workspace.paneIds { + let selectedSurfaceId = workspace.selectedSurfaceId(inPane: paneId) + for panelId in workspace.surfaceIds(inPane: paneId) { + selectedInPaneByPanelId[panelId] = (panelId == selectedSurfaceId) } } @@ -5857,7 +5827,7 @@ class TerminalController { paneIndex: workspace.paneId(forPanelId: terminalPanel.id).flatMap { paneIndexById[$0.id] }, surfaceIndex: surfaceIndex, selectedInPane: selectedInPaneByPanelId[terminalPanel.id], - bonsplitTabId: workspace.surfaceIdFromPanelId(terminalPanel.id) + splitTabId: workspace.surfaceIdFromPanelId(terminalPanel.id) ) } } @@ -5875,13 +5845,14 @@ class TerminalController { let panelId = mapped?.terminalPanel.id ?? terminalSurface.id let portalState = hostedView.portalBindingGuardState() let portalHostLease = terminalSurface.debugPortalHostLease() - let gitBranchState = workspace?.panelGitBranches[panelId] - let listeningPorts = (workspace?.surfaceListeningPorts[panelId] ?? []).sorted() + let surfaceState = workspace?.surfaceStateSnapshot(panelId: panelId) + let gitBranchState = surfaceState?.gitBranch + let listeningPorts = (surfaceState?.listeningPorts ?? []).sorted() let title = workspace?.panelTitle(panelId: panelId) let paneId = mapped?.paneId - let treeVisible = mapped?.bonsplitTabId != nil && paneId != nil - let ttyName = workspace?.surfaceTTYNames[panelId] - let currentDirectory = nonEmpty(workspace?.panelDirectories[panelId] ?? mapped?.terminalPanel.directory) + let treeVisible = mapped?.splitTabId != nil && paneId != nil + let ttyName = workspace?.surfaceTTYName(panelId: panelId) + let currentDirectory = nonEmpty(workspace?.panelDirectory(panelId: panelId) ?? mapped?.terminalPanel.directory) let teardownRequest = terminalSurface.debugTeardownRequest() let lastKnownWorkspaceId = terminalSurface.debugLastKnownWorkspaceId() @@ -5925,7 +5896,10 @@ class TerminalController { "surface_age_seconds": v2OrNull(ageSeconds(since: terminalSurface.debugCreatedAt())), "runtime_surface_created_at": v2OrNull(iso8601String(terminalSurface.debugRuntimeSurfaceCreatedAt())), "runtime_surface_age_seconds": v2OrNull(ageSeconds(since: terminalSurface.debugRuntimeSurfaceCreatedAt())), - "bonsplit_tab_id": v2OrNull(mapped?.bonsplitTabId?.uuid.uuidString), + "WorkspaceSplit_tab_id": v2OrNull(mapped?.splitTabId?.uuid.uuidString), + "workspacesplit_tab_id": v2OrNull(mapped?.splitTabId?.uuid.uuidString), + // Backward-compatible alias for existing CLI tooling. + "bonsplit_tab_id": v2OrNull(mapped?.splitTabId?.uuid.uuidString), "terminal_object_ptr": objectPointerString(terminalSurface), "ghostty_surface_ptr": ghosttyPointerString(terminalSurface.surface), "runtime_surface_ready": terminalSurface.surface != nil, @@ -6032,7 +6006,7 @@ class TerminalController { } #if DEBUG let sendMs = (ProcessInfo.processInfo.systemUptime - sendStart) * 1000.0 - cmuxDebugLog( + dlog( "socket.surface.send_text workspace=\(ws.id.uuidString.prefix(8)) surface=\(surfaceId.uuidString.prefix(8)) queued=\(queued ? 1 : 0) chars=\(text.count) ms=\(String(format: "%.2f", sendMs))" ) #endif @@ -6470,18 +6444,16 @@ class TerminalController { v2MainSync { guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } - let focusedPaneId = ws.bonsplitController.focusedPaneId - let snapshot = ws.bonsplitController.layoutSnapshot() + let focusedPaneId = ws.focusedPaneId + let snapshot = ws.layoutSnapshot() let geometryByPaneId = Dictionary( snapshot.panes.map { ($0.paneId, $0.frame) }, uniquingKeysWith: { first, _ in first } ) - let panes: [[String: Any]] = ws.bonsplitController.allPaneIds.enumerated().map { index, paneId in - let tabs = ws.bonsplitController.tabs(inPane: paneId) - let surfaceUUIDs: [UUID] = tabs.compactMap { ws.panelIdFromSurfaceId($0.id) } - let selectedTab = ws.bonsplitController.selectedTab(inPane: paneId) - let selectedSurfaceUUID = selectedTab.flatMap { ws.panelIdFromSurfaceId($0.id) } + let panes: [[String: Any]] = ws.paneIds.enumerated().map { index, paneId in + let surfaceUUIDs = ws.surfaceIds(inPane: paneId) + let selectedSurfaceUUID = ws.selectedSurfaceId(inPane: paneId) var dict: [String: Any] = [ "id": paneId.id.uuidString, @@ -6553,7 +6525,7 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - guard let paneId = ws.bonsplitController.allPaneIds.first(where: { $0.id == paneUUID }) else { + guard let paneId = ws.paneIds.first(where: { $0.id == paneUUID }) else { result = .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString]) return } @@ -6564,7 +6536,7 @@ class TerminalController { if tabManager.selectedTabId != ws.id { tabManager.selectWorkspace(ws) } - ws.bonsplitController.focusPane(paneId) + _ = ws.focusPane(paneId) let windowId = v2ResolveWindowId(tabManager: tabManager) result = .ok(["window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "pane_id": paneId.id.uuidString, "pane_ref": v2Ref(kind: .pane, uuid: paneId.id)]) } @@ -6583,25 +6555,24 @@ class TerminalController { let paneUUID = v2UUID(params, "pane_id") let paneId: PaneID? = { if let paneUUID { - return ws.bonsplitController.allPaneIds.first(where: { $0.id == paneUUID }) + return ws.paneIds.first(where: { $0.id == paneUUID }) } - return ws.bonsplitController.focusedPaneId + return ws.focusedPaneId }() guard let paneId else { return } - let selectedTab = ws.bonsplitController.selectedTab(inPane: paneId) - let tabs = ws.bonsplitController.tabs(inPane: paneId) + let selectedSurfaceId = ws.selectedSurfaceId(inPane: paneId) + let surfaceIds = ws.surfaceIds(inPane: paneId) - let surfaces: [[String: Any]] = tabs.enumerated().map { index, tab in - let panelId = ws.panelIdFromSurfaceId(tab.id) - let panel = panelId.flatMap { ws.panels[$0] } + let surfaces: [[String: Any]] = surfaceIds.enumerated().map { index, panelId in + let panel = ws.panels[panelId] return [ - "id": v2OrNull(panelId?.uuidString), + "id": panelId.uuidString, "ref": v2Ref(kind: .surface, uuid: panelId), "index": index, - "title": tab.title, + "title": ws.panelTitle(panelId: panelId) ?? panel?.displayTitle ?? "Tab", "type": v2OrNull(panel?.panelType.rawValue), - "selected": tab.id == selectedTab?.id + "selected": panelId == selectedSurfaceId ] } @@ -6653,16 +6624,16 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = ws.newBrowserSplit( - from: focusedPanelId, + newPanelId = ws.splitBrowserPanel( + fromPanelId: focusedPanelId, orientation: orientation, insertFirst: insertFirst, url: url, focus: v2FocusAllowed() )?.id } else { - newPanelId = ws.newTerminalSplit( - from: focusedPanelId, + newPanelId = ws.splitTerminalPanel( + fromPanelId: focusedPanelId, orientation: orientation, insertFirst: insertFirst, focus: v2FocusAllowed() @@ -6802,17 +6773,17 @@ class TerminalController { return } - let paneUUID = v2UUID(params, "pane_id") ?? ws.bonsplitController.focusedPaneId?.id + let paneUUID = v2UUID(params, "pane_id") ?? ws.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 { + guard ws.paneIds.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() + let tree = ws.treeSnapshot() var candidates: [V2PaneResizeCandidate] = [] let trace = v2PaneResizeCollectCandidates( node: tree, @@ -6846,7 +6817,7 @@ class TerminalController { 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 { + guard ws.setDividerPosition(clamped, forSplit: candidate.splitId, fromExternal: true) else { result = .err( code: "internal_error", message: "Failed to set split divider position", @@ -6891,17 +6862,15 @@ class TerminalController { result = .err(code: "not_found", message: "Source pane not found", data: ["pane_id": sourcePaneUUID.uuidString]) return } - guard let targetPane = located.workspace.bonsplitController.allPaneIds.first(where: { $0.id == targetPaneUUID }) else { + guard let targetPane = located.workspace.paneIds.first(where: { $0.id == targetPaneUUID }) else { result = .err(code: "not_found", message: "Target pane not found in source workspace", data: ["target_pane_id": targetPaneUUID.uuidString]) return } let workspace = located.workspace let sourcePane = located.paneId - guard let selectedSourceTab = workspace.bonsplitController.selectedTab(inPane: sourcePane), - let selectedTargetTab = workspace.bonsplitController.selectedTab(inPane: targetPane), - let sourceSurfaceId = workspace.panelIdFromSurfaceId(selectedSourceTab.id), - let targetSurfaceId = workspace.panelIdFromSurfaceId(selectedTargetTab.id) else { + guard let sourceSurfaceId = workspace.selectedSurfaceId(inPane: sourcePane), + let targetSurfaceId = workspace.selectedSurfaceId(inPane: targetPane) else { result = .err(code: "invalid_state", message: "Both panes must have a selected surface", data: nil) return } @@ -6909,15 +6878,15 @@ class TerminalController { // Keep pane identities stable during swap when one side has a single surface. var sourcePlaceholder: UUID? var targetPlaceholder: UUID? - if workspace.bonsplitController.tabs(inPane: sourcePane).count <= 1 { - sourcePlaceholder = workspace.newTerminalSurface(inPane: sourcePane, focus: false)?.id + if workspace.surfaceIds(inPane: sourcePane).count <= 1 { + sourcePlaceholder = workspace.createTerminalPanel(inPane: sourcePane, focus: false)?.id if sourcePlaceholder == nil { result = .err(code: "internal_error", message: "Failed to create source placeholder surface", data: nil) return } } - if workspace.bonsplitController.tabs(inPane: targetPane).count <= 1 { - targetPlaceholder = workspace.newTerminalSurface(inPane: targetPane, focus: false)?.id + if workspace.surfaceIds(inPane: targetPane).count <= 1 { + targetPlaceholder = workspace.createTerminalPanel(inPane: targetPane, focus: false)?.id if targetPlaceholder == nil { result = .err(code: "internal_error", message: "Failed to create target placeholder surface", data: nil) return @@ -6941,7 +6910,7 @@ class TerminalController { } if focus { - workspace.bonsplitController.focusPane(targetPane) + _ = workspace.focusPane(targetPane) } let windowId = located.windowId result = .ok([ @@ -6978,16 +6947,15 @@ class TerminalController { let sourcePaneUUID = v2UUID(params, "pane_id") let sourcePane: PaneID? = { if let sourcePaneUUID { - return sourceWorkspace.bonsplitController.allPaneIds.first(where: { $0.id == sourcePaneUUID }) + return sourceWorkspace.paneIds.first(where: { $0.id == sourcePaneUUID }) } - return sourceWorkspace.bonsplitController.focusedPaneId + return sourceWorkspace.focusedPaneId }() let surfaceId: UUID? = { if let explicitSurface = v2UUID(params, "surface_id") { return explicitSurface } - if let sourcePane, - let selected = sourceWorkspace.bonsplitController.selectedTab(inPane: sourcePane) { - return sourceWorkspace.panelIdFromSurfaceId(selected.id) + if let sourcePane { + return sourceWorkspace.selectedSurfaceId(inPane: sourcePane) } return sourceWorkspace.focusedPanelId }() @@ -7008,8 +6976,8 @@ class TerminalController { } let destinationWorkspace = tabManager.addWorkspace(select: focus) - guard let destinationPane = destinationWorkspace.bonsplitController.focusedPaneId - ?? destinationWorkspace.bonsplitController.allPaneIds.first else { + guard let destinationPane = destinationWorkspace.focusedPaneId + ?? destinationWorkspace.paneIds.first else { if let sourcePaneForRollback { _ = sourceWorkspace.attachDetachedSurface( detached, @@ -7057,8 +7025,7 @@ class TerminalController { var surfaceId = v2UUID(params, "surface_id") if surfaceId == nil, let sourcePaneUUID = v2UUID(params, "pane_id") { guard let sourceLocated = v2LocatePane(sourcePaneUUID), - let selected = sourceLocated.workspace.bonsplitController.selectedTab(inPane: sourceLocated.paneId), - let selectedSurface = sourceLocated.workspace.panelIdFromSurfaceId(selected.id) else { + let selectedSurface = sourceLocated.workspace.selectedSurfaceId(inPane: sourceLocated.paneId) else { return .err(code: "not_found", message: "Unable to resolve selected surface in source pane", data: [ "pane_id": sourcePaneUUID.uuidString ]) @@ -7090,17 +7057,17 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - guard let focused = ws.bonsplitController.focusedPaneId else { + guard let focused = ws.focusedPaneId else { result = .err(code: "not_found", message: "No focused pane", data: nil) return } - guard let target = ws.bonsplitController.allPaneIds.first(where: { $0.id != focused.id }) else { + guard let target = ws.paneIds.first(where: { $0.id != focused.id }) else { result = .err(code: "not_found", message: "No alternate pane available", data: nil) return } - ws.bonsplitController.focusPane(target) - let selectedSurfaceId = ws.bonsplitController.selectedTab(inPane: target).flatMap { ws.panelIdFromSurfaceId($0.id) } + _ = ws.focusPane(target) + let selectedSurfaceId = ws.selectedSurfaceId(inPane: target) let windowId = v2ResolveWindowId(tabManager: tabManager) result = .ok([ "window_id": v2OrNull(windowId?.uuidString), @@ -7279,24 +7246,6 @@ class TerminalController { return .ok(["opened": true]) } - private func v2SessionRestorePrevious() -> V2CallResult { - var restored = false - v2MainSync { - restored = AppDelegate.shared?.reopenPreviousSession(shouldActivate: false) ?? false - } - guard restored else { - return .err( - code: "not_found", - message: String( - localized: "terminal.restore.no_snapshot", - defaultValue: "No previous session snapshot available" - ), - data: nil - ) - } - return .ok(["restored": true]) - } - private func v2SettingsOpen(params: [String: Any]) -> V2CallResult { let targetRaw = v2String(params, "target") let shouldActivate = v2FocusAllowed(requested: v2Bool(params, "activate") ?? true) @@ -7372,170 +7321,6 @@ class TerminalController { return result } - // MARK: - V2 Feed (workstream) handlers - - private func v2FeedPush(params: [String: Any]) -> V2CallResult { - let waitTimeout: TimeInterval - if let rawTimeout = params["wait_timeout_seconds"] { - let seconds: Double? - if let number = rawTimeout as? NSNumber { - seconds = number.doubleValue - } else if let value = rawTimeout as? Double { - seconds = value - } else if let value = rawTimeout as? Int { - seconds = Double(value) - } else { - seconds = nil - } - guard let seconds else { - return .err( - code: "invalid_params", - message: "feed.push wait_timeout_seconds must be numeric", - data: nil - ) - } - guard seconds.isFinite, seconds >= 0, seconds <= 120 else { - return .err( - code: "invalid_params", - message: "feed.push wait_timeout_seconds must be between 0 and 120", - data: nil - ) - } - waitTimeout = seconds - } else { - waitTimeout = 0 - } - let eventDict: [String: Any] - if let nested = params["event"] as? [String: Any] { - eventDict = nested - } else if params["session_id"] != nil, - params["hook_event_name"] != nil, - params["_source"] != nil { - eventDict = params - } else { - return .err( - code: "invalid_params", - message: "feed.push requires an `event` object", - data: nil - ) - } - - let event: WorkstreamEvent - do { - let data = try JSONSerialization.data(withJSONObject: eventDict) - event = try JSONDecoder().decode(WorkstreamEvent.self, from: data) - } catch { - return .err( - code: "invalid_params", - message: "feed.push event failed to decode: \(error)", - data: nil - ) - } - - let result = FeedCoordinator.shared.ingestBlocking( - event: event, - waitTimeout: waitTimeout - ) - return .ok(FeedSocketEncoding.payload(for: result)) - } - - private func v2FeedPermissionReply(params: [String: Any]) -> V2CallResult { - guard let requestId = params["request_id"] as? String else { - return .err( - code: "invalid_params", - message: "feed.permission.reply requires request_id", - data: nil - ) - } - guard let modeRaw = params["mode"] as? String, - let mode = WorkstreamPermissionMode(rawValue: modeRaw) - else { - return .err( - code: "invalid_params", - message: "feed.permission.reply requires mode ∈ once|always|all|bypass|deny", - data: nil - ) - } - FeedCoordinator.shared.deliverReply( - requestId: requestId, - decision: .permission(mode) - ) - return .ok(["delivered": true]) - } - - private func v2FeedQuestionReply(params: [String: Any]) -> V2CallResult { - guard let requestId = params["request_id"] as? String else { - return .err( - code: "invalid_params", - message: "feed.question.reply requires request_id", - data: nil - ) - } - guard let selections = params["selections"] as? [String] else { - return .err( - code: "invalid_params", - message: "feed.question.reply requires selections: [string]", - data: nil - ) - } - FeedCoordinator.shared.deliverReply( - requestId: requestId, - decision: .question(selections: selections) - ) - return .ok(["delivered": true]) - } - - private func v2FeedExitPlanReply(params: [String: Any]) -> V2CallResult { - guard let requestId = params["request_id"] as? String else { - return .err( - code: "invalid_params", - message: "feed.exit_plan.reply requires request_id", - data: nil - ) - } - guard let modeRaw = params["mode"] as? String, - let mode = WorkstreamExitPlanMode(rawValue: modeRaw) - else { - return .err( - code: "invalid_params", - message: "feed.exit_plan.reply requires mode ∈ ultraplan|bypassPermissions|autoAccept|manual|deny", - data: nil - ) - } - let feedback = params["feedback"] as? String - FeedCoordinator.shared.deliverReply( - requestId: requestId, - decision: .exitPlan(mode, feedback: feedback) - ) - return .ok(["delivered": true]) - } - - private func v2FeedJump(params: [String: Any]) -> V2CallResult { - guard let workstreamId = params["workstream_id"] as? String else { - return .err( - code: "invalid_params", - message: "feed.jump requires workstream_id", - data: nil - ) - } - // MVP: resolve to a cmux surface via `SessionIndexStore` lands in - // the UI PR; for now we return whether the id is known so callers - // can show a toast. - let matched = FeedCoordinator.shared.resolvePossibleSurface(for: workstreamId) - return .ok([ - "workstream_id": workstreamId, - "matched": matched - ]) - } - - private func v2FeedList(params: [String: Any]) -> V2CallResult { - let pendingOnly = (params["pending_only"] as? Bool) ?? false - let items = FeedCoordinator.shared.snapshot(pendingOnly: pendingOnly) - return .ok([ - "items": items.map { FeedSocketEncoding.itemDict($0) } - ]) - } - // MARK: - V2 App Focus Methods private func v2AppFocusOverride(params: [String: Any]) -> V2CallResult { @@ -8158,8 +7943,8 @@ class TerminalController { let orientation: SplitOrientation = direction.isHorizontal ? .horizontal : .vertical let insertFirst = (direction == .left || direction == .up) - let createdPanel = ws.newMarkdownSplit( - from: sourceSurfaceId, + let createdPanel = ws.splitMarkdownPanel( + fromPanelId: sourceSurfaceId, orientation: orientation, insertFirst: insertFirst, filePath: filePath, @@ -8257,11 +8042,11 @@ class TerminalController { var placementStrategy = "split_right" let createdPanel: BrowserPanel? if let targetPane = ws.preferredBrowserTargetPane(fromPanelId: sourceSurfaceId) { - createdPanel = ws.newBrowserSurface(inPane: targetPane, url: url, focus: true) + createdPanel = ws.createBrowserPanel(inPane: targetPane, url: url, focus: true) createdSplit = false placementStrategy = "reuse_right_sibling" } else { - createdPanel = ws.newBrowserSplit(from: sourceSurfaceId, orientation: .horizontal, url: url) + createdPanel = ws.splitBrowserPanel(fromPanelId: sourceSurfaceId, orientation: .horizontal, url: url) } guard let browserPanelId = createdPanel?.id else { @@ -9628,11 +9413,8 @@ class TerminalController { tabManager.selectWorkspace(ws) } - // Prevent omnibar auto-focus from immediately stealing first responder back. - browserPanel.suppressOmnibarAutofocus(for: 1.0) - let webView = browserPanel.webView - guard let window = webView.window else { + guard webView.window != nil else { result = .err(code: "invalid_state", message: "WebView is not in a window", data: nil) return } @@ -9641,8 +9423,7 @@ class TerminalController { return } - window.makeFirstResponder(webView) - if let fr = window.firstResponder as? NSView, fr.isDescendant(of: webView) { + if browserPanel.requestExplicitWebViewFocus() { result = .ok(["focused": true]) } else { result = .err(code: "internal_error", message: "Focus did not move into web view", data: nil) @@ -10766,14 +10547,14 @@ class TerminalController { ?? v2UUID(params, "target_pane_id") ?? (v2UUID(params, "surface_id").flatMap { ws.paneId(forPanelId: $0)?.id }) ?? ws.paneId(forPanelId: ws.focusedPanelId ?? UUID())?.id - ?? ws.bonsplitController.focusedPaneId?.id + ?? ws.focusedPaneId?.id guard let paneUUID, - let pane = ws.bonsplitController.allPaneIds.first(where: { $0.id == paneUUID }) else { + let pane = ws.paneIds.first(where: { $0.id == paneUUID }) else { result = .err(code: "not_found", message: "Target pane not found", data: nil) return } - guard let panel = ws.newBrowserSurface(inPane: pane, url: url, focus: true) else { + guard let panel = ws.createBrowserPanel(inPane: pane, url: url, focus: true) else { result = .err(code: "internal_error", message: "Failed to create browser tab", data: nil) return } @@ -11669,15 +11450,15 @@ class TerminalController { return .ok(payload) } - private func v2DebugBonsplitUnderflowCount() -> V2CallResult { - let resp = bonsplitUnderflowCount() + private func v2DebugSplitUnderflowCount() -> V2CallResult { + let resp = splitUnderflowCount() guard resp.hasPrefix("OK ") else { return .err(code: "internal_error", message: resp, data: nil) } let n = Int(resp.split(separator: " ").last ?? "0") ?? 0 return .ok(["count": n]) } - private func v2DebugResetBonsplitUnderflowCount() -> V2CallResult { - let resp = resetBonsplitUnderflowCount() + private func v2DebugResetSplitUnderflowCount() -> V2CallResult { + let resp = resetSplitUnderflowCount() return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil) } @@ -11995,9 +11776,9 @@ class TerminalController { is_terminal_focused - Return true/false if terminal surface is first responder (test-only) read_terminal_text [id|idx] - Read visible terminal text (base64, test-only) render_stats [id|idx] - Read terminal render stats (draw counters, test-only) - layout_debug - Dump bonsplit layout + selected panel bounds (test-only) - bonsplit_underflow_count - Count bonsplit arranged-subview underflow events (test-only) - reset_bonsplit_underflow_count - Reset bonsplit underflow counter (test-only) + layout_debug - Dump WorkspaceSplit layout + selected panel bounds (test-only) + WorkspaceSplit_underflow_count - Count WorkspaceSplit arranged-subview underflow events (test-only) + reset_WorkspaceSplit_underflow_count - Reset WorkspaceSplit underflow counter (test-only) empty_panel_count - Count EmptyPanelView appearances (test-only) reset_empty_panel_count - Reset EmptyPanelView appearance count (test-only) """ @@ -12480,7 +12261,7 @@ class TerminalController { case "fileurl", "file-url", "public.file-url": return .fileURL case "tabtransfer", "tab-transfer", "com.splittabbar.tabtransfer": - return DragOverlayRoutingPolicy.bonsplitTabTransferType + return DragOverlayRoutingPolicy.splitTabTransferType case "sidebarreorder", "sidebar-reorder", "sidebar_tab_reorder", "com.cmux.sidebar-tab-reorder": return DragOverlayRoutingPolicy.sidebarTabReorderType @@ -12535,7 +12316,7 @@ class TerminalController { /// Return the hit-test chain at normalized (0-1) coordinates in the main window's /// content area. Used by regression tests to detect root-level drag destinations - /// shadowing pane-local Bonsplit drop targets. + /// shadowing pane-local WorkspaceSplit drop targets. private func dragHitChain(_ args: String) -> String { let parts = args.split(separator: " ").map(String.init) guard parts.count == 2, @@ -12818,6 +12599,10 @@ class TerminalController { storedKey = "\r" keyCode = UInt16(kVK_Return) charactersIgnoringModifiers = storedKey + case "space": + storedKey = " " + keyCode = UInt16(kVK_Space) + charactersIgnoringModifiers = storedKey default: let key = keyToken.lowercased() guard let code = keyCodeForShortcutKey(key) else { return nil } @@ -12978,7 +12763,7 @@ class TerminalController { var ok = false let focus = socketCommandAllowsInAppFocusMutations() v2MainSync { - guard let srcTM = AppDelegate.shared?.tabManagerFor(tabId: wsId), + guard let srcTM = self.resolvedWorkspaceOwner(workspaceId: wsId)?.tabManager, let dstTM = AppDelegate.shared?.tabManagerFor(windowId: windowId), let ws = srcTM.detachWorkspace(tabId: wsId) else { ok = false @@ -13580,7 +13365,7 @@ class TerminalController { return } - let layout = tab.bonsplitController.layoutSnapshot() + let layout = tab.layoutSnapshot() var paneFrames: [String: PixelRect] = [:] for pane in layout.panes { paneFrames[pane.paneId] = pane.frame @@ -13612,7 +13397,7 @@ class TerminalController { var depth = 0 while let v = current, depth < 12 { if let sv = v as? NSSplitView { - // The split view can be mid-update during bonsplit structural changes; force a layout + // The split view can be mid-update during WorkspaceSplit structural changes; force a layout // pass so our debug snapshot reflects the real state. sv.layoutSubtreeIfNeeded() let isVertical = sv.isVertical @@ -13646,15 +13431,16 @@ class TerminalController { return infos } - let selectedPanels: [LayoutDebugSelectedPanel] = tab.bonsplitController.allPaneIds.map { paneId in + let selectedPanels: [LayoutDebugSelectedPanel] = tab.paneIds.map { paneId in let paneIdStr = paneId.id.uuidString let paneFrame = paneFrames[paneIdStr] let selectedTabId = layout.panes.first(where: { $0.paneId == paneIdStr })?.selectedTabId - guard let selectedTab = tab.bonsplitController.selectedTab(inPane: paneId) else { - return LayoutDebugSelectedPanel( - paneId: paneIdStr, - paneFrame: paneFrame, + guard let panelId = tab.selectedSurfaceId(inPane: paneId), + let panel = tab.panels[panelId] else { + return LayoutDebugSelectedPanel( + paneId: paneIdStr, + paneFrame: paneFrame, selectedTabId: selectedTabId, panelId: nil, panelType: nil, @@ -13663,22 +13449,7 @@ class TerminalController { viewFrame: nil, splitViews: nil ) - } - - guard let panelId = tab.panelIdFromSurfaceId(selectedTab.id), - let panel = tab.panels[panelId] else { - return LayoutDebugSelectedPanel( - paneId: paneIdStr, - paneFrame: paneFrame, - selectedTabId: selectedTabId, - panelId: nil, - panelType: nil, - inWindow: nil, - hidden: nil, - viewFrame: nil, - splitViews: nil - ) - } + } if let tp = panel as? TerminalPanel { let viewRect = windowFrame(for: tp.hostedView).map { PixelRect(from: $0) } @@ -13759,11 +13530,11 @@ class TerminalController { return "OK" } - private func bonsplitUnderflowCount() -> String { + private func splitUnderflowCount() -> String { var result = "OK 0" DispatchQueue.main.sync { #if DEBUG - result = "OK \(BonsplitDebugCounters.arrangedSubviewUnderflowCount)" + result = "OK \(WorkspaceLayoutDebugCounters.arrangedSubviewUnderflowCount)" #else result = "OK 0" #endif @@ -13771,10 +13542,10 @@ class TerminalController { return result } - private func resetBonsplitUnderflowCount() -> String { + private func resetSplitUnderflowCount() -> String { DispatchQueue.main.sync { #if DEBUG - BonsplitDebugCounters.reset() + WorkspaceLayoutDebugCounters.reset() #endif } return "OK" @@ -13878,16 +13649,15 @@ class TerminalController { } private func orderedPanels(in tab: Workspace) -> [any Panel] { - // Use bonsplit's tab ordering as the source of truth. This avoids relying on + // Use WorkspaceSplit's tab ordering as the source of truth. This avoids relying on // Dictionary iteration order, and prevents indexing into panels that aren't - // actually present in bonsplit anymore. - let orderedTabIds = tab.bonsplitController.allTabIds + // actually present in WorkspaceSplit anymore. + let orderedSurfaceIds = tab.allSurfaceIds var result: [any Panel] = [] var seen = Set() - for tabId in orderedTabIds { - guard let panelId = tab.panelIdFromSurfaceId(tabId), - let panel = tab.panels[panelId] else { continue } + for panelId in orderedSurfaceIds { + guard let panel = tab.panels[panelId] else { continue } result.append(panel) seen.insert(panelId) } @@ -13931,23 +13701,18 @@ class TerminalController { let terminalSurface = terminalPanel.surface terminalSurface.requestBackgroundSurfaceStartIfNeeded() _ = v2AwaitCallback(timeout: timeout) { finish in - var readyObserver: NSObjectProtocol? var hostedViewObserver: NSObjectProtocol? + var readyToken: UUID? let finishOnce: () -> Void = { - if let readyObserver { - NotificationCenter.default.removeObserver(readyObserver) - } + terminalSurface.cancelRuntimeReadyCallback(readyToken) + readyToken = nil if let hostedViewObserver { NotificationCenter.default.removeObserver(hostedViewObserver) } finish(()) } - readyObserver = NotificationCenter.default.addObserver( - forName: .terminalSurfaceDidBecomeReady, - object: terminalSurface, - queue: .main - ) { _ in + readyToken = terminalSurface.onRuntimeReady { finishOnce() } hostedViewObserver = NotificationCenter.default.addObserver( @@ -13972,7 +13737,7 @@ class TerminalController { private func resolveSurface(from arg: String, tabManager: TabManager) -> ghostty_surface_t? { // Backwards compatibility: resolve a terminal surface by panel UUID or a stable index. - // Use a slightly longer wait to reduce flakiness during bonsplit/layout restructures. + // Use a slightly longer wait to reduce flakiness during WorkspaceSplit/layout restructures. return resolveTerminalSurface(from: arg, tabManager: tabManager, waitUpTo: 2.0) } @@ -14199,7 +13964,7 @@ class TerminalController { #if DEBUG let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 if elapsedMs >= 8 || chunks.count > 1 { - cmuxDebugLog( + dlog( "socket.send_text.inject chars=\(text.count) chunks=\(chunks.count) ms=\(String(format: "%.2f", elapsedMs))" ) } @@ -14220,8 +13985,10 @@ class TerminalController { var success = false var error: String? DispatchQueue.main.sync { - guard let targetManager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) - ?? (tabManager.tabs.contains(where: { $0.id == workspaceId }) ? tabManager : nil) else { + guard let targetManager = self.resolvedWorkspaceOwner( + workspaceId: workspaceId, + preferredTabManager: tabManager + )?.tabManager else { error = "ERROR: Workspace not found" return } @@ -14261,8 +14028,7 @@ class TerminalController { private func sendableWorkspaceTerminalPanel(in workspace: Workspace) -> TerminalPanel? { func selectedTerminalPanel(in paneId: PaneID) -> TerminalPanel? { - guard let selectedTab = workspace.bonsplitController.selectedTab(inPane: paneId), - let panelId = workspace.panelIdFromSurfaceId(selectedTab.id), + guard let panelId = workspace.selectedSurfaceId(inPane: paneId), let terminalPanel = workspace.panels[panelId] as? TerminalPanel else { return nil } @@ -14270,15 +14036,12 @@ class TerminalController { } func isSelectedTerminalPanel(_ terminalPanel: TerminalPanel) -> Bool { - guard let surfaceId = workspace.surfaceIdFromPanelId(terminalPanel.id) else { - return false - } - return workspace.bonsplitController.allPaneIds.contains { paneId in - workspace.bonsplitController.selectedTab(inPane: paneId)?.id == surfaceId + return workspace.paneIds.contains { paneId in + workspace.selectedSurfaceId(inPane: paneId) == terminalPanel.id } } - if let focusedPane = workspace.bonsplitController.focusedPaneId, + if let focusedPane = workspace.focusedPaneId, let terminalPanel = selectedTerminalPanel(in: focusedPane) { return terminalPanel } @@ -14288,7 +14051,7 @@ class TerminalController { return rememberedTerminal } - for paneId in workspace.bonsplitController.allPaneIds { + for paneId in workspace.paneIds { if let terminalPanel = selectedTerminalPanel(in: paneId) { return terminalPanel } @@ -14386,8 +14149,8 @@ class TerminalController { return } - if let browserPanelId = tab.newBrowserSplit( - from: focusedPanelId, + if let browserPanelId = tab.splitBrowserPanel( + fromPanelId: focusedPanelId, orientation: .horizontal, url: url, focus: focus @@ -14520,36 +14283,7 @@ class TerminalController { return } - // Programmatic WebView focus should win over stale omnibar focus state, especially - // after workspace switches where the blank-page omnibar auto-focus can re-trigger. - browserPanel.endSuppressWebViewFocusForAddressBar() - browserPanel.clearWebViewFocusSuppression() - NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: panelId) - - // Prevent omnibar auto-focus from immediately stealing first responder back. - browserPanel.suppressOmnibarAutofocus(for: 1.5) - - let webView = browserPanel.webView - guard let window = webView.window else { - result = "ERROR: WebView is not in a window" - return - } - guard !webView.isHiddenOrHasHiddenAncestor else { - result = "ERROR: WebView is hidden" - return - } - - window.makeFirstResponder(webView) - if Self.responderChainContains(window.firstResponder, target: webView) { - // Some focus churn paths (workspace handoff / omnibar blur) can race this call. - // Reassert on the next runloop if another responder steals focus immediately. - DispatchQueue.main.async { [weak window, weak webView] in - guard let window, let webView else { return } - guard webView.window === window else { return } - if !Self.responderChainContains(window.firstResponder, target: webView) { - window.makeFirstResponder(webView) - } - } + if browserPanel.requestExplicitWebViewFocus() { result = "OK" } else { result = "ERROR: Focus did not move into web view" @@ -14583,7 +14317,7 @@ class TerminalController { return result } - // MARK: - Bonsplit Pane Commands + // MARK: - WorkspaceSplit Pane Commands private func listPanes() -> String { guard let tabManager = tabManager else { return "ERROR: TabManager not available" } @@ -14596,12 +14330,12 @@ class TerminalController { return } - let paneIds = tab.bonsplitController.allPaneIds - let focusedPaneId = tab.bonsplitController.focusedPaneId + let paneIds = tab.paneIds + let focusedPaneId = tab.focusedPaneId let lines = paneIds.enumerated().map { index, paneId in let selected = paneId == focusedPaneId ? "*" : " " - let tabCount = tab.bonsplitController.tabs(inPane: paneId).count + let tabCount = tab.surfaceIds(inPane: paneId).count return "\(selected) \(index): \(paneId) [\(tabCount) tabs]" } result = lines.isEmpty ? "No panes" : lines.joined(separator: "\n") @@ -14629,8 +14363,8 @@ class TerminalController { } } - let paneIds = tab.bonsplitController.allPaneIds - var targetPaneId: PaneID? = tab.bonsplitController.focusedPaneId + let paneIds = tab.paneIds + var targetPaneId: PaneID? = tab.focusedPaneId if let paneArg { if let uuid = UUID(uuidString: paneArg), let paneId = paneIds.first(where: { $0.id == uuid }) { @@ -14648,14 +14382,13 @@ class TerminalController { return } - let tabs = tab.bonsplitController.tabs(inPane: paneId) - let selectedTab = tab.bonsplitController.selectedTab(inPane: paneId) + let surfaceIds = tab.surfaceIds(inPane: paneId) + let selectedSurfaceId = tab.selectedSurfaceId(inPane: paneId) - let lines = tabs.enumerated().map { index, bonsplitTab in - let selected = bonsplitTab.id == selectedTab?.id ? "*" : " " - let panelId = tab.panelIdFromSurfaceId(bonsplitTab.id) - let panelIdStr = panelId?.uuidString ?? "unknown" - return "\(selected) \(index): \(bonsplitTab.title) [panel:\(panelIdStr)]" + let lines = surfaceIds.enumerated().map { index, panelId in + let selected = panelId == selectedSurfaceId ? "*" : " " + let title = tab.panelTitle(panelId: panelId) ?? tab.panels[panelId]?.displayTitle ?? "Tab" + return "\(selected) \(index): \(title) [panel:\(panelId.uuidString)]" } result = lines.isEmpty ? "No tabs in pane" : lines.joined(separator: "\n") } @@ -14675,15 +14408,15 @@ class TerminalController { return } - let paneIds = tab.bonsplitController.allPaneIds + let paneIds = tab.paneIds // Try UUID first, then fall back to index if let uuid = UUID(uuidString: paneArg), let paneId = paneIds.first(where: { $0.id == uuid }) { - tab.bonsplitController.focusPane(paneId) + _ = tab.focusPane(paneId) result = "OK" } else if let index = Int(paneArg), index >= 0, index < paneIds.count { - tab.bonsplitController.focusPane(paneIds[index]) + _ = tab.focusPane(paneIds[index]) result = "OK" } } @@ -14739,17 +14472,16 @@ class TerminalController { return } - guard let panelId = resolveSurfaceId(from: surfaceArg, tab: tab), - let bonsplitTabId = tab.surfaceIdFromPanelId(panelId) else { + guard let panelId = resolveSurfaceId(from: surfaceArg, tab: tab) else { result = "ERROR: Surface not found" return } - guard let newPaneId = tab.bonsplitController.splitPane( - orientation: orientation, - movingTab: bonsplitTabId, - insertFirst: insertFirst - ) else { + guard let newPaneId = tab.splitSurface( + panelId: panelId, + orientation: orientation, + insertFirst: insertFirst + ) else { result = "ERROR: Failed to split pane" return } @@ -14805,16 +14537,16 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = tab.newBrowserSplit( - from: focusedPanelId, + newPanelId = tab.splitBrowserPanel( + fromPanelId: focusedPanelId, orientation: orientation, insertFirst: insertFirst, url: url, focus: focus )?.id } else { - newPanelId = tab.newTerminalSplit( - from: focusedPanelId, + newPanelId = tab.splitTerminalPanel( + fromPanelId: focusedPanelId, orientation: orientation, insertFirst: insertFirst, focus: focus @@ -14985,9 +14717,8 @@ class TerminalController { return tab } // The tab may belong to a different window — search all contexts. - if let uuid = UUID(uuidString: tabArg.trimmingCharacters(in: .whitespacesAndNewlines)), - let otherManager = AppDelegate.shared?.tabManagerFor(tabId: uuid) { - return otherManager.tabs.first(where: { $0.id == uuid }) + if let uuid = UUID(uuidString: tabArg.trimmingCharacters(in: .whitespacesAndNewlines)) { + return resolvedWorkspaceOwner(workspaceId: uuid)?.workspace } return nil } @@ -15042,13 +14773,7 @@ class TerminalController { } private func tabForSidebarMutation(id: UUID) -> Tab? { - if let tab = tabManager?.tabs.first(where: { $0.id == id }) { - return tab - } - if let otherManager = AppDelegate.shared?.tabManagerFor(tabId: id) { - return otherManager.tabs.first(where: { $0.id == id }) - } - return nil + resolvedWorkspaceOwner(workspaceId: id)?.workspace } private func parseSidebarMetadataFormat(_ raw: String) -> SidebarMetadataFormat? { @@ -15576,10 +15301,11 @@ class TerminalController { // block socket handlers and starve subsequent branch updates. if let scope = Self.explicitSocketScope(options: parsed.options) { DispatchQueue.main.async { - guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId), - let tab = tabManager.tabs.first(where: { $0.id == scope.workspaceId }) else { + guard let owner = self.resolvedWorkspaceOwner(workspaceId: scope.workspaceId) else { return } + let tabManager = owner.tabManager + let tab = owner.workspace let validSurfaceIds = Set(tab.panels.keys) tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) guard validSurfaceIds.contains(scope.panelId) else { return } @@ -15612,10 +15338,11 @@ class TerminalController { // block socket handlers and starve subsequent branch updates. if let scope = Self.explicitSocketScope(options: parsed.options) { DispatchQueue.main.async { - guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId), - let tab = tabManager.tabs.first(where: { $0.id == scope.workspaceId }) else { + guard let owner = self.resolvedWorkspaceOwner(workspaceId: scope.workspaceId) else { return } + let tabManager = owner.tabManager + let tab = owner.workspace let validSurfaceIds = Set(tab.panels.keys) tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) guard validSurfaceIds.contains(scope.panelId) else { return } @@ -15676,7 +15403,7 @@ class TerminalController { missingPanelUsage: "report_pr [--label=PR] [--state=open|merged|closed] [--branch=] [--tab=X] [--panel=Y]" ) { tab, surfaceId in guard Self.shouldReplacePullRequest( - current: tab.panelPullRequests[surfaceId], + current: tab.surfaceStateSnapshot(panelId: surfaceId).pullRequest, number: number, label: label, url: url, @@ -15756,7 +15483,7 @@ class TerminalController { return } - tab.surfaceListeningPorts[surfaceId] = ports + tab.setSurfaceListeningPorts(panelId: surfaceId, ports: ports) tab.recomputeListeningPorts() } return result @@ -15772,10 +15499,11 @@ class TerminalController { let directory = parsed.positional.joined(separator: " ") if let scope = Self.explicitSocketScope(options: parsed.options) { DispatchQueue.main.async { - guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId), - let tab = tabManager.tabs.first(where: { $0.id == scope.workspaceId }) else { + guard let owner = self.resolvedWorkspaceOwner(workspaceId: scope.workspaceId) else { return } + let tabManager = owner.tabManager + let tab = owner.workspace let validSurfaceIds = Set(tab.panels.keys) tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) guard validSurfaceIds.contains(scope.panelId) else { return } @@ -15841,7 +15569,7 @@ class TerminalController { return "OK" } DispatchQueue.main.async { - guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId) else { return } + guard let tabManager = self.resolvedWorkspaceOwner(workspaceId: scope.workspaceId)?.tabManager else { return } tabManager.updateSurfaceShellActivity(tabId: scope.workspaceId, surfaceId: scope.panelId, state: state) } return "OK" @@ -15907,7 +15635,7 @@ class TerminalController { options: parsed.options, missingPanelUsage: "report_pr_action [--target=X] [--tab=X] [--panel=Y]" ) { tab, surfaceId in - guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: tab.id) else { return } + guard let tabManager = self.resolvedWorkspaceOwner(workspaceId: tab.id)?.tabManager else { return } tabManager.handleWorkspacePullRequestCommandHint( tabId: tab.id, surfaceId: surfaceId, @@ -15943,9 +15671,9 @@ class TerminalController { result = "ERROR: Panel not found '\(surfaceId.uuidString)'" return } - tab.surfaceListeningPorts.removeValue(forKey: surfaceId) + tab.setSurfaceListeningPorts(panelId: surfaceId, ports: []) } else { - tab.surfaceListeningPorts.removeAll() + tab.clearAllSurfaceListeningPorts() } tab.recomputeListeningPorts() } @@ -15960,14 +15688,14 @@ class TerminalController { if let scope = Self.explicitSocketScope(options: parsed.options) { DispatchQueue.main.async { - guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId), - let tab = tabManager.tabs.first(where: { $0.id == scope.workspaceId }) else { + guard let owner = self.resolvedWorkspaceOwner(workspaceId: scope.workspaceId) else { return } + let tab = owner.workspace let validSurfaceIds = Set(tab.panels.keys) tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) guard validSurfaceIds.contains(scope.panelId) else { return } - tab.surfaceTTYNames[scope.panelId] = ttyName + tab.setSurfaceTTYName(panelId: scope.panelId, ttyName: ttyName) if tab.isRemoteWorkspace { tab.syncRemotePortScanTTYs() _ = tab.applyPendingRemoteSurfacePortKickIfNeeded(to: scope.panelId) @@ -16011,7 +15739,7 @@ class TerminalController { return } - tab.surfaceTTYNames[surfaceId] = ttyName + tab.setSurfaceTTYName(panelId: surfaceId, ttyName: ttyName) if tab.isRemoteWorkspace { tab.syncRemotePortScanTTYs() _ = tab.applyPendingRemoteSurfacePortKickIfNeeded(to: surfaceId) @@ -16036,10 +15764,10 @@ class TerminalController { if let scope = Self.explicitSocketScope(options: parsed.options) { DispatchQueue.main.async { - guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId), - let tab = tabManager.tabs.first(where: { $0.id == scope.workspaceId }) else { + guard let owner = self.resolvedWorkspaceOwner(workspaceId: scope.workspaceId) else { return } + let tab = owner.workspace let validSurfaceIds = Set(tab.panels.keys) tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) guard validSurfaceIds.contains(scope.panelId) else { return } @@ -16102,7 +15830,7 @@ class TerminalController { lines.append("cwd=\(tab.currentDirectory)") if let focused = tab.focusedPanelId, - let focusedDir = tab.panelDirectories[focused] { + let focusedDir = tab.panelDirectory(panelId: focused) { lines.append("focused_cwd=\(focusedDir)") lines.append("focused_panel=\(focused.uuidString)") } else { @@ -16323,7 +16051,7 @@ class TerminalController { // Get target pane let paneId: PaneID? - let paneIds = tab.bonsplitController.allPaneIds + let paneIds = tab.paneIds if let paneArg { if let uuid = UUID(uuidString: paneArg) { paneId = paneIds.first(where: { $0.id == uuid }) @@ -16333,7 +16061,7 @@ class TerminalController { paneId = nil } } else { - paneId = tab.bonsplitController.focusedPaneId + paneId = tab.focusedPaneId } guard let targetPaneId = paneId else { @@ -16343,9 +16071,9 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = tab.newBrowserSurface(inPane: targetPaneId, url: url, focus: focus)?.id + newPanelId = tab.createBrowserPanel(inPane: targetPaneId, url: url, focus: focus)?.id } else { - newPanelId = tab.newTerminalSurface(inPane: targetPaneId, focus: focus)?.id + newPanelId = tab.createTerminalPanel(inPane: targetPaneId, focus: focus)?.id } if let id = newPanelId { diff --git a/Sources/TerminalImageTransfer.swift b/Sources/TerminalImageTransfer.swift index 0fb28e9f9123..1562f3696277 100644 --- a/Sources/TerminalImageTransfer.swift +++ b/Sources/TerminalImageTransfer.swift @@ -347,7 +347,7 @@ extension TerminalSurface { if workspace.isRemoteTerminalSurface(id) { return .remote(.workspaceRemote) } - if let ttyName = workspace.surfaceTTYNames[id], + if let ttyName = workspace.surfaceTTYName(panelId: id), let session = TerminalSSHSessionDetector.detect(forTTY: ttyName) { return .remote(.detectedSSH(session)) } diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 82a73fa498a4..a0bd995849b8 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -1,7 +1,6 @@ import AppKit import Foundation import UserNotifications -import Bonsplit // UNUserNotificationCenter.removeDeliveredNotifications(withIdentifiers:) and // removePendingNotificationRequests(withIdentifiers:) perform synchronous XPC to @@ -564,6 +563,13 @@ enum NotificationBadgeSettings { enum NotificationPaneRingSettings { static let enabledKey = "notificationPaneRingEnabled" static let defaultEnabled = true + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: enabledKey) == nil { + return defaultEnabled + } + return defaults.bool(forKey: enabledKey) + } } enum NotificationPaneFlashSettings { @@ -780,7 +786,7 @@ final class TerminalNotificationStore: ObservableObject { private func logAuthorization(_ message: String) { #if DEBUG - cmuxDebugLog("notification.auth \(message)") + dlog("notification.auth \(message)") #endif NSLog("notification.auth %@", message) } diff --git a/Sources/TerminalView.swift b/Sources/TerminalView.swift deleted file mode 100644 index 932e5dd3e5de..000000000000 --- a/Sources/TerminalView.swift +++ /dev/null @@ -1,287 +0,0 @@ -import SwiftUI -import SwiftTerm -import AppKit - -// Helper to create SwiftTerm Color from hex -extension SwiftTerm.Color { - convenience init(hex: String) { - var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) - hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "") - - var rgb: UInt64 = 0 - Scanner(string: hexSanitized).scanHexInt64(&rgb) - - let r = UInt16((rgb & 0xFF0000) >> 16) - let g = UInt16((rgb & 0x00FF00) >> 8) - let b = UInt16(rgb & 0x0000FF) - - // Convert 8-bit to 16-bit - self.init(red: r * 257, green: g * 257, blue: b * 257) - } -} - -struct TerminalContainerView: View { - @ObservedObject var tab: Tab - let config: GhosttyConfig - - init(tab: Tab, config: GhosttyConfig = GhosttyConfig.load()) { - self.tab = tab - self.config = config - } - - var body: some View { - SwiftTermView(tab: tab, config: config) - .background(Color(config.backgroundColor)) - } -} - -// Custom wrapper to handle first responder and layout -class FocusableTerminalView: NSView { - var terminalView: LocalProcessTerminalView? - private var scroller: NSScroller? - private var fadeTimer: Timer? - private var scrollMonitor: Any? - private var lastScrollerValue: Double = 0 - - override var acceptsFirstResponder: Bool { true } - - override func becomeFirstResponder() -> Bool { - if let tv = terminalView { - DispatchQueue.main.async { - self.window?.makeFirstResponder(tv) - } - } - return true - } - - override func mouseDown(with event: NSEvent) { - window?.makeFirstResponder(terminalView) - super.mouseDown(with: event) - } - - override func layout() { - super.layout() - if let tv = terminalView, bounds.size.width > 0, bounds.size.height > 0 { - tv.setFrameSize(bounds.size) - setupScrollerTracking(in: tv) - } - } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - if window != nil, let tv = terminalView, bounds.size.width > 0 { - tv.setFrameSize(bounds.size) - setupScrollerTracking(in: tv) - setupScrollMonitor() - } - } - - override func viewWillMove(toWindow newWindow: NSWindow?) { - super.viewWillMove(toWindow: newWindow) - if newWindow == nil, let monitor = scrollMonitor { - NSEvent.removeMonitor(monitor) - scrollMonitor = nil - } - } - - private func setupScrollerTracking(in view: NSView) { - if scroller == nil { - for subview in view.subviews { - if let s = subview as? NSScroller { - scroller = s - s.alphaValue = 0 // Start hidden - lastScrollerValue = s.doubleValue - break - } - } - } - } - - private func setupScrollMonitor() { - guard scrollMonitor == nil else { return } - - // Monitor scroll wheel events - scrollMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in - if let self = self, - let window = self.window, - event.window == window { - self.showScrollerTemporarily() - } - return event - } - } - - func showScrollerTemporarily() { - guard let scroller = scroller else { return } - - // Show scroller - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.15 - scroller.animator().alphaValue = 1 - } - - // Cancel existing timer - fadeTimer?.invalidate() - - // Fade out after 1.5 seconds of no scrolling - fadeTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { [weak self] _ in - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.3 - self?.scroller?.animator().alphaValue = 0 - } - } - } -} - -struct SwiftTermView: NSViewRepresentable { - @ObservedObject var tab: Tab - let config: GhosttyConfig - - func makeNSView(context: Context) -> FocusableTerminalView { - let containerView = FocusableTerminalView() - containerView.wantsLayer = true - - let terminalView = LocalProcessTerminalView(frame: CGRect(x: 0, y: 0, width: 800, height: 600)) - - // Use autoresizingMask instead of Auto Layout for SwiftTerm compatibility - terminalView.autoresizingMask = [.width, .height] - - // Apply Ghostty config colors - terminalView.nativeForegroundColor = config.foregroundColor - terminalView.nativeBackgroundColor = config.backgroundColor - - // Set cursor color to match Ghostty - terminalView.caretColor = config.cursorColor - terminalView.caretTextColor = config.cursorTextColor - - // Set selection colors - terminalView.selectedTextBackgroundColor = config.selectionBackground - - // Apply ANSI palette colors - applyPalette(to: terminalView, config: config) - - // Configure font from config - if let font = NSFont(name: config.fontFamily, size: config.fontSize) { - terminalView.font = font - } else { - terminalView.font = NSFont.monospacedSystemFont(ofSize: config.fontSize, weight: .regular) - } - - // Set terminal delegate (only processDelegate, not terminalDelegate which breaks input) - terminalView.processDelegate = context.coordinator - context.coordinator.terminalView = terminalView - context.coordinator.containerView = containerView - - containerView.addSubview(terminalView) - containerView.terminalView = terminalView - - // Get shell path - let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh" - - // Determine working directory - let workingDir = config.workingDirectory ?? FileManager.default.homeDirectoryForCurrentUser.path - - // Build environment with working directory - var env = ProcessInfo.processInfo.environment - env["PWD"] = workingDir - - // Start the shell process - terminalView.startProcess( - executable: shell, - args: [], - environment: env.map { "\($0.key)=\($0.value)" }, - execName: "-" + (shell as NSString).lastPathComponent - ) - - // Change to working directory - terminalView.feed(text: "cd \"\(workingDir)\" && clear\n") - - - // Make first responder after a delay to ensure window is ready - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - containerView.window?.makeFirstResponder(terminalView) - } - - return containerView - } - - func updateNSView(_ nsView: FocusableTerminalView, context: Context) { - // When this view becomes visible (tab switch), make it first responder - DispatchQueue.main.async { - if let terminalView = nsView.terminalView { - nsView.window?.makeFirstResponder(terminalView) - } - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(tab: tab) - } - - private func applyPalette(to terminalView: LocalProcessTerminalView, config: GhosttyConfig) { - // SwiftTerm uses installColors to set the ANSI color palette - // Build the color array (16 ANSI colors) - - // Default Monokai Classic palette hex values - let defaultPaletteHex: [String] = [ - "#272822", // 0 - black - "#f92672", // 1 - red - "#a6e22e", // 2 - green - "#e6db74", // 3 - yellow - "#fd971f", // 4 - blue (orange in Monokai) - "#ae81ff", // 5 - magenta - "#66d9ef", // 6 - cyan - "#fdfff1", // 7 - white - "#6e7066", // 8 - bright black - "#f92672", // 9 - bright red - "#a6e22e", // 10 - bright green - "#e6db74", // 11 - bright yellow - "#fd971f", // 12 - bright blue - "#ae81ff", // 13 - bright magenta - "#66d9ef", // 14 - bright cyan - "#fdfff1", // 15 - bright white - ] - - var colors: [SwiftTerm.Color] = [] - for i in 0..<16 { - colors.append(SwiftTerm.Color(hex: defaultPaletteHex[i])) - } - - // Install the ANSI colors - terminalView.installColors(colors) - } - - class Coordinator: NSObject, LocalProcessTerminalViewDelegate { - var tab: Tab - weak var terminalView: LocalProcessTerminalView? - weak var containerView: FocusableTerminalView? - - init(tab: Tab) { - self.tab = tab - } - - func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) { - // Handle size change - } - - func setTerminalTitle(source: LocalProcessTerminalView, title: String) { - DispatchQueue.main.async { - if !title.isEmpty { - self.tab.applyProcessTitle(title) - } - } - } - - func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) { - if let dir = directory { - DispatchQueue.main.async { - self.tab.currentDirectory = dir - } - } - } - - func processTerminated(source: TerminalView, exitCode: Int32?) { - // Could close tab or show message - } - } -} diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index a8b7f7befcb8..10a7f69611fe 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -1,7 +1,6 @@ import AppKit import ObjectiveC #if DEBUG -import Bonsplit #endif private var cmuxWindowTerminalPortalKey: UInt8 = 0 @@ -206,10 +205,6 @@ final class WindowTerminalHostView: NSView { let visibleHostedViews = subviews.compactMap { $0 as? GhosttySurfaceScrollView } .filter { !$0.isHidden && $0.window != nil && $0.frame.width > 1 && $0.frame.height > 1 } - if shouldPassThroughToTrailingSidebarResizer(at: point, visibleHostedViews: visibleHostedViews) { - return true - } - // If content is flush to the leading edge, sidebar is effectively hidden. // In that state, treating any internal split edge as a sidebar divider // steals split-divider cursor/drag behavior. @@ -251,17 +246,9 @@ final class WindowTerminalHostView: NSView { return false } - return SidebarResizeInteraction.Edge.leading.hitRange(dividerX: dividerX).contains(point.x) - } - - private func shouldPassThroughToTrailingSidebarResizer( - at point: NSPoint, - visibleHostedViews: [GhosttySurfaceScrollView] - ) -> Bool { - guard let rightMostEdge = visibleHostedViews.map(\.frame.maxX).max() else { return false } - let trailingGap = bounds.maxX - rightMostEdge - guard trailingGap > Self.minimumVisibleLeadingContentWidth else { return false } - return SidebarResizeInteraction.Edge.trailing.hitRange(dividerX: rightMostEdge).contains(point.x) + let regionMinX = dividerX - SidebarResizeInteraction.sidebarSideHitWidth + let regionMaxX = dividerX + SidebarResizeInteraction.contentSideHitWidth + return point.x >= regionMinX && point.x <= regionMaxX } private func updateDividerCursor(at point: NSPoint) { @@ -401,7 +388,7 @@ final class WindowTerminalHostView: NSView { pasteboardTypes: [NSPasteboard.PasteboardType]?, hitView: NSView? ) { - let hasRelevantTypes = DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes) + let hasRelevantTypes = DragOverlayRoutingPolicy.hasSplitTabTransfer(pasteboardTypes) || DragOverlayRoutingPolicy.hasSidebarTabReorder(pasteboardTypes) guard passThrough || hasRelevantTypes else { return } @@ -415,7 +402,7 @@ final class WindowTerminalHostView: NSView { guard lastDragRouteSignature != signature else { return } lastDragRouteSignature = signature - cmuxDebugLog( + dlog( "portal.dragRoute passThrough=\(passThrough ? 1 : 0) " + "event=\(debugEventName(eventType)) target=\(targetClass) " + "types=\(debugPasteboardTypes(pasteboardTypes))" @@ -614,7 +601,7 @@ final class WindowTerminalPortal: NSObject { private var externalGeometrySyncGeneration: UInt64 = 0 private var geometryObservers: [NSObjectProtocol] = [] #if DEBUG - private var lastLoggedBonsplitContainerSignature: String? + private var lastLoggedsplitContainerSignature: String? #endif private struct Entry { @@ -772,7 +759,7 @@ final class WindowTerminalPortal: NSObject { hostView.frame = frameInContainer CATransaction.commit() #if DEBUG - cmuxDebugLog( + dlog( "portal.hostFrame.update host=\(portalDebugToken(hostView)) " + "frame=\(portalDebugFrame(frameInContainer))" ) @@ -932,11 +919,11 @@ final class WindowTerminalPortal: NSObject { } #if DEBUG - private func nearestBonsplitContainer(from anchorView: NSView) -> NSView? { + private func nearestsplitContainer(from anchorView: NSView) -> NSView? { var current: NSView? = anchorView while let view = current { let className = NSStringFromClass(type(of: view)) - if className.contains("PaneDragContainerView") || className.contains("Bonsplit") { + if className.contains("PaneDragContainerView") || className.contains("WorkspaceSplit") { return view } current = view.superview @@ -944,16 +931,16 @@ final class WindowTerminalPortal: NSObject { return installedReferenceView } - private func logBonsplitContainerFrameIfNeeded(anchorView: NSView, hostedView: GhosttySurfaceScrollView) { - guard let container = nearestBonsplitContainer(from: anchorView) else { return } + private func logsplitContainerFrameIfNeeded(anchorView: NSView, hostedView: GhosttySurfaceScrollView) { + guard let container = nearestsplitContainer(from: anchorView) else { return } let containerFrame = container.convert(container.bounds, to: nil) let signature = "\(ObjectIdentifier(container)):\(portalDebugFrame(containerFrame))" - guard signature != lastLoggedBonsplitContainerSignature else { return } - lastLoggedBonsplitContainerSignature = signature + guard signature != lastLoggedsplitContainerSignature else { return } + lastLoggedsplitContainerSignature = signature let containerClass = NSStringFromClass(type(of: container)) - cmuxDebugLog( - "portal.bonsplit.container hosted=\(portalDebugToken(hostedView)) " + + dlog( + "portal.WorkspaceLayout.container hosted=\(portalDebugToken(hostedView)) " + "class=\(containerClass) frame=\(portalDebugFrame(containerFrame)) " + "host=\(portalDebugFrameInWindow(hostView)) anchor=\(portalDebugFrameInWindow(anchorView))" ) @@ -1019,7 +1006,7 @@ final class WindowTerminalPortal: NSObject { } #if DEBUG let hadSuperview = (entry.hostedView?.superview === hostView) ? 1 : 0 - cmuxDebugLog( + dlog( "portal.detach hosted=\(portalDebugToken(entry.hostedView)) " + "anchor=\(portalDebugToken(entry.anchorView)) hadSuperview=\(hadSuperview)" ) @@ -1029,32 +1016,6 @@ final class WindowTerminalPortal: NSObject { } } - /// Hide a portal entry without detaching it. Updates visibleInUI to false and - /// sets isHidden = true so subsequent synchronizeHostedView calls keep it hidden. - /// Used when a workspace is permanently unmounted (vs. transient bonsplit dismantles). - func hideEntry(forHostedId hostedId: ObjectIdentifier) { - guard var entry = entriesByHostedId[hostedId] else { return } - entry.visibleInUI = false - entry.transientRecoveryRetriesRemaining = 0 - entriesByHostedId[hostedId] = entry - entry.hostedView?.isHidden = true -#if DEBUG - cmuxDebugLog("portal.hideEntry hosted=\(portalDebugToken(entry.hostedView)) reason=workspaceUnmount") -#endif - } - - /// Update the visibleInUI flag on an existing entry without rebinding. - /// Used when a deferred bind is pending — this ensures synchronizeHostedView - /// won't hide a view that updateNSView has already marked as visible. - func updateEntryVisibility(forHostedId hostedId: ObjectIdentifier, visibleInUI: Bool) { - guard var entry = entriesByHostedId[hostedId] else { return } - entry.visibleInUI = visibleInUI - if !visibleInUI { - entry.transientRecoveryRetriesRemaining = 0 - } - entriesByHostedId[hostedId] = entry - } - func isHostedViewBoundToAnchor(withId hostedId: ObjectIdentifier, anchorView: NSView) -> Bool { guard let entry = entriesByHostedId[hostedId], let boundAnchor = entry.anchorView else { return false } @@ -1073,7 +1034,7 @@ final class WindowTerminalPortal: NSObject { let previousToken = entriesByHostedId[previousHostedId] .map { portalDebugToken($0.hostedView) } ?? String(describing: previousHostedId) - cmuxDebugLog( + dlog( "portal.bind.replace anchor=\(portalDebugToken(anchorView)) " + "oldHosted=\(previousToken) newHosted=\(portalDebugToken(hostedView))" ) @@ -1104,7 +1065,7 @@ final class WindowTerminalPortal: NSObject { let priorityIncreased = zPriority > (previousEntry?.zPriority ?? Int.min) #if DEBUG if previousEntry == nil || didChangeAnchor || becameVisible || priorityIncreased || hostedView.superview !== hostView { - cmuxDebugLog( + dlog( "portal.bind hosted=\(portalDebugToken(hostedView)) " + "anchor=\(portalDebugToken(anchorView)) prevAnchor=\(portalDebugToken(previousEntry?.anchorView)) " + "visible=\(visibleInUI ? 1 : 0) prevVisible=\((previousEntry?.visibleInUI ?? false) ? 1 : 0) " + @@ -1141,7 +1102,7 @@ final class WindowTerminalPortal: NSObject { if hostedView.superview !== hostView { #if DEBUG - cmuxDebugLog( + dlog( "portal.reparent hosted=\(portalDebugToken(hostedView)) " + "reason=attach super=\(portalDebugToken(hostedView.superview))" ) @@ -1152,7 +1113,7 @@ final class WindowTerminalPortal: NSObject { // Anchor-only churn is common during split tree updates; forcing remove/add there // causes transient inWindow=0 -> 1 bounces that can flash black. #if DEBUG - cmuxDebugLog( + dlog( "portal.reparent hosted=\(portalDebugToken(hostedView)) reason=raise " + "didChangeAnchor=\(didChangeAnchor ? 1 : 0) becameVisible=\(becameVisible ? 1 : 0) " + "priorityIncreased=\(priorityIncreased ? 1 : 0)" @@ -1240,7 +1201,7 @@ final class WindowTerminalPortal: NSObject { entry.transientRecoveryRetriesRemaining -= 1 entriesByHostedId[hostedId] = entry #if DEBUG - cmuxDebugLog( + dlog( "portal.sync.deferRecover hosted=\(portalDebugToken(hostedView)) " + "reason=\(reason) remaining=\(entry.transientRecoveryRetriesRemaining)" ) @@ -1269,7 +1230,7 @@ final class WindowTerminalPortal: NSObject { ) if shouldPreserveVisibleOnTransient { #if DEBUG - cmuxDebugLog( + dlog( "portal.hidden.deferKeep hosted=\(portalDebugToken(hostedView)) " + "reason=missingAnchorOrWindow frame=\(portalDebugFrame(hostedView.frame))" ) @@ -1281,7 +1242,7 @@ final class WindowTerminalPortal: NSObject { } #if DEBUG if !hostedView.isHidden { - cmuxDebugLog("portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 reason=missingAnchorOrWindow") + dlog("portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 reason=missingAnchorOrWindow") } #endif hostedView.isHidden = true @@ -1298,7 +1259,7 @@ final class WindowTerminalPortal: NSObject { guard anchorView.window === window else { #if DEBUG if !hostedView.isHidden { - cmuxDebugLog( + dlog( "portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 " + "reason=anchorWindowMismatch anchorWindow=\(portalDebugToken(anchorView.window?.contentView))" ) @@ -1314,7 +1275,7 @@ final class WindowTerminalPortal: NSObject { ) if shouldPreserveVisibleOnTransient { #if DEBUG - cmuxDebugLog( + dlog( "portal.hidden.deferKeep hosted=\(portalDebugToken(hostedView)) " + "reason=anchorWindowMismatch frame=\(portalDebugFrame(hostedView.frame))" ) @@ -1341,7 +1302,7 @@ final class WindowTerminalPortal: NSObject { let frameInHostRaw = hostView.convert(frameInWindow, from: nil) let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView) #if DEBUG - logBonsplitContainerFrameIfNeeded(anchorView: anchorView, hostedView: hostedView) + logsplitContainerFrameIfNeeded(anchorView: anchorView, hostedView: hostedView) #endif let hostBounds = hostView.bounds let hasFiniteHostBounds = @@ -1352,7 +1313,7 @@ final class WindowTerminalPortal: NSObject { let hostBoundsReady = hasFiniteHostBounds && hostBounds.width > 1 && hostBounds.height > 1 if !hostBoundsReady { #if DEBUG - cmuxDebugLog( + dlog( "portal.sync.defer hosted=\(portalDebugToken(hostedView)) " + "reason=hostBoundsNotReady host=\(portalDebugFrame(hostBounds)) " + "anchor=\(portalDebugFrame(frameInHost)) visibleInUI=\(entry.visibleInUI ? 1 : 0)" @@ -1368,7 +1329,7 @@ final class WindowTerminalPortal: NSObject { ) if shouldPreserveVisibleOnTransient { #if DEBUG - cmuxDebugLog( + dlog( "portal.hidden.deferKeep hosted=\(portalDebugToken(hostedView)) " + "reason=hostBoundsNotReady frame=\(portalDebugFrame(hostedView.frame))" ) @@ -1448,7 +1409,7 @@ final class WindowTerminalPortal: NSObject { #if DEBUG let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame) if frameWasClamped { - cmuxDebugLog( + dlog( "portal.frame.clamp hosted=\(portalDebugToken(hostedView)) " + "anchor=\(portalDebugToken(anchorView)) " + "raw=\(portalDebugFrame(frameInHost)) clamped=\(portalDebugFrame(targetFrame)) " + @@ -1458,12 +1419,12 @@ final class WindowTerminalPortal: NSObject { let collapsedToTiny = oldFrame.width > 1 && oldFrame.height > 1 && tinyFrame let restoredFromTiny = (oldFrame.width <= 1 || oldFrame.height <= 1) && !tinyFrame if collapsedToTiny { - cmuxDebugLog( + dlog( "portal.frame.collapse hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " + "old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(targetFrame))" ) } else if restoredFromTiny { - cmuxDebugLog( + dlog( "portal.frame.restore hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " + "old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(targetFrame))" ) @@ -1475,7 +1436,7 @@ final class WindowTerminalPortal: NSObject { // briefly transitions through offscreen/tiny geometry during rapid split churn. if shouldHide, !hostedView.isHidden, !shouldPreserveVisibleOnTransientGeometry { #if DEBUG - cmuxDebugLog( + dlog( "portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 " + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + "tiny=\(tinyFrame ? 1 : 0) revealReady=\(revealReadyForDisplay ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + @@ -1487,7 +1448,7 @@ final class WindowTerminalPortal: NSObject { } if shouldPreserveVisibleOnTransientGeometry { #if DEBUG - cmuxDebugLog( + dlog( "portal.hidden.deferKeep hosted=\(portalDebugToken(hostedView)) " + "reason=\(transientRecoveryReason ?? "unknown") frame=\(portalDebugFrame(hostedView.frame))" ) @@ -1517,7 +1478,7 @@ final class WindowTerminalPortal: NSObject { if shouldDeferReveal { #if DEBUG if !Self.rectApproximatelyEqual(oldFrame, frameInHost) { - cmuxDebugLog( + dlog( "portal.hidden.deferReveal hosted=\(portalDebugToken(hostedView)) " + "frame=\(portalDebugFrame(frameInHost)) min=\(Int(Self.minimumRevealWidth))x\(Int(Self.minimumRevealHeight))" ) @@ -1527,7 +1488,7 @@ final class WindowTerminalPortal: NSObject { if !shouldHide, hostedView.isHidden, revealReadyForDisplay { #if DEBUG - cmuxDebugLog( + dlog( "portal.hidden hosted=\(portalDebugToken(hostedView)) value=0 " + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + "tiny=\(tinyFrame ? 1 : 0) revealReady=\(revealReadyForDisplay ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + @@ -1548,7 +1509,7 @@ final class WindowTerminalPortal: NSObject { } #if DEBUG - cmuxDebugLog( + dlog( "portal.sync.result hosted=\(portalDebugToken(hostedView)) " + "anchor=\(portalDebugToken(anchorView)) host=\(portalDebugToken(hostView)) " + "hostWin=\(hostView.window?.windowNumber ?? -1) " + @@ -1925,7 +1886,7 @@ enum TerminalWindowPortalRegistry { ) blockedBindCount += 1 blockedBindReasons[reason, default: 0] += 1 - cmuxDebugLog( + dlog( "portal.bind.blocked hosted=\(portalDebugToken(hostedView)) " + "reason=\(reason) expectedSurface=\(expectedSurfaceId?.uuidString.prefix(5) ?? "nil") " + "expectedGeneration=\(expectedGeneration.map { String($0) } ?? "nil") " + @@ -1995,13 +1956,6 @@ enum TerminalWindowPortalRegistry { } } - static func hideHostedView(_ hostedView: GhosttySurfaceScrollView) { - let hostedId = ObjectIdentifier(hostedView) - guard let windowId = hostedToWindowId[hostedId], - let portal = portalsByWindowId[windowId] else { return } - portal.hideEntry(forHostedId: hostedId) - } - /// Permanently detach a hosted terminal view from the window-level portal. /// Use this when a terminal panel is actually closing (not transient SwiftUI dismantle). static func detach(hostedView: GhosttySurfaceScrollView) { @@ -2010,16 +1964,6 @@ enum TerminalWindowPortalRegistry { portalsByWindowId[windowId]?.detachHostedView(withId: hostedId) } - /// Update the visibleInUI flag on an existing portal entry without rebinding. - /// Called when a bind is deferred (host not yet in window) to prevent stale - /// portal syncs from hiding a view that is about to become visible. - static func updateEntryVisibility(for hostedView: GhosttySurfaceScrollView, visibleInUI: Bool) { - let hostedId = ObjectIdentifier(hostedView) - guard let windowId = hostedToWindowId[hostedId], - let portal = portalsByWindowId[windowId] else { return } - portal.updateEntryVisibility(forHostedId: hostedId, visibleInUI: visibleInUI) - } - static func isHostedView(_ hostedView: GhosttySurfaceScrollView, boundTo anchorView: NSView) -> Bool { let hostedId = ObjectIdentifier(hostedView) guard let window = anchorView.window else { return false } diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 335a23e69366..d77c18e9fa59 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -1,5 +1,4 @@ import AppKit -import Bonsplit import Combine import SwiftUI @@ -359,7 +358,7 @@ struct TitlebarControlsView: View { let content = HStack(spacing: config.spacing) { TitlebarControlButton(config: config, action: { #if DEBUG - cmuxDebugLog("titlebar.toggleSidebar") + dlog("titlebar.toggleSidebar") #endif onToggleSidebar() }) { @@ -371,7 +370,7 @@ struct TitlebarControlsView: View { TitlebarControlButton(config: config, action: { #if DEBUG - cmuxDebugLog("titlebar.notifications") + dlog("titlebar.notifications") #endif onToggleNotifications() }) { @@ -398,7 +397,7 @@ struct TitlebarControlsView: View { TitlebarControlButton(config: config, action: { #if DEBUG - cmuxDebugLog("titlebar.newTab") + dlog("titlebar.newTab") #endif onNewTab() }) { @@ -507,10 +506,10 @@ struct TitlebarControlsView: View { .accessibilityIdentifier("titlebarShortcutHint.\(item.action.rawValue)") .frame(width: item.width, alignment: .leading) .offset(x: item.leftEdge, y: yOffset) - .shortcutHintTransition() } } - .shortcutHintVisibilityAnimation(value: shouldShowTitlebarShortcutHints) + .animation(.easeOut(duration: 0.12), value: shouldShowTitlebarShortcutHints) + .transition(.opacity) .allowsHitTesting(false) } @@ -558,9 +557,7 @@ struct HiddenTitlebarSidebarControlsView: View { anchorView: viewModel.notificationsAnchorView ) }, - onNewTab: { - AppDelegate.shared?.performNewWorkspaceAction(debugSource: "titlebar.hiddenNewWorkspace") - }, + onNewTab: { _ = AppDelegate.shared?.tabManager?.addTab() }, visibilityMode: .onHover ) .frame(width: hostWidth, height: hostHeight, alignment: .leading) @@ -686,13 +683,13 @@ private final class TitlebarShortcutHintModifierMonitor: ObservableObject { } private func update(from modifierFlags: NSEvent.ModifierFlags, eventWindow: NSWindow?) { - guard ShortcutHintModifierPolicy.shouldShowCommandHints(for: modifierFlags), - ShortcutHintModifierPolicy.isCurrentWindow( - hostWindowNumber: hostWindow?.windowNumber, - hostWindowIsKey: hostWindow?.isKeyWindow ?? false, - eventWindowNumber: eventWindow?.windowNumber, - keyWindowNumber: NSApp.keyWindow?.windowNumber - ) else { + guard ShortcutHintModifierPolicy.shouldShowHints( + for: modifierFlags, + hostWindowNumber: hostWindow?.windowNumber, + hostWindowIsKey: hostWindow?.isKeyWindow ?? false, + eventWindowNumber: eventWindow?.windowNumber, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) else { cancelPendingHintShow(resetVisible: true) return } @@ -701,20 +698,19 @@ private final class TitlebarShortcutHintModifierMonitor: ObservableObject { } private func queueHintShow() { - if pendingShowWorkItem != nil || isModifierPressed { - return - } + guard !isModifierPressed else { return } + guard pendingShowWorkItem == nil else { return } let workItem = DispatchWorkItem { [weak self] in guard let self else { return } self.pendingShowWorkItem = nil - guard ShortcutHintModifierPolicy.shouldShowCommandHints(for: NSEvent.modifierFlags), - ShortcutHintModifierPolicy.isCurrentWindow( - hostWindowNumber: self.hostWindow?.windowNumber, - hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false, - eventWindowNumber: nil, - keyWindowNumber: NSApp.keyWindow?.windowNumber - ) else { return } + guard ShortcutHintModifierPolicy.shouldShowHints( + for: NSEvent.modifierFlags, + hostWindowNumber: self.hostWindow?.windowNumber, + hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false, + eventWindowNumber: nil, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) else { return } self.isModifierPressed = true } @@ -794,7 +790,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont self.notificationStore = notificationStore let toggleSidebar = { _ = AppDelegate.shared?.sidebarState?.toggle() } let toggleNotifications: () -> Void = { _ = AppDelegate.shared?.toggleNotificationsPopover(animated: true) } - let newTab = { _ = AppDelegate.shared?.performNewWorkspaceAction(debugSource: "titlebar.accessoryNewWorkspace") } + let newTab = { _ = AppDelegate.shared?.tabManager?.addTab() } hostingView = NonDraggableHostingView( rootView: TitlebarControlsView( notificationStore: notificationStore, @@ -1280,21 +1276,25 @@ final class UpdateTitlebarAccessoryController { let currentMode = WorkspacePresentationModeSettings.mode() guard currentMode != lastKnownPresentationMode else { return } lastKnownPresentationMode = currentMode - // Ensure accessories exist on all windows. TitlebarControlsAccessoryViewController - // handles its own visibility (hidden in minimal, visible in standard) via its - // UserDefaults observer, so we just need to make sure it's attached. + if currentMode == .minimal { + for window in NSApp.windows { + removeAccessoryIfPresent(from: window) + } + return + } + attachToExistingWindows() // When switching back to standard mode while a window is in fullscreen, // hide the accessories because fullscreen uses SwiftUI overlay controls. - if currentMode == .standard { - let controlsId = self.controlsIdentifier - for window in NSApp.windows where window.styleMask.contains(.fullScreen) { - for accessory in window.titlebarAccessoryViewControllers - where accessory.view.identifier == controlsId { - accessory.isHidden = true - accessory.view.alphaValue = 0 - } + let controlsId = self.controlsIdentifier + for window in NSApp.windows + where window.styleMask.contains(.fullScreen) + && window.cmuxSupportsTitlebarAccessoryControllers { + for accessory in window.titlebarAccessoryViewControllers + where accessory.view.identifier == controlsId { + accessory.isHidden = true + accessory.view.alphaValue = 0 } } } @@ -1331,6 +1331,14 @@ final class UpdateTitlebarAccessoryController { private func attachIfNeeded(to window: NSWindow) { guard !isSettingsWindow(window) else { return } + guard window.cmuxSupportsTitlebarAccessoryControllers else { + forgetWindow(window) + return + } + if WorkspacePresentationModeSettings.mode() == .minimal { + removeAccessoryIfPresent(from: window) + return + } // Window identifiers are assigned by SwiftUI via WindowAccessor, which can run // after didBecomeKey/didBecomeMain notifications. Retry briefly to avoid missing @@ -1379,6 +1387,10 @@ final class UpdateTitlebarAccessoryController { } private func removeAccessoryIfPresent(from window: NSWindow) { + guard window.cmuxSupportsTitlebarAccessoryControllers else { + forgetWindow(window) + return + } let matchingIndices = window.titlebarAccessoryViewControllers.indices.reversed().filter { index in let id = window.titlebarAccessoryViewControllers[index].view.identifier return id == controlsIdentifier @@ -1413,6 +1425,11 @@ final class UpdateTitlebarAccessoryController { #endif } + private func forgetWindow(_ window: NSWindow) { + attachedWindows.remove(window) + pendingAttachRetries.removeValue(forKey: ObjectIdentifier(window)) + } + private func isSettingsWindow(_ window: NSWindow) -> Bool { if window.identifier?.rawValue == "cmux.settings" { return true diff --git a/Sources/WindowDragHandleView.swift b/Sources/WindowDragHandleView.swift index 5fbfcee45da0..48a46608f567 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -1,5 +1,4 @@ import AppKit -import Bonsplit import SwiftUI private func windowDragHandleFormatPoint(_ point: NSPoint) -> String { @@ -271,14 +270,14 @@ func windowDragHandleShouldCaptureHit( ] ) #if DEBUG - cmuxDebugLog( + dlog( "titlebar.dragHandle.hitTest suppressionRecovered clearedDepth=\(clearedDepth) point=\(windowDragHandleFormatPoint(point))" ) #endif } else { #if DEBUG let depth = windowDragSuppressionDepth(window: dragHandleWindow) - cmuxDebugLog( + dlog( "titlebar.dragHandle.hitTest capture=false reason=suppressed depth=\(depth) point=\(windowDragHandleFormatPoint(point))" ) #endif @@ -297,7 +296,7 @@ func windowDragHandleShouldCaptureHit( let eventTypeDescription = eventType.map { String(describing: $0) } ?? "nil" let eventWindowNumber = eventWindow?.windowNumber ?? -1 let dragWindowNumber = dragHandleWindow?.windowNumber ?? -1 - cmuxDebugLog( + dlog( "titlebar.dragHandle.hitTest capture=false reason=passiveEvent eventType=\(eventTypeDescription) eventWindow=\(eventWindowNumber) dragWindow=\(dragWindowNumber) point=\(windowDragHandleFormatPoint(point))" ) #endif @@ -306,14 +305,14 @@ func windowDragHandleShouldCaptureHit( guard dragHandleView.bounds.contains(point) else { #if DEBUG - cmuxDebugLog("titlebar.dragHandle.hitTest capture=false reason=outside point=\(windowDragHandleFormatPoint(point))") + dlog("titlebar.dragHandle.hitTest capture=false reason=outside point=\(windowDragHandleFormatPoint(point))") #endif return false } guard let superview = dragHandleView.superview else { #if DEBUG - cmuxDebugLog("titlebar.dragHandle.hitTest capture=true reason=noSuperview point=\(windowDragHandleFormatPoint(point))") + dlog("titlebar.dragHandle.hitTest capture=true reason=noSuperview point=\(windowDragHandleFormatPoint(point))") #endif return true } @@ -324,7 +323,7 @@ func windowDragHandleShouldCaptureHit( // violation in the Swift runtime. guard !_windowDragHandleIsResolvingSiblingHits else { #if DEBUG - cmuxDebugLog("titlebar.dragHandle.hitTest capture=false reason=reentrant point=\(windowDragHandleFormatPoint(point))") + dlog("titlebar.dragHandle.hitTest capture=false reason=reentrant point=\(windowDragHandleFormatPoint(point))") #endif return false } @@ -347,14 +346,14 @@ func windowDragHandleShouldCaptureHit( let passiveHostHit = windowDragHandleShouldTreatTopHitAsPassiveHost(hitView) if passiveHostHit { #if DEBUG - cmuxDebugLog( + dlog( "titlebar.dragHandle.hitTest capture=defer point=\(windowDragHandleFormatPoint(point)) sibling=\(type(of: sibling)) hit=\(type(of: hitView)) passiveHost=true" ) #endif continue } #if DEBUG - cmuxDebugLog( + dlog( "titlebar.dragHandle.hitTest capture=false point=\(windowDragHandleFormatPoint(point)) siblingCount=\(siblingCount) sibling=\(type(of: sibling)) hit=\(type(of: hitView)) passiveHost=false" ) #endif @@ -374,7 +373,7 @@ func windowDragHandleShouldCaptureHit( } #if DEBUG - cmuxDebugLog("titlebar.dragHandle.hitTest capture=true point=\(windowDragHandleFormatPoint(point)) siblingCount=\(siblingCount)") + dlog("titlebar.dragHandle.hitTest capture=true point=\(windowDragHandleFormatPoint(point)) siblingCount=\(siblingCount)") #endif return true } @@ -410,7 +409,7 @@ struct WindowDragHandleView: NSViewRepresentable { eventWindow: currentEvent?.window ) #if DEBUG - cmuxDebugLog( + dlog( "titlebar.dragHandle.hitTestResult capture=\(shouldCapture) point=\(windowDragHandleFormatPoint(point)) window=\(window != nil)" ) #endif @@ -421,7 +420,7 @@ struct WindowDragHandleView: NSViewRepresentable { #if DEBUG let point = convert(event.locationInWindow, from: nil) let depth = windowDragSuppressionDepth(window: window) - cmuxDebugLog( + dlog( "titlebar.dragHandle.mouseDown point=\(windowDragHandleFormatPoint(point)) clickCount=\(event.clickCount) depth=\(depth)" ) #endif @@ -429,7 +428,7 @@ struct WindowDragHandleView: NSViewRepresentable { if event.clickCount >= 2 { let action = performStandardTitlebarDoubleClick(window: window) #if DEBUG - cmuxDebugLog("titlebar.dragHandle.mouseDownDoubleClick action=\(String(describing: action))") + dlog("titlebar.dragHandle.mouseDownDoubleClick action=\(String(describing: action))") #endif if action != nil { return @@ -438,7 +437,7 @@ struct WindowDragHandleView: NSViewRepresentable { guard !isWindowDragSuppressed(window: window) else { #if DEBUG - cmuxDebugLog("titlebar.dragHandle.mouseDownIgnored reason=suppressed") + dlog("titlebar.dragHandle.mouseDownIgnored reason=suppressed") #endif return } @@ -449,7 +448,7 @@ struct WindowDragHandleView: NSViewRepresentable { } #if DEBUG let restored = previousMovableState.map { String($0) } ?? "nil" - cmuxDebugLog("titlebar.dragHandle.mouseDownComplete restoredMovable=\(restored) nowMovable=\(window.isMovable)") + dlog("titlebar.dragHandle.mouseDownComplete restoredMovable=\(restored) nowMovable=\(window.isMovable)") #endif } else { super.mouseDown(with: event) diff --git a/Sources/WindowToolbarController.swift b/Sources/WindowToolbarController.swift index 5c9be3014543..d163784797fd 100644 --- a/Sources/WindowToolbarController.swift +++ b/Sources/WindowToolbarController.swift @@ -2,6 +2,12 @@ import AppKit import Combine import SwiftUI +extension NSWindow { + var cmuxSupportsTitlebarAccessoryControllers: Bool { + styleMask.contains(.titled) + } +} + @MainActor final class WindowToolbarController: NSObject, NSToolbarDelegate { private let commandItemIdentifier = NSToolbarItem.Identifier("cmux.focusedCommand") @@ -92,6 +98,7 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate { if !isMinimal { DispatchQueue.main.async { for window in NSApp.windows { + guard window.cmuxSupportsTitlebarAccessoryControllers else { continue } for accessory in window.titlebarAccessoryViewControllers { if !accessory.isHidden { accessory.view.needsLayout = true diff --git a/Sources/Windowing/WindowAppearanceSnapshot.swift b/Sources/Windowing/WindowAppearanceSnapshot.swift index f321b30701d3..f11e7ff15cea 100644 --- a/Sources/Windowing/WindowAppearanceSnapshot.swift +++ b/Sources/Windowing/WindowAppearanceSnapshot.swift @@ -13,7 +13,7 @@ enum GhosttyTerminalBackdropRenderingMode { enum WindowBackdropRole { case windowRoot case terminalCanvas - case bonsplitChrome + case workspaceSplitChrome case titlebar case leftSidebar case rightSidebar @@ -192,7 +192,7 @@ struct WindowAppearanceSnapshot { switch role { case .windowRoot: return terminalBackdropPolicy() - case .terminalCanvas, .bonsplitChrome, .titlebar, .browserSurface: + case .terminalCanvas, .workspaceSplitChrome, .titlebar, .browserSurface: return .clear case .leftSidebar, .rightSidebar: if unifySurfaceBackdrops { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 4bf639211c1c..32e1d3999203 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1,7 +1,6 @@ import Foundation import SwiftUI import AppKit -import Bonsplit import Combine import CryptoKit import Darwin @@ -36,7 +35,9 @@ struct CmuxSurfaceConfigTemplate { init(cConfig: ghostty_surface_config_s) { fontSize = cConfig.font_size if let workingDirectory = cConfig.working_directory { - self.workingDirectory = String(cString: workingDirectory, encoding: .utf8) + self.workingDirectory = cmuxNormalizedWorkingDirectory( + String(cString: workingDirectory, encoding: .utf8) + ) } if let command = cConfig.command { self.command = String(cString: command, encoding: .utf8) @@ -57,21 +58,19 @@ struct CmuxSurfaceConfigTemplate { } } -enum WorkspacePendingTerminalInputReason { - case configurationCommand +func cmuxNormalizedWorkingDirectory(_ raw: String?) -> String? { + let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return nil } + let expanded = NSString(string: trimmed).expandingTildeInPath + let normalized = expanded.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : normalized } -enum WorkspacePendingTerminalInputPolicy { - static func timeout(for reason: WorkspacePendingTerminalInputReason) -> TimeInterval? { - switch reason { - case .configurationCommand: - return 3.0 - } +func cmuxDefaultWorkingDirectory() -> String { + if let configured = cmuxNormalizedWorkingDirectory(GhosttyConfig.load().workingDirectory) { + return configured } -} - -private final class WorkspacePendingTerminalInputObserver: @unchecked Sendable { - var observer: NSObjectProtocol? + return FileManager.default.homeDirectoryForCurrentUser.path } func cmuxSurfaceContextName(_ context: ghostty_surface_context_e) -> String { @@ -99,7 +98,10 @@ func cmuxSurfacePointerAppearsLive(_ surface: ghostty_surface_t) -> Bool { // Best-effort check: reject pointers that no longer belong to an active // malloc zone allocation. A Swift wrapper around `ghostty_surface_t` can // remain non-nil after the backing native surface has already been freed. - cmuxPointerAppearsLive(surface) + guard TerminalSurfaceRegistry.shared.runtimeSurfaceOwnerId(surface) != nil else { + return false + } + return cmuxPointerAppearsLive(surface) } func cmuxCurrentSurfaceFontSizePoints(_ surface: ghostty_surface_t) -> Float? { @@ -120,7 +122,11 @@ func cmuxCurrentSurfaceFontSizePoints(_ surface: ghostty_surface_t) -> Float? { func cmuxInheritedSurfaceConfig( sourceSurface: ghostty_surface_t, context: ghostty_surface_context_e -) -> CmuxSurfaceConfigTemplate { +) -> CmuxSurfaceConfigTemplate? { + guard cmuxSurfacePointerAppearsLive(sourceSurface) else { + return nil + } + let inherited = ghostty_surface_inherited_config(sourceSurface, context) var config = CmuxSurfaceConfigTemplate(cConfig: inherited) @@ -135,7 +141,7 @@ func cmuxInheritedSurfaceConfig( let inheritedText = String(format: "%.2f", inherited.font_size) let runtimeText = runtimePoints.map { String(format: "%.2f", $0) } ?? "nil" let finalText = String(format: "%.2f", config.fontSize) - cmuxDebugLog( + dlog( "zoom.inherit context=\(cmuxSurfaceContextName(context)) " + "inherited=\(inheritedText) runtime=\(runtimeText) final=\(finalText)" ) @@ -192,6 +198,556 @@ private struct SessionPaneRestoreEntry { let snapshot: SessionPaneLayoutSnapshot } +struct WorkspaceBrowserTabChromeState: Equatable { + let iconImageData: Data? + let isLoading: Bool +} + +struct WorkspaceSurfaceState: Equatable { + var directory: String? + var title: String? + var customTitle: String? + var isPinned = false + var isManuallyUnread = false + var manualUnreadMarkedAt: Date? + var browserTabChromeState: WorkspaceBrowserTabChromeState? + var gitBranch: SidebarGitBranchState? + var pullRequest: SidebarPullRequestState? + var listeningPorts: [Int] = [] + var ttyName: String? + + var isEmpty: Bool { + directory == nil + && title == nil + && customTitle == nil + && isPinned == false + && isManuallyUnread == false + && manualUnreadMarkedAt == nil + && browserTabChromeState == nil + && gitBranch == nil + && pullRequest == nil + && listeningPorts.isEmpty + && ttyName == nil + } +} + +struct WorkspaceTabChromeProjectionState { + struct Entry: Equatable { + let title: String + let hasCustomTitle: Bool + let icon: String? + let iconImageData: Data? + let kind: WorkspaceLayoutTabKind? + let isDirty: Bool + let showsNotificationBadge: Bool + let isLoading: Bool + let isPinned: Bool + } + + let entriesByPanelId: [UUID: Entry] +} + +@MainActor +final class WorkspaceSurfaceRegistry: WorkspaceSurfaceRegistryProtocol { + private unowned let workspace: Workspace + private var retainedHosts: [WorkspacePaneMountIdentity: any WorkspaceRetainedSurfaceHost] = [:] + + init(workspace: Workspace) { + self.workspace = workspace + } + + func mountContent( + _ content: WorkspacePaneContent, + contentId: UUID, + in slotView: WorkspaceLayoutPaneContentSlotView, + activeDropZone: DropZone?, + onPresentationChange: (() -> Void)? = nil + ) { + retainedHost(for: content, contentId: contentId).mount( + content: content, + in: slotView, + activeDropZone: activeDropZone, + onPresentationChange: onPresentationChange + ) + } + + func unmountContent( + _ content: WorkspacePaneContent, + contentId: UUID, + from slotView: WorkspaceLayoutPaneContentSlotView + ) { + let identity = content.mountIdentity(contentId: contentId) + let didUnmount = retainedHosts[identity]?.unmount(from: slotView) ?? false + if case .placeholder = identity, didUnmount { + discardRetainedHost(identity) + } + } + + func removeSurface(surfaceId: UUID) { + discardRetainedHost(.terminal(surfaceId)) + discardRetainedHost(.browser(surfaceId)) + discardRetainedHost(.markdown(surfaceId)) + } + + func removeAllSurfaces() { + let identities = Array(retainedHosts.keys) + for identity in identities { + discardRetainedHost(identity) + } + } + + private func retainedHost( + for content: WorkspacePaneContent, + contentId: UUID + ) -> any WorkspaceRetainedSurfaceHost { + let identity = content.mountIdentity(contentId: contentId) + if let existing = retainedHosts[identity] { + return existing + } + let next = makeRetainedHost(for: content, contentId: contentId) + retainedHosts[identity] = next + return next + } + + private func makeRetainedHost( + for content: WorkspacePaneContent, + contentId: UUID + ) -> any WorkspaceRetainedSurfaceHost { + switch content { + case .terminal(let descriptor): + WorkspaceTerminalRetainedSurfaceHost( + workspace: workspace, + surfaceId: descriptor.surfaceId + ) + case .browser(let descriptor): + WorkspaceBrowserRetainedSurfaceHost( + workspace: workspace, + surfaceId: descriptor.surfaceId + ) + case .markdown(let descriptor): + WorkspaceMarkdownRetainedSurfaceHost( + workspace: workspace, + surfaceId: descriptor.surfaceId + ) + case .placeholder: + WorkspacePlaceholderRetainedSurfaceHost(contentId: contentId) + } + } + + private func discardRetainedHost(_ identity: WorkspacePaneMountIdentity) { + if let host = retainedHosts.removeValue(forKey: identity) { + host.prepareForSurfaceRemoval() + } + } + + func reconcileViewportLifecycle( + _ content: WorkspacePaneContent, + contentId: UUID, + in slotView: WorkspaceLayoutPaneContentSlotView, + reason: String + ) { + let identity = content.mountIdentity(contentId: contentId) + retainedHosts[identity]?.reconcileViewportLifecycle( + content: content, + in: slotView, + reason: reason + ) + } + + func presentationFacts( + _ content: WorkspacePaneContent, + contentId: UUID + ) -> WorkspaceSurfacePresentationFacts { + let identity = content.mountIdentity(contentId: contentId) + return retainedHosts[identity]?.presentationFacts( + content: content, + contentId: contentId + ) ?? .visible + } +} + +@MainActor +private protocol WorkspaceRetainedSurfaceHost: AnyObject { + var mountIdentity: WorkspacePaneMountIdentity { get } + + func mount( + content: WorkspacePaneContent, + in slotView: WorkspaceLayoutPaneContentSlotView, + activeDropZone: DropZone?, + onPresentationChange: (() -> Void)? + ) + + @discardableResult + func unmount(from slotView: WorkspaceLayoutPaneContentSlotView) -> Bool + + func reconcileViewportLifecycle( + content: WorkspacePaneContent, + in slotView: WorkspaceLayoutPaneContentSlotView, + reason: String + ) + + func presentationFacts( + content: WorkspacePaneContent, + contentId: UUID + ) -> WorkspaceSurfacePresentationFacts + + func prepareForSurfaceRemoval() +} + +private extension WorkspaceRetainedSurfaceHost { + func reconcileViewportLifecycle( + content _: WorkspacePaneContent, + in _: WorkspaceLayoutPaneContentSlotView, + reason _: String + ) {} + + func presentationFacts( + content _: WorkspacePaneContent, + contentId _: UUID + ) -> WorkspaceSurfacePresentationFacts { + .visible + } +} + +@MainActor +private final class WorkspaceTerminalRetainedSurfaceHost: WorkspaceRetainedSurfaceHost { + private struct MountedTerminalState: Equatable { + let presentation: WorkspaceTerminalPresentationState + } + + let mountIdentity: WorkspacePaneMountIdentity + + private unowned let workspace: Workspace + private let surfaceId: UUID + private var lastMountedState: MountedTerminalState? + + init(workspace: Workspace, surfaceId: UUID) { + self.workspace = workspace + self.surfaceId = surfaceId + mountIdentity = .terminal(surfaceId) + } + + func mount( + content: WorkspacePaneContent, + in slotView: WorkspaceLayoutPaneContentSlotView, + activeDropZone: DropZone?, + onPresentationChange: (() -> Void)? + ) { + guard case .terminal(let descriptor) = content, + let panel = workspace.panels[surfaceId] as? TerminalPanel else { + slotView.clearContentView() + return + } + + let hostedView = panel.hostedView + let desiredState = MountedTerminalState( + presentation: WorkspaceTerminalPresentationState( + isVisibleInUI: descriptor.isVisibleInUI, + isActive: descriptor.isFocused + ) + ) + + slotView.installContentView(hostedView) + hostedView.setPresentationChangeHandler(onPresentationChange) + + hostedView.setFocusHandler { descriptor.onFocus() } + hostedView.setTriggerFlashHandler(descriptor.onTriggerFlash) + hostedView.setInactiveOverlay( + color: descriptor.appearance.unfocusedOverlayNSColor, + opacity: CGFloat(descriptor.appearance.unfocusedOverlayOpacity), + visible: descriptor.isSplit && !descriptor.isFocused + ) + hostedView.setNotificationRing(visible: descriptor.hasUnreadNotification) + hostedView.setSearchOverlay(searchState: panel.searchState) + hostedView.syncKeyStateIndicator(text: panel.surface.currentKeyStateIndicatorText) + applyStateTransition( + on: hostedView, + from: lastMountedState, + to: desiredState, + reason: "workspace.terminalHost.mount" + ) + lastMountedState = desiredState + } + + @discardableResult + func unmount(from slotView: WorkspaceLayoutPaneContentSlotView) -> Bool { + let didUnmount = clearHostedView(detachingFrom: slotView) + slotView.clearContentView() + return didUnmount + } + + func prepareForSurfaceRemoval() { + clearHostedView() + } + + @discardableResult + private func clearHostedView(detachingFrom ownerView: NSView? = nil) -> Bool { + guard let panel = workspace.panels[surfaceId] as? TerminalPanel else { return false } + let hostedView = panel.hostedView + if let ownerView, hostedView.superview !== ownerView { + return false + } + applyStateTransition( + on: hostedView, + from: lastMountedState, + to: MountedTerminalState( + presentation: WorkspaceTerminalPresentationState( + isVisibleInUI: false, + isActive: false + ) + ), + reason: "workspace.terminalHost.clear" + ) + lastMountedState = nil + hostedView.setFocusHandler(nil) + hostedView.setTriggerFlashHandler(nil) + hostedView.setPresentationChangeHandler(nil) + hostedView.removeFromSuperview() + return true + } + + private func applyStateTransition( + on hostedView: GhosttySurfaceScrollView, + from previous: MountedTerminalState?, + to next: MountedTerminalState, + reason: String + ) { + let operations = WorkspaceTerminalPresentationTransitionResolver.operations( + previous: previous?.presentation, + next: next.presentation + ) + + for operation in operations { + switch operation { + case .setVisibleInUI(let visible): + hostedView.setVisibleInUI(visible) + case .setActive(let active): + hostedView.setActive(active) + case .requestFirstResponderReconcile: + hostedView.requestAutomaticFirstResponderApply(reason: reason) + } + } + } + + func reconcileViewportLifecycle( + content: WorkspacePaneContent, + in slotView: WorkspaceLayoutPaneContentSlotView, + reason: String + ) { + guard case .terminal = content, + let panel = workspace.panels[surfaceId] as? TerminalPanel else { + return + } + + let hostedView = panel.hostedView + guard hostedView.superview === slotView else { return } + hostedView.reconcileViewportLifecycle(reason: reason) + } + + func presentationFacts( + content: WorkspacePaneContent, + contentId _: UUID + ) -> WorkspaceSurfacePresentationFacts { + guard case .terminal = content, + let panel = workspace.panels[surfaceId] as? TerminalPanel else { + return .hidden + } + return panel.hostedView.presentationFacts + } +} + +@MainActor +private final class WorkspaceBrowserRetainedSurfaceHost: WorkspaceRetainedSurfaceHost { + let mountIdentity: WorkspacePaneMountIdentity + + private unowned let workspace: Workspace + private let surfaceId: UUID + private let hostView = BrowserPanelWorkspaceContentView(frame: .zero) + + init(workspace: Workspace, surfaceId: UUID) { + self.workspace = workspace + self.surfaceId = surfaceId + mountIdentity = .browser(surfaceId) + } + + func mount( + content: WorkspacePaneContent, + in slotView: WorkspaceLayoutPaneContentSlotView, + activeDropZone: DropZone?, + onPresentationChange _: (() -> Void)? + ) { + guard case .browser(let descriptor) = content, + let panel = workspace.panels[surfaceId] as? BrowserPanel else { + slotView.clearContentView() + return + } + + hostView.update( + panel: panel, + descriptor: descriptor, + activeDropZone: activeDropZone + ) + slotView.installContentView(hostView) + } + + @discardableResult + func unmount(from slotView: WorkspaceLayoutPaneContentSlotView) -> Bool { + let didUnmount = hostView.superview === slotView + if didUnmount { + hostView.prepareForRemoval(reason: "workspaceHostRemoval") + hostView.removeFromSuperview() + } + slotView.clearContentView() + return didUnmount + } + + func prepareForSurfaceRemoval() { + hostView.prepareForRemoval(reason: "surfaceRemoved") + hostView.removeFromSuperview() + } + + func presentationFacts( + content: WorkspacePaneContent, + contentId _: UUID + ) -> WorkspaceSurfacePresentationFacts { + guard case .browser(let descriptor) = content, descriptor.isVisibleInUI else { + return .hidden + } + return .visible + } +} + +@MainActor +private final class WorkspaceMarkdownRetainedSurfaceHost: WorkspaceRetainedSurfaceHost { + let mountIdentity: WorkspacePaneMountIdentity + + private unowned let workspace: Workspace + private let surfaceId: UUID + private let hostingController: NSHostingController + + init(workspace: Workspace, surfaceId: UUID) { + self.workspace = workspace + self.surfaceId = surfaceId + mountIdentity = .markdown(surfaceId) + hostingController = NSHostingController(rootView: AnyView(EmptyView())) + hostingController.view.translatesAutoresizingMaskIntoConstraints = true + hostingController.view.autoresizingMask = [.width, .height] + } + + func mount( + content: WorkspacePaneContent, + in slotView: WorkspaceLayoutPaneContentSlotView, + activeDropZone: DropZone?, + onPresentationChange _: (() -> Void)? + ) { + guard case .markdown(let descriptor) = content, + let panel = workspace.panels[surfaceId] as? MarkdownPanel else { + slotView.clearContentView() + return + } + + hostingController.rootView = AnyView( + MarkdownPanelView( + panel: panel, + isVisibleInUI: descriptor.isVisibleInUI, + onRequestPanelFocus: descriptor.onRequestPanelFocus + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .environment(\.paneDropZone, activeDropZone) + .transaction { tx in + tx.disablesAnimations = true + } + ) + + slotView.installContentView(hostingController.view) + } + + @discardableResult + func unmount(from slotView: WorkspaceLayoutPaneContentSlotView) -> Bool { + let didUnmount = hostingController.view.superview === slotView + if didUnmount { + hostingController.view.removeFromSuperview() + } + slotView.clearContentView() + return didUnmount + } + + func prepareForSurfaceRemoval() { + hostingController.view.removeFromSuperview() + } + + func presentationFacts( + content: WorkspacePaneContent, + contentId _: UUID + ) -> WorkspaceSurfacePresentationFacts { + guard case .markdown(let descriptor) = content, descriptor.isVisibleInUI else { + return .hidden + } + return .visible + } +} + +@MainActor +private final class WorkspacePlaceholderRetainedSurfaceHost: WorkspaceRetainedSurfaceHost { + let mountIdentity: WorkspacePaneMountIdentity + + private let contentId: UUID + private let hostingController: NSHostingController + + init(contentId: UUID) { + self.contentId = contentId + mountIdentity = .placeholder(contentId) + hostingController = NSHostingController(rootView: AnyView(EmptyView())) + hostingController.view.translatesAutoresizingMaskIntoConstraints = true + hostingController.view.autoresizingMask = [.width, .height] + } + + func mount( + content: WorkspacePaneContent, + in slotView: WorkspaceLayoutPaneContentSlotView, + activeDropZone _: DropZone?, + onPresentationChange _: (() -> Void)? + ) { + guard case .placeholder(let descriptor) = content else { + slotView.clearContentView() + return + } + + let rootView = AnyView( + EmptyPanelView(descriptor: descriptor) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .transaction { tx in + tx.disablesAnimations = true + } + ) + + hostingController.rootView = rootView + + slotView.installContentView(hostingController.view) + } + + @discardableResult + func unmount(from slotView: WorkspaceLayoutPaneContentSlotView) -> Bool { + let didUnmount = hostingController.view.superview === slotView + if didUnmount { + hostingController.view.removeFromSuperview() + } + slotView.clearContentView() + return didUnmount + } + + func prepareForSurfaceRemoval() { + hostingController.view.removeFromSuperview() + } + + func presentationFacts( + content _: WorkspacePaneContent, + contentId _: UUID + ) -> WorkspaceSurfacePresentationFacts { + .visible + } +} + private enum RemoteDropUploadError: LocalizedError { case unavailable case invalidFileURL @@ -264,11 +820,8 @@ extension Workspace { ) } - func sessionSnapshot( - includeScrollback: Bool, - restorableAgentIndex: RestorableAgentSessionIndex? = nil - ) -> SessionWorkspaceSnapshot { - let tree = bonsplitController.treeSnapshot() + func sessionSnapshot(includeScrollback: Bool) -> SessionWorkspaceSnapshot { + let tree = treeSnapshot() let layout = sessionLayoutSnapshot(from: tree) let orderedPanelIds = sidebarOrderedPanelIds() @@ -283,13 +836,7 @@ extension Workspace { let panelSnapshots = allPanelIds .prefix(SessionPersistencePolicy.maxPanelsPerWorkspace) - .compactMap { panelId in - sessionPanelSnapshot( - panelId: panelId, - includeScrollback: includeScrollback, - restorableAgent: restorableAgentIndex?.snapshot(workspaceId: id, panelId: panelId) - ) - } + .compactMap { sessionPanelSnapshot(panelId: $0, includeScrollback: includeScrollback) } let statusSnapshots = statusEntries.values .sorted { lhs, rhs in lhs.key < rhs.key } @@ -338,9 +885,14 @@ extension Workspace { func restoreSessionSnapshot(_ snapshot: SessionWorkspaceSnapshot) { restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) - restoredAgentSnapshotsByPanelId.removeAll(keepingCapacity: false) - restoredAgentAutoResumePendingPanelIds.removeAll(keepingCapacity: false) - invalidatedRestoredAgentFingerprintsByPanelId.removeAll(keepingCapacity: false) +#if DEBUG + startupLog( + "startup.restore.workspace.begin workspace=\(id.uuidString.prefix(5)) " + + "snapshotPanels=\(snapshot.panels.count) " + + "existingPanels=\(panels.count) " + + "focusedOld=\(snapshot.focusedPanelId?.uuidString.prefix(5) ?? "nil")" + ) +#endif let normalizedCurrentDirectory = snapshot.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) if !normalizedCurrentDirectory.isEmpty { @@ -361,7 +913,7 @@ extension Workspace { } pruneSurfaceMetadata(validSurfaceIds: Set(panels.keys)) - applySessionDividerPositions(snapshotNode: snapshot.layout, liveNode: bonsplitController.treeSnapshot()) + applySessionDividerPositions(snapshotNode: snapshot.layout, liveNode: treeSnapshot()) applyProcessTitle(snapshot.processTitle) setCustomTitle(snapshot.customTitle) @@ -398,6 +950,13 @@ extension Workspace { } else { scheduleFocusReconcile() } +#if DEBUG + startupLog( + "startup.restore.workspace.end workspace=\(id.uuidString.prefix(5)) " + + "panels=\(panels.count) panes=\(splitController.allPaneIds.count) " + + "focusedNow=\(focusedPanelId?.uuidString.prefix(5) ?? "nil")" + ) +#endif } private func sessionLayoutSnapshot(from node: ExternalTreeNode) -> SessionWorkspaceLayoutSnapshot { @@ -437,8 +996,18 @@ extension Workspace { private func sessionPanelID(forExternalTabIDString tabIDString: String) -> UUID? { guard let tabUUID = UUID(uuidString: tabIDString) else { return nil } - for (surfaceId, panelId) in surfaceIdToPanelId { - guard let surfaceUUID = sessionSurfaceUUID(for: surfaceId) else { continue } + + // New snapshots persist panel IDs directly. + if panels[tabUUID] != nil { + return tabUUID + } + + // Backward compatibility: older snapshots stored external surface IDs. + for panelId in panels.keys { + guard let surfaceId = surfaceIdFromPanelId(panelId), + let surfaceUUID = sessionSurfaceUUID(for: surfaceId) else { + continue + } if surfaceUUID == tabUUID { return panelId } @@ -447,50 +1016,45 @@ extension Workspace { } private func sessionSurfaceUUID(for surfaceId: TabID) -> UUID? { - struct EncodedSurfaceID: Decodable { + struct LegacyEncodedSurfaceID: Decodable { let id: UUID } + struct CurrentEncodedSurfaceID: Decodable { + let rawValue: UUID + } - guard let data = try? JSONEncoder().encode(surfaceId), - let decoded = try? JSONDecoder().decode(EncodedSurfaceID.self, from: data) else { + guard let data = try? JSONEncoder().encode(surfaceId) else { return nil } - return decoded.id + + if let decoded = try? JSONDecoder().decode(LegacyEncodedSurfaceID.self, from: data) { + return decoded.id + } + if let decoded = try? JSONDecoder().decode(CurrentEncodedSurfaceID.self, from: data) { + return decoded.rawValue + } + return nil } - private func sessionPanelSnapshot( - panelId: UUID, - includeScrollback: Bool, - restorableAgent: SessionRestorableAgentSnapshot? - ) -> SessionPanelSnapshot? { + private func sessionPanelSnapshot(panelId: UUID, includeScrollback: Bool) -> SessionPanelSnapshot? { guard let panel = panels[panelId] else { return nil } - - if let restorableAgent { - let fingerprint = TabManager.restorableAgentSnapshotFingerprint(restorableAgent) - if invalidatedRestoredAgentFingerprintsByPanelId[panelId] == fingerprint { - restoredAgentSnapshotsByPanelId.removeValue(forKey: panelId) - } else { - restoredAgentSnapshotsByPanelId[panelId] = restorableAgent - invalidatedRestoredAgentFingerprintsByPanelId.removeValue(forKey: panelId) - } - } - let effectiveRestorableAgent = restoredAgentSnapshotsByPanelId[panelId] + let surfaceState = surfaceStateSnapshot(panelId: panelId) let panelTitle = panelTitle(panelId: panelId) - let customTitle = panelCustomTitles[panelId] - let directory = panelDirectories[panelId] ?? effectiveRestorableAgent?.workingDirectory - let isPinned = pinnedPanelIds.contains(panelId) - let isManuallyUnread = manualUnreadPanelIds.contains(panelId) - let branchSnapshot = panelGitBranches[panelId].map { + let customTitle = surfaceState.customTitle + let directory = surfaceState.directory + let isPinned = surfaceState.isPinned + let isManuallyUnread = surfaceState.isManuallyUnread + let branchSnapshot = surfaceState.gitBranch.map { SessionGitBranchSnapshot(branch: $0.branch, isDirty: $0.isDirty) } let listeningPorts: [Int] if remoteDetectedSurfaceIds.contains(panelId) || isRemoteTerminalSurface(panelId) { listeningPorts = [] } else { - listeningPorts = (surfaceListeningPorts[panelId] ?? []).sorted() + listeningPorts = surfaceState.listeningPorts.sorted() } - let ttyName = surfaceTTYNames[panelId] + let ttyName = surfaceState.ttyName let terminalSnapshot: SessionTerminalPanelSnapshot? let browserSnapshot: SessionBrowserPanelSnapshot? @@ -513,9 +1077,8 @@ extension Workspace { allowFallbackScrollback: shouldPersistScrollback ) terminalSnapshot = SessionTerminalPanelSnapshot( - workingDirectory: directory, - scrollback: resolvedScrollback, - agent: effectiveRestorableAgent + workingDirectory: surfaceState.directory, + scrollback: resolvedScrollback ) browserSnapshot = nil markdownSnapshot = nil @@ -569,14 +1132,6 @@ extension Workspace { return SessionPersistencePolicy.truncatedScrollback(fallbackScrollback) } - nonisolated static func shouldReplaySessionScrollback( - restorableAgent: SessionRestorableAgentSnapshot? - ) -> Bool { - // Agent restores relaunch from the provider's session ID. Replaying the - // old TUI scrollback can print stale launch commands and race the resume input. - restorableAgent == nil - } - private func terminalSnapshotScrollback( panelId: UUID, capturedScrollback: String?, @@ -599,7 +1154,7 @@ extension Workspace { } private func restoreSessionLayout(_ layout: SessionWorkspaceLayoutSnapshot) -> [SessionPaneRestoreEntry] { - guard let rootPaneId = bonsplitController.allPaneIds.first else { + guard let rootPaneId = splitController.allPaneIds.first else { return [] } @@ -617,18 +1172,15 @@ extension Workspace { case .pane(let pane): leaves.append(SessionPaneRestoreEntry(paneId: paneId, snapshot: pane)) case .split(let split): - var anchorPanelId = bonsplitController - .tabs(inPane: paneId) - .compactMap { panelIdFromSurfaceId($0.id) } - .first + var anchorPanelId = surfaceIds(inPane: paneId).first if anchorPanelId == nil { - anchorPanelId = newTerminalSurface(inPane: paneId, focus: false)?.id + anchorPanelId = createTerminalPanel(inPane: paneId, focus: false)?.id } guard let anchorPanelId, - let newSplitPanel = newTerminalSplit( - from: anchorPanelId, + let newSplitPanel = splitTerminalPanel( + fromPanelId: anchorPanelId, orientation: split.orientation.splitOrientation, insertFirst: false, focus: false @@ -654,10 +1206,15 @@ extension Workspace { panelSnapshotsById: [UUID: SessionPanelSnapshot], oldToNewPanelIds: inout [UUID: UUID] ) { - let existingPanelIds = bonsplitController - .tabs(inPane: paneId) - .compactMap { panelIdFromSurfaceId($0.id) } + let existingPanelIds = surfaceIds(inPane: paneId) let desiredOldPanelIds = snapshot.panelIds.filter { panelSnapshotsById[$0] != nil } +#if DEBUG + startupLog( + "startup.restore.pane.begin workspace=\(id.uuidString.prefix(5)) " + + "pane=\(paneId.id.uuidString.prefix(5)) existing=\(existingPanelIds.count) " + + "desired=\(desiredOldPanelIds.count) selectedOld=\(snapshot.selectedPanelId?.uuidString.prefix(5) ?? "nil")" + ) +#endif var createdPanelIds: [UUID] = [] for oldPanelId in desiredOldPanelIds { @@ -686,100 +1243,118 @@ extension Workspace { if let selectedPanelId, let selectedTabId = surfaceIdFromPanelId(selectedPanelId) { - bonsplitController.focusPane(paneId) - bonsplitController.selectTab(selectedTabId) + splitController.focusPane(paneId) + splitController.selectTab(selectedTabId) } +#if DEBUG + startupLog( + "startup.restore.pane.end workspace=\(id.uuidString.prefix(5)) " + + "pane=\(paneId.id.uuidString.prefix(5)) created=\(createdPanelIds.count) " + + "selectedNew=\(selectedPanelId?.uuidString.prefix(5) ?? "nil")" + ) +#endif } private func createPanel(from snapshot: SessionPanelSnapshot, inPane paneId: PaneID) -> UUID? { - switch snapshot.type { - case .terminal: - let workingDirectory = - snapshot.terminal?.workingDirectory - ?? snapshot.terminal?.agent?.workingDirectory - ?? snapshot.directory - ?? currentDirectory - let restorableAgent = snapshot.terminal?.agent - let shouldReplayScrollback = Self.shouldReplaySessionScrollback( - restorableAgent: restorableAgent - ) - let restoredAgentResumeInput = restorableAgent?.resumeStartupInput() #if DEBUG - if let restorableAgent { - let sessionPreview = String(restorableAgent.sessionId.prefix(8)) - let launchArgc = restorableAgent.launchCommand?.arguments.count ?? 0 - cmuxDebugLog( - "session.restore.agent panel=\(snapshot.id.uuidString.prefix(5)) " + - "kind=\(restorableAgent.kind.rawValue) session=\(sessionPreview) " + - "hasLaunch=\(restorableAgent.launchCommand == nil ? 0 : 1) " + - "launchArgc=\(launchArgc) hasResume=\(restoredAgentResumeInput == nil ? 0 : 1) " + - "replayScrollback=\(shouldReplayScrollback ? 1 : 0)" - ) - } + startupLog( + "startup.restore.createPanel workspace=\(id.uuidString.prefix(5)) " + + "pane=\(paneId.id.uuidString.prefix(5)) oldPanel=\(snapshot.id.uuidString.prefix(5)) " + + "type=\(snapshot.type.rawValue)" + ) #endif + switch snapshot.type { + case .terminal: + let workingDirectory = snapshot.terminal?.workingDirectory ?? snapshot.directory ?? currentDirectory let replayEnvironment = SessionScrollbackReplayStore.replayEnvironment( - for: shouldReplayScrollback ? snapshot.terminal?.scrollback : nil + for: snapshot.terminal?.scrollback ) - guard let terminalPanel = newTerminalSurface( + guard let terminalPanel = createTerminalPanel( inPane: paneId, focus: false, workingDirectory: workingDirectory, - initialInput: restoredAgentResumeInput, startupEnvironment: replayEnvironment ) else { +#if DEBUG + startupLog( + "startup.restore.createPanel.fail workspace=\(id.uuidString.prefix(5)) " + + "pane=\(paneId.id.uuidString.prefix(5)) oldPanel=\(snapshot.id.uuidString.prefix(5)) " + + "type=terminal" + ) +#endif return nil } - let fallbackScrollback = shouldReplayScrollback - ? SessionPersistencePolicy.truncatedScrollback(snapshot.terminal?.scrollback) - : nil + let fallbackScrollback = SessionPersistencePolicy.truncatedScrollback(snapshot.terminal?.scrollback) if let fallbackScrollback { restoredTerminalScrollbackByPanelId[terminalPanel.id] = fallbackScrollback } else { restoredTerminalScrollbackByPanelId.removeValue(forKey: terminalPanel.id) } - if let restorableAgent { - restoredAgentSnapshotsByPanelId[terminalPanel.id] = restorableAgent - if restoredAgentResumeInput != nil { - restoredAgentAutoResumePendingPanelIds.insert(terminalPanel.id) - } else { - restoredAgentAutoResumePendingPanelIds.remove(terminalPanel.id) - } - invalidatedRestoredAgentFingerprintsByPanelId.removeValue(forKey: terminalPanel.id) - } else { - restoredAgentSnapshotsByPanelId.removeValue(forKey: terminalPanel.id) - restoredAgentAutoResumePendingPanelIds.remove(terminalPanel.id) - invalidatedRestoredAgentFingerprintsByPanelId.removeValue(forKey: terminalPanel.id) - } applySessionPanelMetadata(snapshot, toPanelId: terminalPanel.id) +#if DEBUG + startupLog( + "startup.restore.createPanel.ok workspace=\(id.uuidString.prefix(5)) " + + "pane=\(paneId.id.uuidString.prefix(5)) oldPanel=\(snapshot.id.uuidString.prefix(5)) " + + "newPanel=\(terminalPanel.id.uuidString.prefix(5)) type=terminal" + ) +#endif return terminalPanel.id case .browser: - guard let browserPanel = newBrowserSurface( + guard let browserPanel = createBrowserPanel( inPane: paneId, url: nil, focus: false, preferredProfileID: snapshot.browser?.profileID ) else { +#if DEBUG + startupLog( + "startup.restore.createPanel.fail workspace=\(id.uuidString.prefix(5)) " + + "pane=\(paneId.id.uuidString.prefix(5)) oldPanel=\(snapshot.id.uuidString.prefix(5)) " + + "type=browser" + ) +#endif return nil } applySessionPanelMetadata(snapshot, toPanelId: browserPanel.id) - return browserPanel.id +#if DEBUG + startupLog( + "startup.restore.createPanel.ok workspace=\(id.uuidString.prefix(5)) " + + "pane=\(paneId.id.uuidString.prefix(5)) oldPanel=\(snapshot.id.uuidString.prefix(5)) " + + "newPanel=\(browserPanel.id.uuidString.prefix(5)) type=browser" + ) +#endif + return browserPanel.id case .markdown: guard let filePath = snapshot.markdown?.filePath, - let markdownPanel = newMarkdownSurface( + let markdownPanel = createMarkdownPanel( inPane: paneId, filePath: filePath, focus: false ) else { +#if DEBUG + startupLog( + "startup.restore.createPanel.fail workspace=\(id.uuidString.prefix(5)) " + + "pane=\(paneId.id.uuidString.prefix(5)) oldPanel=\(snapshot.id.uuidString.prefix(5)) " + + "type=markdown" + ) +#endif return nil } applySessionPanelMetadata(snapshot, toPanelId: markdownPanel.id) +#if DEBUG + startupLog( + "startup.restore.createPanel.ok workspace=\(id.uuidString.prefix(5)) " + + "pane=\(paneId.id.uuidString.prefix(5)) oldPanel=\(snapshot.id.uuidString.prefix(5)) " + + "newPanel=\(markdownPanel.id.uuidString.prefix(5)) type=markdown" + ) +#endif return markdownPanel.id } } private func applySessionPanelMetadata(_ snapshot: SessionPanelSnapshot, toPanelId panelId: UUID) { if let title = snapshot.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { - panelTitles[panelId] = title + updateSurfaceState(panelId: panelId) { $0.title = title } } setPanelCustomTitle(panelId: panelId, title: snapshot.customTitle) @@ -796,17 +1371,21 @@ extension Workspace { } if let branch = snapshot.gitBranch { - panelGitBranches[panelId] = SidebarGitBranchState(branch: branch.branch, isDirty: branch.isDirty) + updateSurfaceState(panelId: panelId) { + $0.gitBranch = SidebarGitBranchState(branch: branch.branch, isDirty: branch.isDirty) + } } else { - panelGitBranches.removeValue(forKey: panelId) + updateSurfaceState(panelId: panelId) { $0.gitBranch = nil } } - surfaceListeningPorts[panelId] = Array(Set(snapshot.listeningPorts)).sorted() + updateSurfaceState(panelId: panelId) { + $0.listeningPorts = Array(Set(snapshot.listeningPorts)).sorted() + } if let ttyName = snapshot.ttyName?.trimmingCharacters(in: .whitespacesAndNewlines), !ttyName.isEmpty { - surfaceTTYNames[panelId] = ttyName + updateSurfaceState(panelId: panelId) { $0.ttyName = ttyName } } else { - surfaceTTYNames.removeValue(forKey: panelId) + updateSurfaceState(panelId: panelId) { $0.ttyName = nil } } syncRemotePortScanTTYs() @@ -835,8 +1414,8 @@ extension Workspace { switch (snapshotNode, liveNode) { case (.split(let snapshotSplit), .split(let liveSplit)): if let splitID = UUID(uuidString: liveSplit.id) { - _ = bonsplitController.setDividerPosition( - CGFloat(snapshotSplit.dividerPosition), + _ = setDividerPosition( + snapshotSplit.dividerPosition, forSplit: splitID, fromExternal: true ) @@ -854,7 +1433,7 @@ extension Workspace { extension Workspace { func applyCustomLayout(_ layout: CmuxLayoutNode, baseCwd: String) { - guard let rootPaneId = bonsplitController.allPaneIds.first else { return } + guard let rootPaneId = splitController.allPaneIds.first else { return } var leaves: [(paneId: PaneID, surfaces: [CmuxSurfaceDefinition])] = [] buildCustomLayoutTree(layout, inPane: rootPaneId, leaves: &leaves) @@ -867,7 +1446,7 @@ extension Workspace { populateCustomPane(leaf.paneId, surfaces: leaf.surfaces, baseCwd: baseCwd, focusPanelId: &focusPanelId) } - let liveRoot = bonsplitController.treeSnapshot() + let liveRoot = treeSnapshot() applyCustomDividerPositions(configNode: layout, liveNode: liveRoot) if let focusPanelId { @@ -891,18 +1470,15 @@ extension Workspace { return } - var anchorPanelId = bonsplitController - .tabs(inPane: paneId) - .compactMap { panelIdFromSurfaceId($0.id) } - .first + var anchorPanelId = surfaceIds(inPane: paneId).first if anchorPanelId == nil { - anchorPanelId = newTerminalSurface(inPane: paneId, focus: false)?.id + anchorPanelId = createTerminalPanel(inPane: paneId, focus: false)?.id } guard let anchorPanelId, - let newSplitPanel = newTerminalSplit( - from: anchorPanelId, + let newSplitPanel = splitTerminalPanel( + fromPanelId: anchorPanelId, orientation: split.splitOrientation, insertFirst: false, focus: false @@ -923,9 +1499,7 @@ extension Workspace { baseCwd: String, focusPanelId: inout UUID? ) { - let existingPanelIds = bonsplitController - .tabs(inPane: paneId) - .compactMap { panelIdFromSurfaceId($0.id) } + let existingPanelIds = surfaceIds(inPane: paneId) guard !surfaces.isEmpty else { return } @@ -961,7 +1535,7 @@ extension Workspace { case .terminal where surface.cwd != nil || surface.env != nil: // Placeholder can't change cwd/env — replace it let resolvedCwd = CmuxConfigStore.resolveCwd(surface.cwd, relativeTo: baseCwd) - if let panel = newTerminalSurface( + if let panel = createTerminalPanel( inPane: paneId, focus: false, workingDirectory: resolvedCwd, @@ -982,7 +1556,7 @@ extension Workspace { case .browser: let url = surface.url.flatMap { URL(string: $0) } - if let panel = newBrowserSurface(inPane: paneId, url: url, focus: false) { + if let panel = createBrowserPanel(inPane: paneId, url: url, focus: false) { _ = closePanel(panelId, force: true) if let name = surface.name { setPanelCustomTitle(panelId: panel.id, title: name) } if surface.focus == true { focusPanelId = panel.id } @@ -999,7 +1573,7 @@ extension Workspace { switch surface.type { case .terminal: let resolvedCwd = CmuxConfigStore.resolveCwd(surface.cwd, relativeTo: baseCwd) - if let panel = newTerminalSurface( + if let panel = createTerminalPanel( inPane: paneId, focus: false, workingDirectory: resolvedCwd, @@ -1012,7 +1586,7 @@ extension Workspace { case .browser: let url = surface.url.flatMap { URL(string: $0) } - if let panel = newBrowserSurface(inPane: paneId, url: url, focus: false) { + if let panel = createBrowserPanel(inPane: paneId, url: url, focus: false) { if let name = surface.name { setPanelCustomTitle(panelId: panel.id, title: name) } if surface.focus == true { focusPanelId = panel.id } } @@ -1026,8 +1600,8 @@ extension Workspace { switch (configNode, liveNode) { case (.split(let configSplit), .split(let liveSplit)): if let splitID = UUID(uuidString: liveSplit.id) { - _ = bonsplitController.setDividerPosition( - CGFloat(configSplit.clampedSplitPosition), + _ = setDividerPosition( + configSplit.clampedSplitPosition, forSplit: splitID, fromExternal: true ) @@ -1041,94 +1615,16 @@ extension Workspace { } } - private func sendInputWhenReady( - _ text: String, - to panel: TerminalPanel, - reason: WorkspacePendingTerminalInputReason = .configurationCommand - ) { + private func sendInputWhenReady(_ text: String, to panel: TerminalPanel) { if panel.surface.surface != nil { panel.sendInput(text) return } - let timeout = WorkspacePendingTerminalInputPolicy.timeout(for: reason) - let panelId = panel.id - let registration = WorkspacePendingTerminalInputObserver() - - registration.observer = NotificationCenter.default.addObserver( - forName: .terminalSurfaceDidBecomeReady, - object: panel.surface, - queue: .main - ) { [weak self, registration] _ in - Task { @MainActor [weak self, registration] in - guard - let self, - self.hasPendingTerminalInputObserver(registration, forPanelId: panelId) - else { - return - } - - self.removePendingTerminalInputObserver(registration, forPanelId: panelId) - if let panel = self.panels[panelId] as? TerminalPanel { - panel.sendInput(text) - } - } - } - pendingTerminalInputObserversByPanelId[panelId, default: []].append(registration) - - guard let timeout else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { [weak self, registration] in - Task { @MainActor [weak self, registration] in - guard - let self, - self.hasPendingTerminalInputObserver(registration, forPanelId: panelId) - else { - return - } - - self.removePendingTerminalInputObserver(registration, forPanelId: panelId) - NSLog("[CmuxConfig] surface not ready after 3s, dropping command (%d chars)", text.count) - } - } - } - - private func hasPendingTerminalInputObserver( - _ registration: WorkspacePendingTerminalInputObserver, - forPanelId panelId: UUID - ) -> Bool { - pendingTerminalInputObserversByPanelId[panelId]?.contains { - $0 === registration - } == true - } - - private func removePendingTerminalInputObserver( - _ registration: WorkspacePendingTerminalInputObserver, - forPanelId panelId: UUID - ) { - if let observer = registration.observer { - NotificationCenter.default.removeObserver(observer) - registration.observer = nil - } - pendingTerminalInputObserversByPanelId[panelId]?.removeAll { - $0 === registration - } - if pendingTerminalInputObserversByPanelId[panelId]?.isEmpty == true { - pendingTerminalInputObserversByPanelId.removeValue(forKey: panelId) - } - } - - private func removePendingTerminalInputObservers(forPanelId panelId: UUID) { - guard let observers = pendingTerminalInputObserversByPanelId.removeValue(forKey: panelId) else { - return - } - for registration in observers { - if let observer = registration.observer { - NotificationCenter.default.removeObserver(observer) - registration.observer = nil - } + _ = panel.surface.onRuntimeReady { [weak panel] in + panel?.sendInput(text) } } - } final class WorkspaceRemoteDaemonPendingCallRegistry { @@ -5527,7 +6023,7 @@ final class WorkspaceRemoteSessionController { private func debugLog(_ message: @autoclosure () -> String) { #if DEBUG - cmuxDebugLog(message()) + dlog(message()) #endif } @@ -6928,7 +7424,7 @@ enum SidebarBranchOrdering { case .pane(let pane): return [pane.id] case .split(let split): - // Bonsplit split order matches visual order for both horizontal and vertical splits. + // WorkspaceSplit split order matches visual order for both horizontal and vertical splits. return orderedPaneIds(tree: split.first) + orderedPaneIds(tree: split.second) } } @@ -7169,8 +7665,59 @@ struct ClosedBrowserPanelRestoreSnapshot { let fallbackAnchorPaneId: UUID? } -/// Workspace represents a sidebar tab. -/// Each workspace contains one BonsplitController that manages split panes and nested surfaces. +private struct WorkspaceFocusTransaction: Equatable { + let id: UInt64 + let target: WorkspaceFocusTarget + let reason: String +} + +private struct WorkspaceFocusTransactionCoordinator { + private var nextTransactionId: UInt64 = 1 + private(set) var active: WorkspaceFocusTransaction? + private(set) var lastTarget: WorkspaceFocusTarget = .none + private(set) var lastActual: WorkspaceFocusActual = .none + + mutating func begin(target: WorkspaceFocusTarget, reason: String) -> WorkspaceFocusTransaction { + let transaction = WorkspaceFocusTransaction( + id: nextTransactionId, + target: target, + reason: reason + ) + nextTransactionId &+= 1 + active = transaction + lastTarget = target +#if DEBUG + dlog( + "focus.tx.begin id=\(transaction.id) " + + "target=\(target.debugDescription) reason=\(reason)" + ) +#endif + return transaction + } + + mutating func end(_ transaction: WorkspaceFocusTransaction, actual: WorkspaceFocusActual) { + guard active == transaction else { +#if DEBUG + dlog( + "focus.tx.end.stale id=\(transaction.id) " + + "target=\(transaction.target.debugDescription) actual=\(actual.debugDescription) " + + "active=\(active?.id.description ?? "nil")" + ) +#endif + return + } + active = nil + lastActual = actual +#if DEBUG + dlog( + "focus.tx.end id=\(transaction.id) " + + "target=\(transaction.target.debugDescription) actual=\(actual.debugDescription) " + + "reason=\(transaction.reason)" + ) +#endif + } +} + enum WorkspaceSurfaceIdentifierClipboardText { static func make(workspaceId: UUID, workspaceRef: String? = nil) -> String { var lines: [String] = [] @@ -7218,6 +7765,8 @@ enum WorkspaceSurfaceIdentifierClipboardText { } } +/// Workspace represents a sidebar tab. +/// Each workspace contains one WorkspaceLayoutController that manages split panes and nested surfaces. @MainActor final class Workspace: Identifiable, ObservableObject { static let terminalScrollBarHiddenDidChangeNotification = Notification.Name( @@ -7232,27 +7781,19 @@ final class Workspace: Identifiable, ObservableObject { @Published var customColor: String? // hex string, e.g. "#C0392B" @Published private(set) var terminalScrollBarHidden: Bool = false @Published var currentDirectory: String - @Published private(set) var surfaceTabBarDirectory: String? private(set) var preferredBrowserProfileID: UUID? /// Ordinal for CMUX_PORT range assignment (monotonically increasing per app session) var portOrdinal: Int = 0 - /// The bonsplit controller managing the split panes for this workspace - let bonsplitController: BonsplitController - private struct SurfaceTabBarExecutableButton { - let button: CmuxSurfaceTabBarButton - let workspaceCommand: CmuxResolvedCommand? - let terminalCommandSourcePath: String? - } - - private var surfaceTabBarCommandButtons: [String: SurfaceTabBarExecutableButton] = [:] - private var surfaceTabBarButtonSourcePath: String? - private var surfaceTabBarButtonGlobalConfigPath: String? + /// The WorkspaceSplit controller managing the split panes for this workspace + let splitController: WorkspaceLayoutController - /// Mapping from bonsplit TabID to our Panel instances + /// Mapping from WorkspaceSplit TabID to our Panel instances @Published private(set) var panels: [UUID: any Panel] = [:] + lazy var surfaceRegistry = WorkspaceSurfaceRegistry(workspace: self) + /// Subscriptions for panel updates (e.g., browser title changes) private var panelSubscriptions: [UUID: AnyCancellable] = [:] @@ -7279,11 +7820,12 @@ final class Workspace: Identifiable, ObservableObject { /// The currently focused pane's panel ID var focusedPanelId: UUID? { - guard let paneId = bonsplitController.focusedPaneId, - let tab = bonsplitController.selectedTab(inPane: paneId) else { + guard let paneId = splitController.focusedPaneId, + let tabId = splitController.selectedTabId(inPane: paneId), + panels[tabId.id] != nil else { return nil } - return panelIdFromSurfaceId(tab.id) + return tabId.id } /// The currently focused terminal panel (if any) @@ -7296,7 +7838,11 @@ final class Workspace: Identifiable, ObservableObject { } func effectiveSelectedPanelId(inPane paneId: PaneID) -> UUID? { - bonsplitController.selectedTab(inPane: paneId).flatMap { panelIdFromSurfaceId($0.id) } + guard let surfaceId = splitController.selectedTabId(inPane: paneId), + panels[surfaceId.id] != nil else { + return nil + } + return surfaceId.id } enum FocusPanelTrigger { @@ -7304,17 +7850,12 @@ final class Workspace: Identifiable, ObservableObject { case terminalFirstResponder } - /// Published directory for each panel - @Published var panelDirectories: [UUID: String] = [:] - @Published var panelTitles: [UUID: String] = [:] - @Published private(set) var panelCustomTitles: [UUID: String] = [:] - @Published private(set) var pinnedPanelIds: Set = [] - @Published private(set) var manualUnreadPanelIds: Set = [] + /// Canonical runtime metadata for every live surface in this workspace. + @Published private var surfaceStatesByPanelId: [UUID: WorkspaceSurfaceState] = [:] @Published private(set) var tmuxLayoutSnapshot: LayoutSnapshot? @Published private(set) var tmuxWorkspaceFlashPanelId: UUID? @Published private(set) var tmuxWorkspaceFlashReason: WorkspaceAttentionFlashReason? @Published private(set) var tmuxWorkspaceFlashToken: UInt64 = 0 - private var manualUnreadMarkedAt: [UUID: Date] = [:] nonisolated private static let manualUnreadFocusGraceInterval: TimeInterval = 0.2 nonisolated private static let manualUnreadClearDelayAfterFocusFlash: TimeInterval = 0.2 @Published var statusEntries: [String: SidebarStatusEntry] = [:] @@ -7322,10 +7863,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 pullRequest: SidebarPullRequestState? - @Published var panelPullRequests: [UUID: SidebarPullRequestState] = [:] - @Published var surfaceListeningPorts: [UUID: [Int]] = [:] var agentListeningPorts: [Int] = [] @Published var remoteConfiguration: WorkspaceRemoteConfiguration? @Published var remoteConnectionState: WorkspaceRemoteConnectionState = .disconnected @@ -7339,7 +7877,6 @@ final class Workspace: Identifiable, ObservableObject { @Published var remoteLastHeartbeatAt: Date? @Published var listeningPorts: [Int] = [] @Published private(set) var activeRemoteTerminalSessionCount: Int = 0 - var surfaceTTYNames: [UUID: String] = [:] private var remoteSessionController: WorkspaceRemoteSessionController? private var pendingRemoteForegroundAuthToken: String? fileprivate var activeRemoteSessionControllerID: UUID? @@ -7373,10 +7910,90 @@ final class Workspace: Identifiable, ObservableObject { /// Used for stale-session detection: if the PID is dead, the status entry is cleared. var agentPIDs: [String: pid_t] = [:] private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] - private var restoredAgentSnapshotsByPanelId: [UUID: SessionRestorableAgentSnapshot] = [:] - private var restoredAgentAutoResumePendingPanelIds: Set = [] - private var invalidatedRestoredAgentFingerprintsByPanelId: [UUID: Int] = [:] - private var pendingTerminalInputObserversByPanelId: [UUID: [WorkspacePendingTerminalInputObserver]] = [:] + + private func mutateSurfaceStates( + _ transform: ([UUID: WorkspaceSurfaceState]) -> [UUID: WorkspaceSurfaceState] + ) { + let next = transform(surfaceStatesByPanelId).filter { _, state in + !state.isEmpty + } + guard next != surfaceStatesByPanelId else { return } + surfaceStatesByPanelId = next + } + + func surfaceStateSnapshot(panelId: UUID) -> WorkspaceSurfaceState { + surfaceStatesByPanelId[panelId] ?? WorkspaceSurfaceState() + } + + func surfaceStatesSnapshot() -> [UUID: WorkspaceSurfaceState] { + surfaceStatesByPanelId + } + + func panelDirectory(panelId: UUID) -> String? { + surfaceStateSnapshot(panelId: panelId).directory + } + + func surfaceTTYName(panelId: UUID) -> String? { + let ttyName = surfaceStateSnapshot(panelId: panelId).ttyName? + .trimmingCharacters(in: .whitespacesAndNewlines) + return ttyName?.isEmpty == false ? ttyName : nil + } + + func setSurfaceTTYName(panelId: UUID, ttyName: String?) { + let trimmedTTYName = ttyName?.trimmingCharacters(in: .whitespacesAndNewlines) + updateSurfaceState(panelId: panelId) { state in + state.ttyName = trimmedTTYName?.isEmpty == false ? trimmedTTYName : nil + } + } + + func surfaceListeningPorts(panelId: UUID) -> [Int] { + surfaceStateSnapshot(panelId: panelId).listeningPorts + } + + func setSurfaceListeningPorts(panelId: UUID, ports: [Int]) { + updateSurfaceState(panelId: panelId) { state in + state.listeningPorts = ports + } + } + + func clearAllSurfaceListeningPorts() { + mutateSurfaceStates { states in + var next = states + for id in next.keys { + next[id]?.listeningPorts = [] + } + return next + } + } + + func isPanelManuallyUnread(_ panelId: UUID) -> Bool { + surfaceStateSnapshot(panelId: panelId).isManuallyUnread + } + + func hasPanelCustomTitle(panelId: UUID) -> Bool { + surfaceStateSnapshot(panelId: panelId).customTitle != nil + } + + private func updateSurfaceState( + panelId: UUID, + _ update: (inout WorkspaceSurfaceState) -> Void + ) { + mutateSurfaceStates { states in + var next = states + var state = next[panelId] ?? WorkspaceSurfaceState() + update(&state) + next[panelId] = state + return next + } + } + + private func removeSurfaceState(panelId: UUID) { + mutateSurfaceStates { states in + var next = states + next.removeValue(forKey: panelId) + return next + } + } private func sidebarObservationSignal( _ publisher: Published.Publisher @@ -7409,15 +8026,13 @@ final class Workspace: Identifiable, ObservableObject { .removeDuplicates() .map { _ in () } .eraseToAnyPublisher(), - sidebarObservationSignal($panelDirectories), + sidebarObservationSignal($surfaceStatesByPanelId), sidebarObservationSignal($statusEntries), sidebarObservationSignal($metadataBlocks), sidebarObservationSignal($logEntries), sidebarObservationSignal($progress), sidebarObservationSignal($gitBranch), - sidebarObservationSignal($panelGitBranches), sidebarObservationSignal($pullRequest), - sidebarObservationSignal($panelPullRequests), sidebarObservationSignal($remoteConfiguration), sidebarObservationSignal($remoteConnectionState), sidebarObservationSignal($remoteConnectionDetail), @@ -7462,19 +8077,9 @@ final class Workspace: Identifiable, ObservableObject { } var focusedSurfaceId: UUID? { focusedPanelId } - var surfaceDirectories: [UUID: String] { - get { panelDirectories } - set { panelDirectories = newValue } - } private var processTitle: String - private enum SurfaceKind { - static let terminal = "terminal" - static let browser = "browser" - static let markdown = "markdown" - } - enum PanelShellActivityState: String { case unknown case promptIdle @@ -7497,8 +8102,9 @@ final class Workspace: Identifiable, ObservableObject { // MARK: - Initialization - private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips { - BonsplitConfiguration.SplitButtonTooltips( + private static func currentSplitButtonTooltips() -> WorkspaceLayoutConfiguration.SplitButtonTooltips { + configureWorkspaceSplitShortcutHints() + return WorkspaceLayoutConfiguration.SplitButtonTooltips( newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip("New Terminal"), newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip("New Browser"), splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip("Split Right"), @@ -7506,8 +8112,19 @@ final class Workspace: Identifiable, ObservableObject { ) } - private static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance { - bonsplitAppearance( + private static func configureWorkspaceSplitShortcutHints() { + WorkspaceLayoutShortcutHintSettings.selectSurfaceByNumberShortcutProvider = { + let shortcut = KeyboardShortcutSettings.selectSurfaceByNumberShortcut() + return WorkspaceLayoutNumberedShortcutHint( + hasChord: shortcut.hasChord, + modifierFlags: shortcut.modifierFlags, + modifierDisplayString: shortcut.modifierDisplayString + ) + } + } + + private static func splitAppearance(from config: GhosttyConfig) -> WorkspaceLayoutConfiguration.Appearance { + splitAppearance( from: config.backgroundColor, backgroundOpacity: config.backgroundOpacity, tabTitleFontSize: config.surfaceTabBarFontSize @@ -7522,7 +8139,7 @@ final class Workspace: Identifiable, ObservableObject { true } - nonisolated static func bonsplitChromeHex( + nonisolated static func splitChromeHex( backgroundColor: NSColor, backgroundOpacity: Double, sharesWindowBackdrop: Bool = false @@ -7536,22 +8153,22 @@ final class Workspace: Identifiable, ObservableObject { return themedColor.hexString(includeAlpha: includeAlpha) } - nonisolated static func usesBonsplitPaneTerminalBackdrop( + nonisolated static func usesWorkspaceSplitPaneTerminalBackdrop( renderingMode: GhosttyTerminalBackdropRenderingMode, sharesWindowBackdrop: Bool ) -> Bool { - // The window root backdrop owns terminal fills. Bonsplit pane fills + // The window root backdrop owns terminal fills. WorkspaceSplit pane fills // would add a second translucent layer under the Metal surface. return false } - nonisolated static func bonsplitChromeColors( + nonisolated static func splitChromeColors( backgroundColor: NSColor, backgroundOpacity: Double, sharesWindowBackdrop: Bool = false, renderingMode: GhosttyTerminalBackdropRenderingMode = .windowHostBackdrop - ) -> BonsplitConfiguration.Appearance.ChromeColors { - let surfaceHex = bonsplitChromeHex( + ) -> WorkspaceLayoutConfiguration.Appearance.ChromeColors { + let surfaceHex = splitChromeHex( backgroundColor: backgroundColor, backgroundOpacity: backgroundOpacity, sharesWindowBackdrop: sharesWindowBackdrop @@ -7570,7 +8187,7 @@ final class Workspace: Identifiable, ObservableObject { ) } - let paneBackgroundHex = usesBonsplitPaneTerminalBackdrop( + let paneBackgroundHex = usesWorkspaceSplitPaneTerminalBackdrop( renderingMode: renderingMode, sharesWindowBackdrop: sharesWindowBackdrop ) @@ -7585,15 +8202,15 @@ final class Workspace: Identifiable, ObservableObject { ) } - nonisolated static let bonsplitSplitButtonBackdropSoftness: CGFloat = 0.60 + nonisolated static let splitButtonBackdropSoftness: CGFloat = 0.60 - nonisolated static func bonsplitSplitButtonBackdropEffect() -> BonsplitConfiguration.Appearance.SplitButtonBackdropEffect { + nonisolated static func splitButtonBackdropEffect() -> WorkspaceLayoutConfiguration.Appearance.SplitButtonBackdropEffect { .init( style: .translucentChrome, fadeWidth: 99.75, contentFadeWidth: 28.875, solidWidth: 23.875, - fadeRampStartFraction: bonsplitSplitButtonBackdropSoftness, + fadeRampStartFraction: splitButtonBackdropSoftness, leadingOpacity: 0, trailingOpacity: 0.8625, contentOcclusionFraction: 0.6875, @@ -7605,8 +8222,8 @@ final class Workspace: Identifiable, ObservableObject { from backgroundColor: NSColor, sharesWindowBackdrop: Bool = false, renderingMode: GhosttyTerminalBackdropRenderingMode = .windowHostBackdrop - ) -> BonsplitConfiguration.Appearance.ChromeColors { - // Keep this signature aligned with bonsplitChromeHex for settings tests + ) -> WorkspaceLayoutConfiguration.Appearance.ChromeColors { + // Keep this signature aligned with splitChromeHex for settings tests // and future background-image handling. let backgroundHex = backgroundColor.hexString() let borderHex = WindowChromeSeparatorColor @@ -7623,7 +8240,7 @@ final class Workspace: Identifiable, ObservableObject { ) } - let paneBackgroundHex = usesBonsplitPaneTerminalBackdrop( + let paneBackgroundHex = usesWorkspaceSplitPaneTerminalBackdrop( renderingMode: renderingMode, sharesWindowBackdrop: sharesWindowBackdrop ) @@ -7638,9 +8255,9 @@ final class Workspace: Identifiable, ObservableObject { ) } - private static func bonsplitChromeColorsEqual( - _ lhs: BonsplitConfiguration.Appearance.ChromeColors, - _ rhs: BonsplitConfiguration.Appearance.ChromeColors + private static func splitChromeColorsEqual( + _ lhs: WorkspaceLayoutConfiguration.Appearance.ChromeColors, + _ rhs: WorkspaceLayoutConfiguration.Appearance.ChromeColors ) -> Bool { lhs.backgroundHex == rhs.backgroundHex && lhs.tabBarBackgroundHex == rhs.tabBarBackgroundHex && @@ -7649,8 +8266,8 @@ final class Workspace: Identifiable, ObservableObject { lhs.borderHex == rhs.borderHex } - private static func bonsplitChromeColorsLogDescription( - _ colors: BonsplitConfiguration.Appearance.ChromeColors + private static func splitChromeColorsLogDescription( + _ colors: WorkspaceLayoutConfiguration.Appearance.ChromeColors ) -> String { "bg=\(colors.backgroundHex ?? "nil") " + "tabBarBg=\(colors.tabBarBackgroundHex ?? "nil") " + @@ -7659,24 +8276,24 @@ final class Workspace: Identifiable, ObservableObject { "border=\(colors.borderHex ?? "nil")" } - private static func bonsplitAppearance( + private static func splitAppearance( from backgroundColor: NSColor, backgroundOpacity: Double, tabTitleFontSize: CGFloat = 11 - ) -> BonsplitConfiguration.Appearance { + ) -> WorkspaceLayoutConfiguration.Appearance { let sharesWindowBackdrop = usesWindowRootTerminalBackdrop() let renderingMode = WindowAppearanceSnapshot.terminalRenderingMode( usesHostLayerBackground: GhosttyApp.shared.usesHostLayerBackground ) - let chromeColors = Self.bonsplitChromeColors( + let chromeColors = Self.splitChromeColors( backgroundColor: backgroundColor, backgroundOpacity: backgroundOpacity, sharesWindowBackdrop: sharesWindowBackdrop, renderingMode: renderingMode ) - return BonsplitConfiguration.Appearance( + return WorkspaceLayoutConfiguration.Appearance( tabTitleFontSize: tabTitleFontSize, - splitButtonBackdropEffect: Self.bonsplitSplitButtonBackdropEffect(), + splitButtonBackdropEffect: Self.splitButtonBackdropEffect(), splitButtonTooltips: Self.currentSplitButtonTooltips(), enableAnimations: false, chromeColors: chromeColors, @@ -7689,16 +8306,16 @@ final class Workspace: Identifiable, ObservableObject { let renderingMode = WindowAppearanceSnapshot.terminalRenderingMode( usesHostLayerBackground: GhosttyApp.shared.usesHostLayerBackground ) - let nextChromeColors = Self.bonsplitChromeColors( + let nextChromeColors = Self.splitChromeColors( backgroundColor: config.backgroundColor, backgroundOpacity: config.backgroundOpacity, sharesWindowBackdrop: sharesWindowBackdrop, renderingMode: renderingMode ) let nextTabTitleFontSize = config.surfaceTabBarFontSize - let currentAppearance = bonsplitController.configuration.appearance + let currentAppearance = splitController.configuration.appearance let currentTabTitleFontSize = currentAppearance.tabTitleFontSize - let colorsChanged = !Self.bonsplitChromeColorsEqual( + let colorsChanged = !Self.splitChromeColorsEqual( currentAppearance.chromeColors, nextChromeColors ) @@ -7709,13 +8326,13 @@ final class Workspace: Identifiable, ObservableObject { if GhosttyApp.shared.backgroundLogEnabled { GhosttyApp.shared.logBackground( "theme apply workspace=\(id.uuidString) reason=\(reason) " + - "current=[\(Self.bonsplitChromeColorsLogDescription(currentAppearance.chromeColors))] " + - "next=[\(Self.bonsplitChromeColorsLogDescription(nextChromeColors))] " + + "current=[\(Self.splitChromeColorsLogDescription(currentAppearance.chromeColors))] " + + "next=[\(Self.splitChromeColorsLogDescription(nextChromeColors))] " + "currentTabFont=\(String(format: "%.3f", currentTabTitleFontSize)) " + "nextTabFont=\(String(format: "%.3f", nextTabTitleFontSize)) " + "sharesWindowBackdrop=\(sharesWindowBackdrop ? 1 : 0) " + "currentUsesSharedBackdrop=\(currentAppearance.usesSharedBackdrop ? 1 : 0) " + - "paneBackdrop=\(Self.usesBonsplitPaneTerminalBackdrop(renderingMode: renderingMode, sharesWindowBackdrop: sharesWindowBackdrop) ? 1 : 0) " + + "paneBackdrop=\(Self.usesWorkspaceSplitPaneTerminalBackdrop(renderingMode: renderingMode, sharesWindowBackdrop: sharesWindowBackdrop) ? 1 : 0) " + "noop=\(isNoOp)" ) } @@ -7723,21 +8340,21 @@ final class Workspace: Identifiable, ObservableObject { guard !isNoOp else { return } if colorsChanged { - bonsplitController.configuration.appearance.chromeColors = nextChromeColors + splitController.configuration.appearance.chromeColors = nextChromeColors } if sharedBackdropChanged { - bonsplitController.configuration.appearance.usesSharedBackdrop = sharesWindowBackdrop + splitController.configuration.appearance.usesSharedBackdrop = sharesWindowBackdrop } if fontSizeChanged { - bonsplitController.configuration.appearance.tabTitleFontSize = nextTabTitleFontSize + splitController.configuration.appearance.tabTitleFontSize = nextTabTitleFontSize } if GhosttyApp.shared.backgroundLogEnabled { GhosttyApp.shared.logBackground( "theme applied workspace=\(id.uuidString) reason=\(reason) " + - "resulting=[\(Self.bonsplitChromeColorsLogDescription(bonsplitController.configuration.appearance.chromeColors))] " + - "resultingUsesSharedBackdrop=\(bonsplitController.configuration.appearance.usesSharedBackdrop ? 1 : 0) " + - "resultingTabFont=\(String(format: "%.3f", bonsplitController.configuration.appearance.tabTitleFontSize))" + "resulting=[\(Self.splitChromeColorsLogDescription(splitController.configuration.appearance.chromeColors))] " + + "resultingUsesSharedBackdrop=\(splitController.configuration.appearance.usesSharedBackdrop ? 1 : 0) " + + "resultingTabFont=\(String(format: "%.3f", splitController.configuration.appearance.tabTitleFontSize))" ) } } @@ -7747,26 +8364,26 @@ final class Workspace: Identifiable, ObservableObject { let renderingMode = WindowAppearanceSnapshot.terminalRenderingMode( usesHostLayerBackground: GhosttyApp.shared.usesHostLayerBackground ) - let nextChromeColors = Self.bonsplitChromeColors( + let nextChromeColors = Self.splitChromeColors( backgroundColor: backgroundColor, backgroundOpacity: backgroundOpacity, sharesWindowBackdrop: sharesWindowBackdrop, renderingMode: renderingMode ) - let currentChromeColors = bonsplitController.configuration.appearance.chromeColors - let currentUsesSharedBackdrop = bonsplitController.configuration.appearance.usesSharedBackdrop - let colorsChanged = !Self.bonsplitChromeColorsEqual(currentChromeColors, nextChromeColors) + let currentChromeColors = splitController.configuration.appearance.chromeColors + let currentUsesSharedBackdrop = splitController.configuration.appearance.usesSharedBackdrop + let colorsChanged = !Self.splitChromeColorsEqual(currentChromeColors, nextChromeColors) let sharedBackdropChanged = currentUsesSharedBackdrop != sharesWindowBackdrop let isNoOp = !colorsChanged && !sharedBackdropChanged if GhosttyApp.shared.backgroundLogEnabled { GhosttyApp.shared.logBackground( "theme apply workspace=\(id.uuidString) reason=\(reason) " + - "current=[\(Self.bonsplitChromeColorsLogDescription(currentChromeColors))] " + - "next=[\(Self.bonsplitChromeColorsLogDescription(nextChromeColors))] " + + "current=[\(Self.splitChromeColorsLogDescription(currentChromeColors))] " + + "next=[\(Self.splitChromeColorsLogDescription(nextChromeColors))] " + "sharesWindowBackdrop=\(sharesWindowBackdrop ? 1 : 0) " + "currentUsesSharedBackdrop=\(currentUsesSharedBackdrop ? 1 : 0) " + - "paneBackdrop=\(Self.usesBonsplitPaneTerminalBackdrop(renderingMode: renderingMode, sharesWindowBackdrop: sharesWindowBackdrop) ? 1 : 0) " + + "paneBackdrop=\(Self.usesWorkspaceSplitPaneTerminalBackdrop(renderingMode: renderingMode, sharesWindowBackdrop: sharesWindowBackdrop) ? 1 : 0) " + "noop=\(isNoOp)" ) } @@ -7775,16 +8392,16 @@ final class Workspace: Identifiable, ObservableObject { return } if colorsChanged { - bonsplitController.configuration.appearance.chromeColors = nextChromeColors + splitController.configuration.appearance.chromeColors = nextChromeColors } if sharedBackdropChanged { - bonsplitController.configuration.appearance.usesSharedBackdrop = sharesWindowBackdrop + splitController.configuration.appearance.usesSharedBackdrop = sharesWindowBackdrop } if GhosttyApp.shared.backgroundLogEnabled { GhosttyApp.shared.logBackground( "theme applied workspace=\(id.uuidString) reason=\(reason) " + - "resulting=[\(Self.bonsplitChromeColorsLogDescription(bonsplitController.configuration.appearance.chromeColors))] " + - "resultingUsesSharedBackdrop=\(bonsplitController.configuration.appearance.usesSharedBackdrop ? 1 : 0)" + "resulting=[\(Self.splitChromeColorsLogDescription(splitController.configuration.appearance.chromeColors))] " + + "resultingUsesSharedBackdrop=\(splitController.configuration.appearance.usesSharedBackdrop ? 1 : 0)" ) } } @@ -7795,7 +8412,6 @@ final class Workspace: Identifiable, ObservableObject { portOrdinal: Int = 0, configTemplate: CmuxSurfaceConfigTemplate? = nil, initialTerminalCommand: String? = nil, - initialTerminalInput: String? = nil, initialTerminalEnvironment: [String: String] = [:] ) { self.id = UUID() @@ -7805,42 +8421,33 @@ final class Workspace: Identifiable, ObservableObject { self.customTitle = nil self.customDescription = nil - let trimmedWorkingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let hasWorkingDirectory = !trimmedWorkingDirectory.isEmpty - let initialDirectory = hasWorkingDirectory - ? trimmedWorkingDirectory - : FileManager.default.homeDirectoryForCurrentUser.path - self.currentDirectory = hasWorkingDirectory - ? trimmedWorkingDirectory - : FileManager.default.homeDirectoryForCurrentUser.path - self.surfaceTabBarDirectory = initialDirectory - - // Configure bonsplit with keepAllAlive to preserve terminal state + let resolvedWorkingDirectory = cmuxNormalizedWorkingDirectory(workingDirectory) + ?? cmuxDefaultWorkingDirectory() + self.currentDirectory = resolvedWorkingDirectory + + // Configure WorkspaceSplit with keepAllAlive to preserve terminal state // and keep split entry instantaneous. - // Use the cached Ghostty config so new workspaces inherit tab-strip sizing - // without paying repeated parse costs on the workspace-creation hot path. - let initialSurfaceTabBarFontSize = GhosttyConfig.load().surfaceTabBarFontSize - let appearance = Self.bonsplitAppearance( + // Avoid re-reading/parsing Ghostty config on every new workspace; this hot path + // runs for socket/CLI workspace creation and can cause visible typing lag. + let appearance = Self.splitAppearance( from: GhosttyApp.shared.defaultBackgroundColor, - backgroundOpacity: GhosttyApp.shared.defaultBackgroundOpacity, - tabTitleFontSize: initialSurfaceTabBarFontSize + backgroundOpacity: GhosttyApp.shared.defaultBackgroundOpacity ) - let config = BonsplitConfiguration( + let config = WorkspaceLayoutConfiguration( allowSplits: true, allowCloseTabs: true, allowCloseLastPane: false, allowTabReordering: true, allowCrossPaneTabMove: true, autoCloseEmptyPanes: true, - contentViewLifecycle: .keepAllAlive, newTabPosition: .current, appearance: appearance ) - self.bonsplitController = BonsplitController(configuration: config) - bonsplitController.contextMenuShortcuts = Self.buildContextMenuShortcuts() - - // Remove the default "Welcome" tab that bonsplit creates - let welcomeTabIds = bonsplitController.allTabIds + self.splitController = WorkspaceLayoutController(configuration: config) + splitController.contextMenuShortcuts = Self.buildContextMenuShortcuts() + splitController.onGeometryChanged = { [weak self] snapshot in + self?.publishGeometrySnapshot(snapshot) + } // When the workspace boots with an explicit initial command (`cmux ssh` / // `cmux vm new` both funnel their ssh startup script through this path), @@ -7860,153 +8467,77 @@ final class Workspace: Identifiable, ObservableObject { workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_TAB, configTemplate: resolvedConfigTemplate, - workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil, + workingDirectory: resolvedWorkingDirectory, portOrdinal: portOrdinal, initialCommand: initialTerminalCommand, - initialInput: initialTerminalInput, initialEnvironmentOverrides: initialTerminalEnvironment ) configureTerminalPanel(terminalPanel) + installTerminalPanelSubscription(terminalPanel) panels[terminalPanel.id] = terminalPanel - panelTitles[terminalPanel.id] = terminalPanel.displayTitle + seedInitialTerminalPanelTitle(terminalPanel, fallbackDirectory: resolvedWorkingDirectory) seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate) - // Create initial tab in bonsplit and store the mapping + // Create initial tab in WorkspaceSplit and store the mapping var initialTabId: TabID? - if let tabId = bonsplitController.createTab( - title: title, - icon: "terminal.fill", - kind: SurfaceKind.terminal, - isDirty: false, + if let tabId = splitController.createTab( + id: TabID(id: terminalPanel.id), + title: panelTitle(panelId: terminalPanel.id) ?? title, isPinned: false ) { - surfaceIdToPanelId[tabId] = terminalPanel.id initialTabId = tabId } - // Close the default Welcome tab(s) - for welcomeTabId in welcomeTabIds { - bonsplitController.closeTab(welcomeTabId) - } - - bonsplitController.onExternalTabDrop = { [weak self] request in - self?.handleExternalTabDrop(request) ?? false - } - bonsplitController.onTabCloseRequest = { [weak self] tabId, _ in - self?.markExplicitClose(surfaceId: tabId) - } - // Set ourselves as delegate - bonsplitController.delegate = self + splitController.delegate = self - // Ensure bonsplit has a focused pane and our didSelectTab handler runs for the - // initial terminal. bonsplit's createTab selects internally but does not emit + // Ensure WorkspaceSplit has a focused pane and our didSelectTab handler runs for the + // initial terminal. WorkspaceSplit's createTab selects internally but does not emit // didSelectTab, and focusedPaneId can otherwise be nil until user interaction. if let initialTabId { // Focus the pane containing the initial tab (or the first pane as fallback). let paneToFocus: PaneID? = { - for paneId in bonsplitController.allPaneIds { - if bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == initialTabId }) { + for paneId in splitController.allPaneIds { + if splitController.tabIds(inPane: paneId).contains(initialTabId) { return paneId } } - return bonsplitController.allPaneIds.first + return splitController.allPaneIds.first }() if let paneToFocus { - bonsplitController.focusPane(paneToFocus) + splitController.focusPane(paneToFocus) } - bonsplitController.selectTab(initialTabId) + splitController.selectTab(initialTabId) } - tmuxLayoutSnapshot = bonsplitController.layoutSnapshot() + publishGeometrySnapshot(splitController.layoutSnapshot()) } - deinit { - for registrations in pendingTerminalInputObserversByPanelId.values { - for registration in registrations { - if let observer = registration.observer { - NotificationCenter.default.removeObserver(observer) - } - } + private func handlePaneFileDrop(urls: [URL], in paneId: PaneID) -> Bool { + guard let tabId = splitController.selectedTabId(inPane: paneId), + let panel = terminalPanel(for: tabId) else { + return false } + return panel.hostedView.handleDroppedURLs(urls) + } + + deinit { activeRemoteSessionControllerID = nil remoteSessionController?.stop() } func refreshSplitButtonTooltips() { let tooltips = Self.currentSplitButtonTooltips() - var configuration = bonsplitController.configuration + var configuration = splitController.configuration guard configuration.appearance.splitButtonTooltips != tooltips else { return } configuration.appearance.splitButtonTooltips = tooltips - bonsplitController.configuration = configuration - } - - func applySurfaceTabBarButtons( - _ buttons: [CmuxSurfaceTabBarButton], - sourcePath: String?, - globalConfigPath: String, - terminalCommandSourcePaths: [String: String], - workspaceCommands: [String: CmuxResolvedCommand] - ) { - let executableButtons = Dictionary( - uniqueKeysWithValues: buttons.compactMap { button in - if button.terminalCommand != nil { - return ( - button.id, - SurfaceTabBarExecutableButton( - button: button, - workspaceCommand: nil, - terminalCommandSourcePath: button.actionSourcePath ?? terminalCommandSourcePaths[button.id] - ) - ) - } - if let workspaceCommand = workspaceCommands[button.id] { - return ( - button.id, - SurfaceTabBarExecutableButton( - button: button, - workspaceCommand: workspaceCommand, - terminalCommandSourcePath: nil - ) - ) - } - return nil - } - ) - surfaceTabBarCommandButtons = executableButtons - surfaceTabBarButtonSourcePath = sourcePath - surfaceTabBarButtonGlobalConfigPath = globalConfigPath - - let bonsplitButtons = buttons.map { button in - let executable = executableButtons[button.id] - let allowProjectLocalIcon = executable.map { - CmuxConfigExecutor.isTrustedSurfaceButton( - $0.button, - workspaceCommand: $0.workspaceCommand, - terminalCommandSourcePath: $0.terminalCommandSourcePath, - surfaceTabBarConfigSourcePath: sourcePath, - globalConfigPath: globalConfigPath - ) - } ?? true - return button.bonsplitActionButton( - configSourcePath: sourcePath, - globalConfigPath: globalConfigPath, - allowProjectLocalIcon: allowProjectLocalIcon - ) - } - var configuration = bonsplitController.configuration - guard configuration.appearance.splitButtons != bonsplitButtons else { return } - configuration.appearance.splitButtons = bonsplitButtons - bonsplitController.configuration = configuration + splitController.configuration = configuration } - // MARK: - Surface ID to Panel ID Mapping - - /// Mapping from bonsplit TabID (surface ID) to panel UUID - private var surfaceIdToPanelId: [TabID: UUID] = [:] + // MARK: - Surface Identity /// Tab IDs that are allowed to close even if they would normally require confirmation. /// This is used by app-level confirmation prompts (e.g., Cmd+W "Close Tab?") so the - /// Bonsplit delegate doesn't block the close after the user already confirmed. + /// WorkspaceSplit delegate doesn't block the close after the user already confirmed. private var forceCloseTabIds: Set = [] /// Tab IDs that are currently showing (or about to show) a close confirmation prompt. @@ -8023,7 +8554,7 @@ final class Workspace: Identifiable, ObservableObject { /// 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. + /// WorkspaceSplit pane-close does not emit per-tab didClose callbacks. private var pendingPaneClosePanelIds: [UUID: [UUID]] = [:] private var pendingClosedBrowserRestoreSnapshots: [TabID: ClosedBrowserPanelRestoreSnapshot] = [:] private var isApplyingTabSelection = false @@ -8037,41 +8568,18 @@ final class Workspace: Identifiable, ObservableObject { private var pendingTabSelection: PendingTabSelectionRequest? private var isReconcilingFocusState = false private var focusReconcileScheduled = false + private var focusTransactions = WorkspaceFocusTransactionCoordinator() #if DEBUG private(set) var debugFocusReconcileScheduledDuringDetachCount: Int = 0 private var debugLastDidMoveTabTimestamp: TimeInterval = 0 private var debugDidMoveTabEventCount: UInt64 = 0 #endif - private var layoutFollowUpObservers: [NSObjectProtocol] = [] - private var layoutFollowUpPanelsCancellable: AnyCancellable? - private var layoutFollowUpTimeoutWorkItem: DispatchWorkItem? - private var layoutFollowUpReason: String? - private var layoutFollowUpTerminalFocusPanelId: UUID? - private var layoutFollowUpBrowserPanelId: UUID? - private var layoutFollowUpBrowserExitFocusPanelId: UUID? - private var layoutFollowUpNeedsGeometryPass = false - private var layoutFollowUpAttemptScheduled = false - private var layoutFollowUpAttemptVersion: Int = 0 - private var layoutFollowUpStalledAttemptCount = 0 - private var isAttemptingLayoutFollowUp = false private var isNormalizingPinnedTabOrder = false - private var pendingNonFocusSplitFocusReassert: PendingNonFocusSplitFocusReassert? - private var nonFocusSplitFocusReassertGeneration: UInt64 = 0 - - private struct PendingNonFocusSplitFocusReassert { - let generation: UInt64 - let preferredPanelId: UUID - let splitPanelId: UUID - } struct DetachedSurfaceTransfer { let panelId: UUID let panel: any Panel let title: String - let icon: String? - let iconImageData: Data? - let kind: String? - let isLoading: Bool let isPinned: Bool let directory: String? let ttyName: String? @@ -8087,10 +8595,6 @@ final class Workspace: Identifiable, ObservableObject { panelId: panelId, panel: panel, title: title, - icon: icon, - iconImageData: iconImageData, - kind: kind, - isLoading: isLoading, isPinned: isPinned, directory: directory, ttyName: ttyName, @@ -8125,16 +8629,13 @@ final class Workspace: Identifiable, ObservableObject { } #endif - func panelIdFromSurfaceId(_ surfaceId: TabID) -> UUID? { - surfaceIdToPanelId[surfaceId] - } - func markExplicitClose(surfaceId: TabID) { explicitUserCloseTabIds.insert(surfaceId) } func surfaceIdFromPanelId(_ panelId: UUID) -> TabID? { - surfaceIdToPanelId.first { $0.value == panelId }?.key + guard panels[panelId] != nil else { return nil } + return TabID(id: panelId) } private func configureTerminalPanel(_ terminalPanel: TerminalPanel) { @@ -8144,6 +8645,16 @@ final class Workspace: Identifiable, ObservableObject { } } + private func installTerminalPanelSubscription(_ terminalPanel: TerminalPanel) { + let subscription = terminalPanel.$searchState + .receive(on: DispatchQueue.main) + .sink { [weak terminalPanel] searchState in + guard let terminalPanel else { return } + terminalPanel.hostedView.setSearchOverlay(searchState: searchState) + } + panelSubscriptions[terminalPanel.id] = subscription + } + private func triggerWorkspacePaneFlash(panelId: UUID, reason: WorkspaceAttentionFlashReason) { tmuxWorkspaceFlashPanelId = panelId tmuxWorkspaceFlashReason = reason @@ -8158,32 +8669,16 @@ final class Workspace: Identifiable, ObservableObject { browserPanel.$faviconPNGData.removeDuplicates(by: { $0 == $1 }) ) .receive(on: DispatchQueue.main) - .sink { [weak self, weak browserPanel] _, isLoading, favicon in + .sink { [weak self, weak browserPanel] _, _, _ in guard let self = self, - let browserPanel = browserPanel, - let tabId = self.surfaceIdFromPanelId(browserPanel.id) else { return } - guard let existing = self.bonsplitController.tab(tabId) else { return } + let browserPanel = browserPanel else { return } - let nextTitle = browserPanel.displayTitle - if self.panelTitles[browserPanel.id] != nextTitle { - self.panelTitles[browserPanel.id] = nextTitle - } - let resolvedTitle = self.resolvedPanelTitle(panelId: browserPanel.id, fallback: nextTitle) - let titleUpdate: String? = existing.title == resolvedTitle ? nil : resolvedTitle - let faviconUpdate: Data?? = existing.iconImageData == favicon ? nil : .some(favicon) - let loadingUpdate: Bool? = existing.isLoading == isLoading ? nil : isLoading - - guard titleUpdate != nil || faviconUpdate != nil || loadingUpdate != nil else { return } - self.bonsplitController.updateTab( - tabId, - title: titleUpdate, - iconImageData: faviconUpdate, - hasCustomTitle: self.panelCustomTitles[browserPanel.id] != nil, - isLoading: loadingUpdate - ) + _ = self.updatePanelTitle(panelId: browserPanel.id, title: browserPanel.displayTitle) + self.syncBrowserTabChromeState(panelId: browserPanel.id) } panelSubscriptions[browserPanel.id] = subscription setPreferredBrowserProfileID(browserPanel.profileID) + syncBrowserTabChromeState(panelId: browserPanel.id) } func setPreferredBrowserProfileID(_ profileID: UUID?) { @@ -8221,20 +8716,9 @@ final class Workspace: Identifiable, ObservableObject { .receive(on: DispatchQueue.main) .sink { [weak self, weak markdownPanel] newTitle in guard let self, - let markdownPanel, - let tabId = self.surfaceIdFromPanelId(markdownPanel.id) else { return } - guard let existing = self.bonsplitController.tab(tabId) else { return } + let markdownPanel else { return } - if self.panelTitles[markdownPanel.id] != newTitle { - self.panelTitles[markdownPanel.id] = newTitle - } - let resolvedTitle = self.resolvedPanelTitle(panelId: markdownPanel.id, fallback: newTitle) - guard existing.title != resolvedTitle else { return } - self.bonsplitController.updateTab( - tabId, - title: resolvedTitle, - hasCustomTitle: self.panelCustomTitles[markdownPanel.id] != nil - ) + _ = self.updatePanelTitle(panelId: markdownPanel.id, title: newTitle) } panelSubscriptions[markdownPanel.id] = subscription } @@ -8260,197 +8744,845 @@ final class Workspace: Identifiable, ObservableObject { // MARK: - Panel Access func panel(for surfaceId: TabID) -> (any Panel)? { - guard let panelId = panelIdFromSurfaceId(surfaceId) else { return nil } - return panels[panelId] + panels[surfaceId.id] } func terminalPanel(for panelId: UUID) -> TerminalPanel? { panels[panelId] as? TerminalPanel } + func terminalPanel(for surfaceId: TabID) -> TerminalPanel? { + panels[surfaceId.id] as? TerminalPanel + } + func browserPanel(for panelId: UUID) -> BrowserPanel? { panels[panelId] as? BrowserPanel } + func browserPanel(for surfaceId: TabID) -> BrowserPanel? { + panels[surfaceId.id] as? BrowserPanel + } + func markdownPanel(for panelId: UUID) -> MarkdownPanel? { panels[panelId] as? MarkdownPanel } - private func surfaceKind(for panel: any Panel) -> String { - switch panel.panelType { - case .terminal: - return SurfaceKind.terminal - case .browser: - return SurfaceKind.browser - case .markdown: - return SurfaceKind.markdown - } + func markdownPanel(for surfaceId: TabID) -> MarkdownPanel? { + panels[surfaceId.id] as? MarkdownPanel } - private func resolvedPanelTitle(panelId: UUID, fallback: String) -> String { - let trimmedFallback = fallback.trimmingCharacters(in: .whitespacesAndNewlines) - let fallbackTitle = trimmedFallback.isEmpty ? "Tab" : trimmedFallback - if let custom = panelCustomTitles[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines), - !custom.isEmpty { - return custom - } - return fallbackTitle + func hasLivePanel(for surfaceId: TabID) -> Bool { + panels[surfaceId.id] != nil } - private func syncPinnedStateForTab(_ tabId: TabID, panelId: UUID) { - let isPinned = pinnedPanelIds.contains(panelId) - if let panel = panels[panelId] { - bonsplitController.updateTab( - tabId, - kind: .some(surfaceKind(for: panel)), - isPinned: isPinned - ) - } else { - bonsplitController.updateTab(tabId, isPinned: isPinned) - } + var paneIds: [PaneID] { + splitController.allPaneIds } - private func hasUnreadNotification(panelId: UUID) -> Bool { - AppDelegate.shared?.notificationStore?.hasVisibleNotificationIndicator(forTabId: id, surfaceId: panelId) ?? false + var paneCount: Int { + paneIds.count } - private func attentionPersistentState() -> WorkspaceAttentionPersistentState { - let notificationStore = AppDelegate.shared?.notificationStore - let unreadPanelIDs = Set( - panels.keys.filter { - notificationStore?.hasUnreadNotification(forTabId: id, surfaceId: $0) ?? false - } - ) - return WorkspaceAttentionPersistentState( - unreadPanelIDs: unreadPanelIDs, - focusedReadPanelID: notificationStore?.focusedReadIndicatorSurfaceId(forTabId: id), - manualUnreadPanelIDs: manualUnreadPanelIds - ) + var focusedPaneId: PaneID? { + splitController.focusedPaneId } - private func requestAttentionFlash(panelId: UUID, reason: WorkspaceAttentionFlashReason) { - let decision = WorkspaceAttentionCoordinator.decideFlash( - targetPanelID: panelId, - reason: reason, - persistentState: attentionPersistentState() - ) - guard decision.isAllowed else { return } - panels[panelId]?.triggerFlash(reason: reason) + var isSplitZoomed: Bool { + splitController.isSplitZoomed } - private func syncUnreadBadgeStateForPanel(_ panelId: UUID) { - guard let tabId = surfaceIdFromPanelId(panelId) else { return } - let shouldShowUnread = Self.shouldShowUnreadIndicator( - hasUnreadNotification: hasUnreadNotification(panelId: panelId), - isManuallyUnread: manualUnreadPanelIds.contains(panelId) - ) - if let existing = bonsplitController.tab(tabId), existing.showsNotificationBadge == shouldShowUnread { - return + var zoomedPaneId: PaneID? { + splitController.zoomedPaneId + } + + var tabBarLeadingInset: CGFloat { + get { splitController.configuration.appearance.tabBarLeadingInset } + set { + var configuration = splitController.configuration + guard configuration.appearance.tabBarLeadingInset != newValue else { return } + configuration.appearance.tabBarLeadingInset = newValue + splitController.configuration = configuration } - bonsplitController.updateTab(tabId, showsNotificationBadge: shouldShowUnread) } - private func normalizePinnedTabs(in paneId: PaneID) { - guard !isNormalizingPinnedTabOrder else { return } - isNormalizingPinnedTabOrder = true - defer { isNormalizingPinnedTabOrder = false } + var showsSplitButtons: Bool { + splitController.configuration.allowSplits && + splitController.configuration.appearance.showSplitButtons + } - let tabs = bonsplitController.tabs(inPane: paneId) - let pinnedTabs = tabs.filter { tab in - guard let panelId = panelIdFromSurfaceId(tab.id) else { return false } - return pinnedPanelIds.contains(panelId) - } - let unpinnedTabs = tabs.filter { tab in - guard let panelId = panelIdFromSurfaceId(tab.id) else { return true } - return !pinnedPanelIds.contains(panelId) - } - let desiredOrder = pinnedTabs + unpinnedTabs + var chromeBackgroundHex: String? { + splitController.configuration.appearance.chromeColors.backgroundHex + } - for (index, desiredTab) in desiredOrder.enumerated() { - let currentTabs = bonsplitController.tabs(inPane: paneId) - guard let currentIndex = currentTabs.firstIndex(where: { $0.id == desiredTab.id }) else { continue } - if currentIndex != index { - _ = bonsplitController.reorderTab(desiredTab.id, toIndex: index) - } - } + func containsPane(_ paneId: PaneID) -> Bool { + paneIds.contains(paneId) } - private func insertionIndexToRight(of anchorTabId: TabID, inPane paneId: PaneID) -> Int { - let tabs = bonsplitController.tabs(inPane: paneId) - guard let anchorIndex = tabs.firstIndex(where: { $0.id == anchorTabId }) else { return tabs.count } - let pinnedCount = tabs.reduce(into: 0) { count, tab in - if let panelId = panelIdFromSurfaceId(tab.id), pinnedPanelIds.contains(panelId) { - count += 1 - } - } - let rawTarget = min(anchorIndex + 1, tabs.count) - return max(rawTarget, pinnedCount) + func paneId(uuid: UUID) -> PaneID? { + paneIds.first(where: { $0.id == uuid }) } - func setPanelCustomTitle(panelId: UUID, title: String?) { - guard panels[panelId] != nil else { return } - let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let previous = panelCustomTitles[panelId] - if trimmed.isEmpty { - guard previous != nil else { return } - panelCustomTitles.removeValue(forKey: panelId) - } else { - guard previous != trimmed else { return } - panelCustomTitles[panelId] = trimmed + @discardableResult + func setDividerPosition( + _ position: Double, + forSplit splitId: UUID, + fromExternal: Bool = false + ) -> Bool { + let didSet = splitController.setDividerPosition(position, forSplit: splitId, fromExternal: fromExternal) + if didSet { + publishGeometrySnapshot(splitController.layoutSnapshot()) } - - guard let panel = panels[panelId], let tabId = surfaceIdFromPanelId(panelId) else { return } - let baseTitle = panelTitles[panelId] ?? panel.displayTitle - bonsplitController.updateTab( - tabId, - title: resolvedPanelTitle(panelId: panelId, fallback: baseTitle), - hasCustomTitle: panelCustomTitles[panelId] != nil - ) + return didSet } - func isPanelPinned(_ panelId: UUID) -> Bool { - pinnedPanelIds.contains(panelId) + func consumeSplitEntryAnimation(_ splitId: UUID) { + splitController.consumeSplitEntryAnimation(splitId) } - func panelKind(panelId: UUID) -> String? { - guard let panel = panels[panelId] else { return nil } - return surfaceKind(for: panel) + var allSurfaceIds: [UUID] { + paneIds.flatMap { surfaceIds(inPane: $0) } } - func requestBackgroundTerminalSurfaceStartIfNeeded() { - for terminalPanel in panels.values.compactMap({ $0 as? TerminalPanel }) { - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + func surfaceIds(inPane paneId: PaneID) -> [UUID] { + splitController.tabIds(inPane: paneId).compactMap { surfaceId in + panels[surfaceId.id] != nil ? surfaceId.id : nil } } - @discardableResult - func preloadTerminalPanelForDebugStress( - tabId: TabID, - inPane paneId: PaneID - ) -> TerminalPanel? { - guard let panelId = panelIdFromSurfaceId(tabId), - let terminalPanel = panels[panelId] as? TerminalPanel else { + func selectedSurfaceId(inPane paneId: PaneID) -> UUID? { + guard let surfaceId = splitController.selectedTabId(inPane: paneId), + panels[surfaceId.id] != nil else { return nil } + return surfaceId.id + } - debugStressPreloadSelectionDepth += 1 - defer { debugStressPreloadSelectionDepth -= 1 } - let isVisibleSelection = - bonsplitController.focusedPaneId == paneId && - bonsplitController.selectedTab(inPane: paneId)?.id == tabId && - terminalPanel.hostedView.window != nil && - terminalPanel.hostedView.superview != nil + func containsSurface(_ panelId: UUID, inPane paneId: PaneID) -> Bool { + surfaceIds(inPane: paneId).contains(panelId) + } - if isVisibleSelection { - terminalPanel.requestViewReattach() - scheduleTerminalGeometryReconcile() + @discardableResult + func focusPane(_ paneId: PaneID) -> Bool { + guard containsPane(paneId) else { return false } + splitController.focusPane(paneId) + if let selectedSurfaceId = selectedSurfaceId(inPane: paneId) { + focusPanel(selectedSurfaceId) + } else { + scheduleFocusReconcile() } - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() - return terminalPanel + return true } - func scheduleDebugStressTerminalGeometryReconcile() { - scheduleTerminalGeometryReconcile() + @discardableResult + func selectSurface(_ panelId: UUID) -> Bool { + guard panels[panelId] != nil else { return false } + focusPanel(panelId) + return true + } + + func layoutSnapshot() -> LayoutSnapshot { + splitController.layoutSnapshot() + } + + private func publishGeometrySnapshot(_ snapshot: LayoutSnapshot) { + tmuxLayoutSnapshot = snapshot + if !isDetachingCloseTransaction { + scheduleFocusReconcile() + } + } + + func treeSnapshot() -> ExternalTreeNode { + buildExternalTreeSnapshot( + from: splitController.rootNode, + containerFrame: splitController.containerFrame + ) + } + + @discardableResult + func equalizeSplits(orientationFilter: String? = nil) -> Bool { + equalizeSplits(node: treeSnapshot(), orientationFilter: orientationFilter) + } + +#if DEBUG + func setLayoutDelegateForTesting(_ delegate: WorkspaceLayoutDelegate?) { + splitController.delegate = delegate ?? self + } +#endif + + private func surfaceKind(for panel: any Panel) -> PanelType { + panel.panelType + } + + private var localizedFallbackPanelTitle: String { + String(localized: "panel.displayName.fallback", defaultValue: "Tab") + } + + private func buildExternalTreeSnapshot( + from node: SplitNode, + containerFrame: CGRect, + bounds: CGRect = CGRect(x: 0, y: 0, width: 1, height: 1) + ) -> ExternalTreeNode { + switch node { + case .pane(let paneState): + let pixelFrame = PixelRect( + x: Double(bounds.minX * containerFrame.width + containerFrame.origin.x), + y: Double(bounds.minY * containerFrame.height + containerFrame.origin.y), + width: Double(bounds.width * containerFrame.width), + height: Double(bounds.height * containerFrame.height) + ) + let tabs = paneState.tabIds.map { tabId in + let resolvedTabId = TabID(id: tabId) + let title = layoutTabSnapshot(for: resolvedTabId)?.title ?? localizedFallbackPanelTitle + return ExternalTab(id: tabId.uuidString, title: title) + } + return .pane( + ExternalPaneNode( + id: paneState.id.id.uuidString, + frame: pixelFrame, + tabs: tabs, + selectedTabId: paneState.selectedTabId?.uuidString + ) + ) + + case .split(let splitState): + let dividerPos = splitState.dividerPosition + let firstBounds: CGRect + let secondBounds: CGRect + + switch splitState.orientation { + case .horizontal: + firstBounds = CGRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width * dividerPos, + height: bounds.height + ) + secondBounds = CGRect( + x: bounds.minX + bounds.width * dividerPos, + y: bounds.minY, + width: bounds.width * (1 - dividerPos), + height: bounds.height + ) + case .vertical: + firstBounds = CGRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width, + height: bounds.height * dividerPos + ) + secondBounds = CGRect( + x: bounds.minX, + y: bounds.minY + bounds.height * dividerPos, + width: bounds.width, + height: bounds.height * (1 - dividerPos) + ) + } + + return .split( + ExternalSplitNode( + id: splitState.id.uuidString, + orientation: splitState.orientation.rawValue, + dividerPosition: Double(splitState.dividerPosition), + first: buildExternalTreeSnapshot( + from: splitState.first, + containerFrame: containerFrame, + bounds: firstBounds + ), + second: buildExternalTreeSnapshot( + from: splitState.second, + containerFrame: containerFrame, + bounds: secondBounds + ) + ) + ) + } + } + + @discardableResult + private func equalizeSplits(node: ExternalTreeNode, orientationFilter: String?) -> Bool { + guard case .split(let split) = node, + let splitId = UUID(uuidString: split.id) else { + return false + } + + var didEqualize = false + if orientationFilter == nil || split.orientation == orientationFilter { + let firstLeaves = countPaneLeaves(in: split.first) + let secondLeaves = countPaneLeaves(in: split.second) + let totalLeaves = firstLeaves + secondLeaves + let position = CGFloat(firstLeaves) / CGFloat(totalLeaves) + setDividerPosition(position, forSplit: splitId, fromExternal: true) + didEqualize = true + } + + let firstChanged = equalizeSplits(node: split.first, orientationFilter: orientationFilter) + let secondChanged = equalizeSplits(node: split.second, orientationFilter: orientationFilter) + return didEqualize || firstChanged || secondChanged + } + + private func countPaneLeaves(in node: ExternalTreeNode) -> Int { + switch node { + case .pane: + return 1 + case .split(let split): + return countPaneLeaves(in: split.first) + countPaneLeaves(in: split.second) + } + } + + private func resolvedPanelTitle(panelId: UUID, fallback: String) -> String { + let trimmedFallback = fallback.trimmingCharacters(in: .whitespacesAndNewlines) + let fallbackTitle = trimmedFallback.isEmpty ? localizedFallbackPanelTitle : trimmedFallback + if let custom = surfaceStateSnapshot(panelId: panelId).customTitle?.trimmingCharacters(in: .whitespacesAndNewlines), + !custom.isEmpty { + return custom + } + return fallbackTitle + } + + private func layoutTabSnapshot(for tabId: TabID) -> WorkspaceLayout.Tab? { + let panelId = tabId.id + guard let panel = panels[panelId] else { return nil } + + let surfaceState = surfaceStateSnapshot(panelId: panelId) + let fallbackTitle = surfaceState.title ?? panel.displayTitle + return WorkspaceLayout.Tab( + id: tabId, + title: resolvedPanelTitle(panelId: panelId, fallback: fallbackTitle), + isPinned: surfaceState.isPinned + ) + } + + private func tabChromeProjectionEntry( + for panelId: UUID, + notificationStore: TerminalNotificationStore? + ) -> WorkspaceTabChromeProjectionState.Entry? { + guard let panel = panels[panelId] else { return nil } + + let surfaceState = surfaceStateSnapshot(panelId: panelId) + let fallbackTitle = surfaceState.title ?? panel.displayTitle + let browserState = surfaceState.browserTabChromeState + let store = notificationStore ?? AppDelegate.shared?.notificationStore + let hasUnreadNotification = + store?.hasVisibleNotificationIndicator(forTabId: id, surfaceId: panelId) ?? false + + return WorkspaceTabChromeProjectionState.Entry( + title: resolvedPanelTitle(panelId: panelId, fallback: fallbackTitle), + hasCustomTitle: surfaceState.customTitle != nil, + icon: panel.displayIcon, + iconImageData: browserState?.iconImageData, + kind: WorkspaceLayoutTabKind(surfaceKind(for: panel)), + isDirty: panel.isDirty, + showsNotificationBadge: Self.shouldShowUnreadIndicator( + hasUnreadNotification: hasUnreadNotification, + isManuallyUnread: surfaceState.isManuallyUnread + ), + isLoading: browserState?.isLoading ?? false, + isPinned: surfaceState.isPinned + ) + } + + private func syncPinnedStateForTab(_ tabId: TabID, panelId: UUID) { + let isPinned = surfaceStateSnapshot(panelId: panelId).isPinned + splitController.updateTab(tabId, isPinned: isPinned) + } + + private func syncBrowserTabChromeState(panelId: UUID) { + guard let browserPanel = panels[panelId] as? BrowserPanel else { + updateSurfaceState(panelId: panelId) { $0.browserTabChromeState = nil } + return + } + + let nextState = WorkspaceBrowserTabChromeState( + iconImageData: browserPanel.faviconPNGData, + isLoading: browserPanel.isLoading + ) + if surfaceStateSnapshot(panelId: panelId).browserTabChromeState != nextState { + updateSurfaceState(panelId: panelId) { $0.browserTabChromeState = nextState } + } + } + + private func attentionPersistentState() -> WorkspaceAttentionPersistentState { + let notificationStore = AppDelegate.shared?.notificationStore + let unreadPanelIDs = Set( + panels.keys.filter { + notificationStore?.hasUnreadNotification(forTabId: id, surfaceId: $0) ?? false + } + ) + return WorkspaceAttentionPersistentState( + unreadPanelIDs: unreadPanelIDs, + focusedReadPanelID: notificationStore?.focusedReadIndicatorSurfaceId(forTabId: id), + manualUnreadPanelIDs: Set( + surfaceStatesByPanelId.lazy.compactMap { $0.value.isManuallyUnread ? $0.key : nil } + ) + ) + } + + private func requestAttentionFlash(panelId: UUID, reason: WorkspaceAttentionFlashReason) { + let decision = WorkspaceAttentionCoordinator.decideFlash( + targetPanelID: panelId, + reason: reason, + persistentState: attentionPersistentState() + ) + guard decision.isAllowed else { return } + panels[panelId]?.triggerFlash(reason: reason) + } + + private func normalizePinnedTabs(in paneId: PaneID) { + guard !isNormalizingPinnedTabOrder else { return } + isNormalizingPinnedTabOrder = true + defer { isNormalizingPinnedTabOrder = false } + + let paneSurfaceIds = surfaceIds(inPane: paneId) + let pinnedSurfaceIds = paneSurfaceIds.filter { surfaceStateSnapshot(panelId: $0).isPinned } + let unpinnedSurfaceIds = paneSurfaceIds.filter { !surfaceStateSnapshot(panelId: $0).isPinned } + let desiredOrder = pinnedSurfaceIds + unpinnedSurfaceIds + + for (index, desiredSurfaceId) in desiredOrder.enumerated() { + let currentSurfaceIds = surfaceIds(inPane: paneId) + guard let currentIndex = currentSurfaceIds.firstIndex(of: desiredSurfaceId) else { continue } + if currentIndex != index { + _ = splitController.reorderTab(TabID(id: desiredSurfaceId), toIndex: index) + } + } + } + + private func insertionIndexToRight(of anchorTabId: TabID, inPane paneId: PaneID) -> Int { + let paneSurfaceIds = surfaceIds(inPane: paneId) + guard let anchorIndex = paneSurfaceIds.firstIndex(of: anchorTabId.id) else { return paneSurfaceIds.count } + let pinnedCount = paneSurfaceIds.reduce(into: 0) { count, panelId in + if surfaceStateSnapshot(panelId: panelId).isPinned { + count += 1 + } + } + let rawTarget = min(anchorIndex + 1, paneSurfaceIds.count) + return max(rawTarget, pinnedCount) + } + + func setPanelCustomTitle(panelId: UUID, title: String?) { + guard panels[panelId] != nil else { return } + let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let previous = surfaceStateSnapshot(panelId: panelId).customTitle + if trimmed.isEmpty { + guard previous != nil else { return } + updateSurfaceState(panelId: panelId) { $0.customTitle = nil } + } else { + guard previous != trimmed else { return } + updateSurfaceState(panelId: panelId) { $0.customTitle = trimmed } + } + if let browserPanel = panels[panelId] as? BrowserPanel { + syncBrowserTabChromeState(panelId: browserPanel.id) + } + } + + func isPanelPinned(_ panelId: UUID) -> Bool { + surfaceStateSnapshot(panelId: panelId).isPinned + } + + func panelKind(panelId: UUID) -> PanelType? { + guard let panel = panels[panelId] else { return nil } + return surfaceKind(for: panel) + } + + func makeTabChromeProjectionState( + notificationStore: TerminalNotificationStore? + ) -> WorkspaceTabChromeProjectionState { + let entriesByPanelId = panels.reduce(into: [UUID: WorkspaceTabChromeProjectionState.Entry]()) { result, item in + let panelId = item.key + guard let entry = tabChromeProjectionEntry( + for: panelId, + notificationStore: notificationStore + ) else { + return + } + result[panelId] = entry + } + + return WorkspaceTabChromeProjectionState(entriesByPanelId: entriesByPanelId) + } + + func renderTabChrome( + for tab: WorkspaceLayout.Tab, + using projectionState: WorkspaceTabChromeProjectionState + ) -> WorkspaceLayout.Tab { + guard let entry = projectionState.entriesByPanelId[tab.id.id] else { return tab } + + var rendered = tab + rendered.title = entry.title + rendered.hasCustomTitle = entry.hasCustomTitle + rendered.kind = entry.kind + rendered.icon = entry.icon + rendered.iconImageData = entry.iconImageData + rendered.isDirty = entry.isDirty + rendered.showsNotificationBadge = entry.showsNotificationBadge + rendered.isPinned = entry.isPinned + rendered.isLoading = entry.isLoading + return rendered + } + + private func makePaneContentDescriptor( + for tab: WorkspaceLayout.Tab, + in paneId: PaneID, + context: WorkspaceLayoutRenderContext + ) -> WorkspacePaneContent { + guard let panel = panel(for: tab.id) else { + return makeEmptyPaneContentDescriptor(in: paneId) + } + + let isSelectedInPane = splitController.selectedTabId(inPane: paneId) == tab.id + let presentationFacts = context.panelPresentationFacts( + paneId: paneId, + panelId: panel.id, + isSelectedInPane: isSelectedInPane, + isFocused: context.isWorkspaceInputActive && focusedPanelId == panel.id + ) + let showsNotificationRing = NotificationPaneRingSettings.isEnabled() && Self.shouldShowUnreadIndicator( + hasUnreadNotification: context.notificationStore?.hasVisibleNotificationIndicator( + forTabId: id, + surfaceId: panel.id + ) ?? false, + isManuallyUnread: isPanelManuallyUnread(panel.id) + ) + + if let terminalPanel = panel as? TerminalPanel { + return .terminal( + WorkspaceTerminalPaneContent( + surfaceId: terminalPanel.id, + isFocused: presentationFacts.isFocused, + isVisibleInUI: presentationFacts.isVisibleInUI, + isSplit: splitController.allPaneIds.count > 1 || panels.count > 1, + appearance: WorkspaceTerminalPaneAppearance(context.appearance), + hasUnreadNotification: showsNotificationRing && !context.usesWorkspacePaneOverlay, + onFocus: { [weak self, weak terminalPanel] in + guard let self, let terminalPanel else { return } + guard context.isWorkspaceInputActive else { return } + guard self.panels[terminalPanel.id] != nil else { return } + self.focusPanel(terminalPanel.id, trigger: .terminalFirstResponder) + }, + onTriggerFlash: { [weak self, weak terminalPanel] in + guard let self, let terminalPanel else { return } + self.triggerDebugFlash(panelId: terminalPanel.id) + } + ) + ) + } + + if let browserPanel = panel as? BrowserPanel { + return .browser( + WorkspaceBrowserPaneContent( + surfaceId: browserPanel.id, + paneId: paneId, + isFocused: presentationFacts.isFocused, + isVisibleInUI: presentationFacts.isVisibleInUI, + prefersLocalInlineHosting: true, + portalPriority: context.workspacePortalPriority, + onRequestPanelFocus: { [weak self, weak browserPanel] in + guard let self, let browserPanel else { return } + guard context.isWorkspaceInputActive else { return } + guard self.panels[browserPanel.id] != nil else { return } + self.focusPanel(browserPanel.id) + } + ) + ) + } + + if let markdownPanel = panel as? MarkdownPanel { + return .markdown( + WorkspaceMarkdownPaneContent( + surfaceId: markdownPanel.id, + isVisibleInUI: presentationFacts.isVisibleInUI, + onRequestPanelFocus: { [weak self, weak markdownPanel] in + guard let self, let markdownPanel else { return } + guard context.isWorkspaceInputActive else { return } + guard self.panels[markdownPanel.id] != nil else { return } + self.focusPanel(markdownPanel.id) + } + ) + ) + } + + return makeEmptyPaneContentDescriptor(in: paneId) + } + + private func makeEmptyPaneContentDescriptor(in paneId: PaneID) -> WorkspacePaneContent { + .placeholder( + WorkspacePlaceholderPaneContent( + paneId: paneId, + onCreateTerminal: { [weak self] in + guard let self else { return } + #if DEBUG + dlog("emptyPane.newTerminal pane=\(paneId.id.uuidString.prefix(5))") + #endif + self.focusPane(paneId) + _ = self.createTerminalPanel(inPane: paneId) + }, + onCreateBrowser: { [weak self] in + guard let self else { return } + #if DEBUG + dlog("emptyPane.newBrowser pane=\(paneId.id.uuidString.prefix(5))") + #endif + self.focusPane(paneId) + _ = self.createBrowserPanel(inPane: paneId) + } + ) + ) + } + + private func makePaneChromeSnapshot( + pane: PaneState, + projectionState: WorkspaceTabChromeProjectionState, + showSplitButtons: Bool + ) -> WorkspaceLayoutPaneChromeSnapshot { + let selectedTabId = pane.selectedTabId ?? pane.tabIds.first + let paneId = pane.id + let renderedTabs = pane.tabIds.map { surfaceId in + let baseTab = layoutTabSnapshot(for: TabID(id: surfaceId)) + ?? WorkspaceLayout.Tab(id: TabID(id: surfaceId), title: localizedFallbackPanelTitle) + return renderTabChrome(for: baseTab, using: projectionState) + } + let actionFacts = WorkspacePaneActionEligibilityFacts( + paneId: paneId, + tabs: renderedTabs, + canMoveToLeftPane: splitController.adjacentPane(to: paneId, direction: .left) != nil, + canMoveToRightPane: splitController.adjacentPane(to: paneId, direction: .right) != nil, + isZoomed: splitController.zoomedPaneId == paneId, + hasSplits: splitController.allPaneIds.count > 1, + shortcuts: splitController.contextMenuShortcuts + ) + let tabs = renderedTabs.enumerated().map { index, tab in + WorkspaceLayoutTabChromeSnapshot( + tab: tab, + contextMenuState: actionFacts.contextMenuState(for: tab, at: index), + isSelected: selectedTabId == tab.id.id, + showsZoomIndicator: actionFacts.isZoomed && selectedTabId == tab.id.id + ) + } + return WorkspaceLayoutPaneChromeSnapshot( + paneId: paneId, + tabs: tabs, + selectedTabId: selectedTabId, + isFocused: splitController.focusedPaneId == paneId, + showSplitButtons: showSplitButtons, + chromeRevision: pane.chromeRevision + ) + } + + private func makeDisplayedPaneContentSnapshot( + pane: PaneState, + chrome: WorkspaceLayoutPaneChromeSnapshot, + context: WorkspaceLayoutRenderContext + ) -> (contentId: UUID, content: WorkspacePaneContent) { + if let selectedTab = chrome.tabs.first(where: { $0.tab.id.id == chrome.selectedTabId })?.tab + ?? chrome.tabs.first?.tab { + return ( + selectedTab.id.id, + makePaneContentDescriptor( + for: selectedTab, + in: pane.id, + context: context + ) + ) + } + + return (pane.id.id, makeEmptyPaneContentDescriptor(in: pane.id)) + } + + private func makeRenderNodeSnapshot( + node: SplitNode, + projectionState: WorkspaceTabChromeProjectionState, + context: WorkspaceLayoutRenderContext + ) -> WorkspaceLayoutRenderNodeSnapshot { + switch node { + case .pane(let pane): + let chrome = makePaneChromeSnapshot( + pane: pane, + projectionState: projectionState, + showSplitButtons: context.showSplitButtons + ) + let displayedContent = makeDisplayedPaneContentSnapshot( + pane: pane, + chrome: chrome, + context: context + ) + return .pane( + WorkspaceLayoutPaneRenderSnapshot( + paneId: pane.id, + chrome: chrome, + contentId: displayedContent.contentId, + content: displayedContent.content + ) + ) + case .split(let split): + return .split( + WorkspaceLayoutSplitRenderSnapshot( + splitId: split.id, + orientation: split.orientation, + dividerPosition: split.dividerPosition, + animationOrigin: split.animationOrigin, + first: makeRenderNodeSnapshot( + node: split.first, + projectionState: projectionState, + context: context + ), + second: makeRenderNodeSnapshot( + node: split.second, + projectionState: projectionState, + context: context + ) + ) + ) + } + } + + private func makeViewportSnapshots( + node: SplitNode, + projectionState: WorkspaceTabChromeProjectionState, + context: WorkspaceLayoutRenderContext, + paneFramesById: [UUID: CGRect] + ) -> [WorkspaceLayoutViewportSnapshot] { + switch node { + case .pane(let pane): + guard let frame = paneFramesById[pane.id.id] else { + return [] + } + let chrome = makePaneChromeSnapshot( + pane: pane, + projectionState: projectionState, + showSplitButtons: context.showSplitButtons + ) + let displayedContent = makeDisplayedPaneContentSnapshot( + pane: pane, + chrome: chrome, + context: context + ) + if displayedContent.content.usesDirectPaneHost { + return [] + } + return [ + WorkspaceLayoutViewportSnapshot( + paneId: pane.id, + contentId: displayedContent.contentId, + mountIdentity: displayedContent.content.mountIdentity(contentId: displayedContent.contentId), + content: displayedContent.content, + frame: frame + ) + ] + case .split(let split): + return makeViewportSnapshots( + node: split.first, + projectionState: projectionState, + context: context, + paneFramesById: paneFramesById + ) + makeViewportSnapshots( + node: split.second, + projectionState: projectionState, + context: context, + paneFramesById: paneFramesById + ) + } + } + + private func viewportFramesByPaneId( + from layoutSnapshot: LayoutSnapshot, + tabBarHeight: CGFloat + ) -> [UUID: CGRect] { + let containerOrigin = CGPoint( + x: CGFloat(layoutSnapshot.containerFrame.x), + y: CGFloat(layoutSnapshot.containerFrame.y) + ) + + return Dictionary(uniqueKeysWithValues: layoutSnapshot.panes.compactMap { pane in + guard let paneUUID = UUID(uuidString: pane.paneId) else { return nil } + let paneFrame = CGRect( + x: CGFloat(pane.frame.x) - containerOrigin.x, + y: CGFloat(pane.frame.y) - containerOrigin.y, + width: CGFloat(pane.frame.width), + height: CGFloat(pane.frame.height) + ) + let topInset = min(tabBarHeight, max(0, paneFrame.height - 1)) + let contentFrame = CGRect( + x: paneFrame.origin.x, + y: paneFrame.origin.y, + width: paneFrame.width, + height: max(0, paneFrame.height - topInset) + ) + return (paneUUID, contentFrame) + }) + } + + @MainActor + func makeLayoutRenderSnapshot(context: WorkspaceLayoutRenderContext) -> WorkspaceLayoutRenderSnapshot { + let projectionState = makeTabChromeProjectionState(notificationStore: context.notificationStore) + let geometrySnapshot = splitController.layoutSnapshot() + let viewportFrames = viewportFramesByPaneId( + from: geometrySnapshot, + tabBarHeight: splitController.configuration.appearance.tabBarHeight + ) + let viewportSnapshots = makeViewportSnapshots( + node: splitController.renderRootNode, + projectionState: projectionState, + context: context, + paneFramesById: viewportFrames + ) + return WorkspaceLayoutRenderSnapshot( + presentation: WorkspaceLayoutPresentationSnapshot( + appearance: splitController.configuration.appearance, + isInteractive: context.isWorkspaceInputActive, + isMinimalMode: context.isMinimalMode, + tabShortcutHintsEnabled: splitController.tabShortcutHintsEnabled, + localTabDrag: { + guard let tabId = splitController.currentDragTabId, + let sourcePaneId = splitController.currentDragSourcePaneId else { + return nil + } + return WorkspaceLayoutLocalDragSnapshot( + tabId: tabId, + sourcePaneId: sourcePaneId + ) + }() + ), + root: makeRenderNodeSnapshot( + node: splitController.renderRootNode, + projectionState: projectionState, + context: context + ), + viewports: viewportSnapshots + ) + } + + func requestBackgroundTerminalSurfaceStartIfNeeded() { + for terminalPanel in panels.values.compactMap({ $0 as? TerminalPanel }) { + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + } + } + + @discardableResult + func preloadTerminalPanelForDebugStress( + tabId: TabID, + inPane paneId: PaneID + ) -> TerminalPanel? { + guard let terminalPanel = terminalPanel(for: tabId) else { + return nil + } + + debugStressPreloadSelectionDepth += 1 + defer { debugStressPreloadSelectionDepth -= 1 } + let isVisibleSelection = + splitController.focusedPaneId == paneId && + splitController.selectedTabId(inPane: paneId) == tabId && + terminalPanel.hostedView.window != nil && + terminalPanel.hostedView.superview != nil + + if isVisibleSelection { + _ = terminalPanel.hostedView.reconcileGeometryNow() + } + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + return terminalPanel + } + + func scheduleDebugStressTerminalGeometryReconcile() { + for terminalPanel in panels.values.compactMap({ $0 as? TerminalPanel }) { + _ = terminalPanel.hostedView.reconcileGeometryNow() + } } func hasLoadedTerminalSurface() -> Bool { @@ -8459,33 +9591,49 @@ final class Workspace: Identifiable, ObservableObject { return terminalPanels.contains { $0.surface.surface != nil } } + func hideAllTerminalPortalViews() { + for panel in panels.values { + guard let terminal = panel as? TerminalPanel else { continue } + terminal.hostedView.setVisibleInUI(false) + } + } + + func hideAllBrowserPortalViews() { + for panel in panels.values { + guard let browser = panel as? BrowserPanel else { continue } + browser.setBrowserPortalVisibility( + visibleInUI: false, + zPriority: 0, + source: "workspaceRetire" + ) + } + } + func panelTitle(panelId: UUID) -> String? { guard let panel = panels[panelId] else { return nil } - let fallback = panelTitles[panelId] ?? panel.displayTitle + let fallback = surfaceStateSnapshot(panelId: panelId).title ?? 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) + let wasPinned = surfaceStateSnapshot(panelId: panelId).isPinned guard wasPinned != pinned else { return } - if pinned { - pinnedPanelIds.insert(panelId) - } else { - pinnedPanelIds.remove(panelId) - } + updateSurfaceState(panelId: panelId) { $0.isPinned = pinned } guard let tabId = surfaceIdFromPanelId(panelId), let paneId = paneId(forPanelId: panelId) else { return } - bonsplitController.updateTab(tabId, isPinned: pinned) + splitController.updateTab(tabId, isPinned: pinned) normalizePinnedTabs(in: paneId) } func markPanelUnread(_ panelId: UUID) { guard panels[panelId] != nil else { return } - guard manualUnreadPanelIds.insert(panelId).inserted else { return } - manualUnreadMarkedAt[panelId] = Date() - syncUnreadBadgeStateForPanel(panelId) + guard !surfaceStateSnapshot(panelId: panelId).isManuallyUnread else { return } + updateSurfaceState(panelId: panelId) { + $0.isManuallyUnread = true + $0.manualUnreadMarkedAt = Date() + } } func markPanelRead(_ panelId: UUID) { @@ -8495,10 +9643,12 @@ final class Workspace: Identifiable, ObservableObject { } func clearManualUnread(panelId: UUID) { - let didRemoveUnread = manualUnreadPanelIds.remove(panelId) != nil - manualUnreadMarkedAt.removeValue(forKey: panelId) + let didRemoveUnread = surfaceStateSnapshot(panelId: panelId).isManuallyUnread + updateSurfaceState(panelId: panelId) { + $0.isManuallyUnread = false + $0.manualUnreadMarkedAt = nil + } guard didRemoveUnread else { return } - syncUnreadBadgeStateForPanel(panelId) } static func shouldClearManualUnread( @@ -8586,7 +9736,7 @@ final class Workspace: Identifiable, ObservableObject { let normalizedNewlines = normalizedDescription?.reduce(into: 0) { count, character in if character == "\n" { count += 1 } } ?? 0 - cmuxDebugLog( + dlog( "workspace.customDescription.update workspace=\(id.uuidString.prefix(8)) " + "inputLen=\((description as NSString?)?.length ?? 0) " + "inputNewlines=\(inputNewlines) " + @@ -8601,38 +9751,66 @@ final class Workspace: Identifiable, ObservableObject { // MARK: - Directory Updates - private func configTrackingDirectory(for panelId: UUID?) -> String? { - if let panelId { - for candidate in [ - panelDirectories[panelId], - terminalPanel(for: panelId)?.requestedWorkingDirectory - ] { - let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmed.isEmpty { - return trimmed - } - } - } - - let trimmedCurrentDirectory = currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmedCurrentDirectory.isEmpty ? nil : trimmedCurrentDirectory - } - func updatePanelDirectory(panelId: UUID, directory: String) { let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } - if panelDirectories[panelId] != trimmed { - panelDirectories[panelId] = trimmed + let previousDirectory = surfaceStateSnapshot(panelId: panelId).directory + if previousDirectory != trimmed { + updateSurfaceState(panelId: panelId) { $0.directory = trimmed } } // Update current directory if this is the focused panel - if panelId == focusedPanelId { - if surfaceTabBarDirectory != trimmed { - surfaceTabBarDirectory = trimmed - } - if currentDirectory != trimmed { - currentDirectory = trimmed - } + if panelId == focusedPanelId, currentDirectory != trimmed { + currentDirectory = trimmed } + syncTerminalTitleFromDirectoryIfNeeded( + panelId: panelId, + previousDirectory: previousDirectory, + nextDirectory: trimmed + ) + } + + private func syncTerminalTitleFromDirectoryIfNeeded( + panelId: UUID, + previousDirectory: String?, + nextDirectory: String + ) { + guard let terminalPanel = panels[panelId] as? TerminalPanel else { return } + guard surfaceStateSnapshot(panelId: panelId).customTitle == nil else { return } + guard let nextDirectoryTitle = Self.derivedTerminalTitle(fromDirectory: nextDirectory) else { return } + + let currentTitle = (surfaceStateSnapshot(panelId: panelId).title ?? terminalPanel.displayTitle) + .trimmingCharacters(in: .whitespacesAndNewlines) + let genericTitle = terminalPanel.displayTitle.trimmingCharacters(in: .whitespacesAndNewlines) + let previousDirectoryTitle = Self.derivedTerminalTitle(fromDirectory: previousDirectory) + + let shouldFollowDirectoryTitle = + currentTitle.isEmpty + || currentTitle == genericTitle + || currentTitle == previousDirectoryTitle + guard shouldFollowDirectoryTitle else { return } + _ = updatePanelTitle(panelId: panelId, title: nextDirectoryTitle) + } + + private static func derivedTerminalTitle(fromDirectory directory: String?) -> String? { + guard let directory else { return nil } + let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let standardized = (trimmed as NSString).standardizingPath + let canonical = standardized.isEmpty ? trimmed : standardized + let abbreviated = (canonical as NSString).abbreviatingWithTildeInPath + let resolved = abbreviated.trimmingCharacters(in: .whitespacesAndNewlines) + return resolved.isEmpty ? nil : resolved + } + + private func seedInitialTerminalPanelTitle( + _ terminalPanel: TerminalPanel, + fallbackDirectory: String? = nil + ) { + let initialTitle = Self.derivedTerminalTitle( + fromDirectory: terminalPanel.requestedWorkingDirectory ?? fallbackDirectory ?? currentDirectory + ) ?? terminalPanel.displayTitle + terminalPanel.updateTitle(initialTitle) + updateSurfaceState(panelId: terminalPanel.id) { $0.title = initialTitle } } func updatePanelShellActivityState(panelId: UUID, state: PanelShellActivityState) { @@ -8640,21 +9818,8 @@ final class Workspace: Identifiable, ObservableObject { let previousState = panelShellActivityStates[panelId] ?? .unknown guard previousState != state else { return } panelShellActivityStates[panelId] = state - if state == .commandRunning, let restoredAgent = restoredAgentSnapshotsByPanelId[panelId] { - if restoredAgentAutoResumePendingPanelIds.remove(panelId) == nil { - let fingerprint = TabManager.restorableAgentSnapshotFingerprint(restoredAgent) - invalidatedRestoredAgentFingerprintsByPanelId[panelId] = fingerprint - restoredAgentSnapshotsByPanelId.removeValue(forKey: panelId) -#if DEBUG - cmuxDebugLog( - "session.restore.agent.invalidate panel=\(panelId.uuidString.prefix(5)) " + - "kind=\(restoredAgent.kind.rawValue) session=\(restoredAgent.sessionId.prefix(8))" - ) -#endif - } - } #if DEBUG - cmuxDebugLog( + dlog( "surface.shellState workspace=\(id.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) from=\(previousState.rawValue) to=\(state.rawValue)" ) @@ -8670,14 +9835,14 @@ final class Workspace: Identifiable, ObservableObject { func updatePanelGitBranch(panelId: UUID, branch: String, isDirty: Bool) { let state = SidebarGitBranchState(branch: branch, isDirty: isDirty) - let existing = panelGitBranches[panelId] + let existing = surfaceStateSnapshot(panelId: panelId).gitBranch let branchChanged = existing?.branch != nil && existing?.branch != branch if existing?.branch != branch || existing?.isDirty != isDirty { - panelGitBranches[panelId] = state + updateSurfaceState(panelId: panelId) { $0.gitBranch = state } } if branchChanged { - if panelPullRequests[panelId] != nil { - panelPullRequests.removeValue(forKey: panelId) + if surfaceStateSnapshot(panelId: panelId).pullRequest != nil { + updateSurfaceState(panelId: panelId) { $0.pullRequest = nil } } if panelId == focusedPanelId, pullRequest != nil { pullRequest = nil @@ -8689,11 +9854,12 @@ final class Workspace: Identifiable, ObservableObject { } func clearPanelGitBranch(panelId: UUID) { - if panelGitBranches[panelId] != nil { - panelGitBranches.removeValue(forKey: panelId) - } - if panelPullRequests[panelId] != nil { - panelPullRequests.removeValue(forKey: panelId) + let state = surfaceStateSnapshot(panelId: panelId) + if state.gitBranch != nil || state.pullRequest != nil { + updateSurfaceState(panelId: panelId) { + $0.gitBranch = nil + $0.pullRequest = nil + } } if panelId == focusedPanelId { if gitBranch != nil { @@ -8714,9 +9880,9 @@ final class Workspace: Identifiable, ObservableObject { branch: String? = nil, isStale: Bool = false ) { - let existing = panelPullRequests[panelId] + let existing = surfaceStateSnapshot(panelId: panelId).pullRequest let normalizedBranch = normalizedSidebarBranchName(branch) - let currentPanelBranch = normalizedSidebarBranchName(panelGitBranches[panelId]?.branch) + let currentPanelBranch = normalizedSidebarBranchName(surfaceStateSnapshot(panelId: panelId).gitBranch?.branch) let resolvedBranch: String? = { if let normalizedBranch { return normalizedBranch @@ -8742,7 +9908,7 @@ final class Workspace: Identifiable, ObservableObject { isStale: isStale ) if existing != state { - panelPullRequests[panelId] = state + updateSurfaceState(panelId: panelId) { $0.pullRequest = state } } if panelId == focusedPanelId, pullRequest != state { pullRequest = state @@ -8750,8 +9916,8 @@ final class Workspace: Identifiable, ObservableObject { } func clearPanelPullRequest(panelId: UUID) { - if panelPullRequests[panelId] != nil { - panelPullRequests.removeValue(forKey: panelId) + if surfaceStateSnapshot(panelId: panelId).pullRequest != nil { + updateSurfaceState(panelId: panelId) { $0.pullRequest = nil } } if panelId == focusedPanelId, pullRequest != nil { pullRequest = nil @@ -8765,10 +9931,16 @@ final class Workspace: Identifiable, ObservableObject { logEntries.removeAll() progress = nil gitBranch = nil - panelGitBranches.removeAll() pullRequest = nil - panelPullRequests.removeAll() - surfaceListeningPorts.removeAll() + mutateSurfaceStates { states in + states.mapValues { state in + var next = state + next.gitBranch = nil + next.pullRequest = nil + next.listeningPorts = [] + return next + } + } listeningPorts.removeAll() metadataBlocks.removeAll() resetBrowserPanelsForContextChange(reason: reason) @@ -8779,7 +9951,7 @@ final class Workspace: Identifiable, ObservableObject { guard !browserPanels.isEmpty else { return } #if DEBUG - cmuxDebugLog( + dlog( "workspace.contextReset.browserPanels workspace=\(id.uuidString.prefix(5)) " + "reason=\(reason) count=\(browserPanels.count)" ) @@ -8789,25 +9961,7 @@ final class Workspace: Identifiable, ObservableObject { browserPanel.resetForWorkspaceContextChange(reason: reason) let nextTitle = browserPanel.displayTitle _ = updatePanelTitle(panelId: browserPanel.id, title: nextTitle) - - guard let tabId = surfaceIdFromPanelId(browserPanel.id), - let existing = bonsplitController.tab(tabId) else { - continue - } - - let faviconUpdate: Data?? = existing.iconImageData == nil ? nil : .some(nil) - let loadingUpdate: Bool? = existing.isLoading ? false : nil - - guard faviconUpdate != nil || loadingUpdate != nil else { - continue - } - - bonsplitController.updateTab( - tabId, - iconImageData: faviconUpdate, - hasCustomTitle: panelCustomTitles[browserPanel.id] != nil, - isLoading: loadingUpdate - ) + syncBrowserTabChromeState(panelId: browserPanel.id) } } @@ -8817,24 +9971,11 @@ final class Workspace: Identifiable, ObservableObject { guard !trimmed.isEmpty else { return false } var didMutate = false - if panelTitles[panelId] != trimmed { - panelTitles[panelId] = trimmed + if surfaceStateSnapshot(panelId: panelId).title != trimmed { + updateSurfaceState(panelId: panelId) { $0.title = trimmed } didMutate = true } - // Update bonsplit tab title only when this panel's title changed. - if didMutate, - let tabId = surfaceIdFromPanelId(panelId), - let panel = panels[panelId] { - let baseTitle = panelTitles[panelId] ?? panel.displayTitle - let resolvedTitle = resolvedPanelTitle(panelId: panelId, fallback: baseTitle) - bonsplitController.updateTab( - tabId, - title: resolvedTitle, - hasCustomTitle: panelCustomTitles[panelId] != nil - ) - } - // If this is the only panel and no custom title, update workspace title if panels.count == 1, customTitle == nil { if self.title != trimmed { @@ -8846,40 +9987,23 @@ final class Workspace: Identifiable, ObservableObject { } } + if didMutate, panels[panelId] is BrowserPanel { + syncBrowserTabChromeState(panelId: panelId) + } + return didMutate } func pruneSurfaceMetadata(validSurfaceIds: Set) { - for panelId in Array(pendingTerminalInputObserversByPanelId.keys) where !validSurfaceIds.contains(panelId) { - removePendingTerminalInputObservers(forPanelId: panelId) - } - panelDirectories = panelDirectories.filter { validSurfaceIds.contains($0.key) } - panelTitles = panelTitles.filter { validSurfaceIds.contains($0.key) } - 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) } + surfaceStatesByPanelId = surfaceStatesByPanelId.filter { validSurfaceIds.contains($0.key) && !$0.value.isEmpty } remoteDetectedSurfaceIds = remoteDetectedSurfaceIds.filter { validSurfaceIds.contains($0) } panelShellActivityStates = panelShellActivityStates.filter { validSurfaceIds.contains($0.key) } - panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) } - restoredAgentSnapshotsByPanelId = restoredAgentSnapshotsByPanelId.filter { - validSurfaceIds.contains($0.key) - } - restoredAgentAutoResumePendingPanelIds = restoredAgentAutoResumePendingPanelIds.filter { - validSurfaceIds.contains($0) - } - invalidatedRestoredAgentFingerprintsByPanelId = invalidatedRestoredAgentFingerprintsByPanelId.filter { - validSurfaceIds.contains($0.key) - } syncRemotePortScanTTYs() recomputeListeningPorts() } func recomputeListeningPorts() { - let unique = Set(surfaceListeningPorts.values.flatMap { $0 }) + let unique = Set(surfaceStatesByPanelId.values.flatMap(\.listeningPorts)) .union(agentListeningPorts) .union(remoteDetectedPorts) .union(remoteForwardedPorts) @@ -8891,16 +10015,14 @@ final class Workspace: Identifiable, ObservableObject { func sidebarOrderedPanelIds() -> [UUID] { let paneTabs: [String: [UUID]] = Dictionary( - uniqueKeysWithValues: bonsplitController.allPaneIds.map { paneId in - let panelIds = bonsplitController - .tabs(inPane: paneId) - .compactMap { panelIdFromSurfaceId($0.id) } + uniqueKeysWithValues: splitController.allPaneIds.map { paneId in + let panelIds = surfaceIds(inPane: paneId) return (paneId.id.uuidString, panelIds) } ) let fallbackPanelIds = panels.keys.sorted { $0.uuidString < $1.uuidString } - let tree = bonsplitController.treeSnapshot() + let tree = treeSnapshot() return SidebarBranchOrdering.orderedPanelIds( tree: tree, paneTabs: paneTabs, @@ -8927,7 +10049,7 @@ final class Workspace: Identifiable, ObservableObject { } private func sidebarResolvedDirectory(for panelId: UUID) -> String? { - if let directory = normalizedSidebarDirectory(panelDirectories[panelId]) { + if let directory = normalizedSidebarDirectory(surfaceStateSnapshot(panelId: panelId).directory) { return directory } if let requestedDirectory = normalizedSidebarDirectory( @@ -8983,7 +10105,7 @@ final class Workspace: Identifiable, ObservableObject { SidebarBranchOrdering .orderedUniqueBranches( orderedPanelIds: orderedPanelIds, - panelBranches: panelGitBranches, + panelBranches: surfaceStatesByPanelId.compactMapValues(\.gitBranch), fallbackBranch: gitBranch ) .map { SidebarGitBranchState(branch: $0.name, isDirty: $0.isDirty) } @@ -8999,7 +10121,7 @@ final class Workspace: Identifiable, ObservableObject { let resolvedDirectories = sidebarResolvedPanelDirectories(orderedPanelIds: orderedPanelIds) return SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( orderedPanelIds: orderedPanelIds, - panelBranches: panelGitBranches, + panelBranches: surfaceStatesByPanelId.compactMapValues(\.gitBranch), panelDirectories: resolvedDirectories, defaultDirectory: normalizedSidebarDirectory(currentDirectory), homeDirectoryForTildeExpansion: sidebarHomeDirectoryForCanonicalization( @@ -9014,6 +10136,8 @@ final class Workspace: Identifiable, ObservableObject { } func sidebarPullRequestsInDisplayOrder(orderedPanelIds: [UUID]) -> [SidebarPullRequestState] { + let panelPullRequests = surfaceStatesByPanelId.compactMapValues(\.pullRequest) + let panelGitBranches = surfaceStatesByPanelId.compactMapValues(\.gitBranch) let validPanelPullRequests = panelPullRequests.filter { panelId, state in guard let pullRequestBranch = normalizedSidebarBranchName(state.branch) else { return true @@ -9096,7 +10220,11 @@ final class Workspace: Identifiable, ObservableObject { func syncRemotePortScanTTYs() { guard isRemoteWorkspace else { return } - remoteSessionController?.updateRemotePortScanTTYs(surfaceTTYNames) + let ttyNames = surfaceStatesByPanelId.compactMapValues { state in + let ttyName = state.ttyName?.trimmingCharacters(in: .whitespacesAndNewlines) + return ttyName?.isEmpty == false ? ttyName : nil + } + remoteSessionController?.updateRemotePortScanTTYs(ttyNames) } func kickRemotePortScan(panelId: UUID, reason: WorkspaceRemoteSessionController.PortScanKickReason = .command) { @@ -9383,7 +10511,7 @@ final class Workspace: Identifiable, ObservableObject { if let requestedSurfaceId = pendingRemoteSurfaceTTYSurfaceId, requestedSurfaceId != panelId { return } - surfaceTTYNames[panelId] = ttyName + updateSurfaceState(panelId: panelId) { $0.ttyName = ttyName } pendingRemoteSurfaceTTYName = nil pendingRemoteSurfaceTTYSurfaceId = nil syncRemotePortScanTTYs() @@ -9402,7 +10530,7 @@ final class Workspace: Identifiable, ObservableObject { requestedSurfaceId != panelId { return false } - guard let ttyName = surfaceTTYNames[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines), + guard let ttyName = surfaceStateSnapshot(panelId: panelId).ttyName?.trimmingCharacters(in: .whitespacesAndNewlines), !ttyName.isEmpty else { return false } @@ -9433,7 +10561,7 @@ final class Workspace: Identifiable, ObservableObject { return } - surfaceTTYNames[candidateSurfaceId] = trimmedTTY + updateSurfaceState(panelId: candidateSurfaceId) { $0.ttyName = trimmedTTY } syncRemotePortScanTTYs() if !applyPendingRemoteSurfacePortKickIfNeeded(to: candidateSurfaceId) { kickRemotePortScan(panelId: candidateSurfaceId, reason: .command) @@ -9653,16 +10781,12 @@ final class Workspace: Identifiable, ObservableObject { ) { let trackedSurfaceIds = Set(detectedByPanel.keys) for panelId in remoteDetectedSurfaceIds.subtracting(trackedSurfaceIds) { - surfaceListeningPorts.removeValue(forKey: panelId) + updateSurfaceState(panelId: panelId) { $0.listeningPorts = [] } } remoteDetectedSurfaceIds = trackedSurfaceIds for (panelId, ports) in detectedByPanel { - if ports.isEmpty { - surfaceListeningPorts.removeValue(forKey: panelId) - } else { - surfaceListeningPorts[panelId] = ports - } + updateSurfaceState(panelId: panelId) { $0.listeningPorts = ports } } remoteDetectedPorts = detected @@ -9697,7 +10821,7 @@ final class Workspace: Identifiable, ObservableObject { private func clearRemoteDetectedSurfacePorts() { for panelId in remoteDetectedSurfaceIds { - surfaceListeningPorts.removeValue(forKey: panelId) + updateSurfaceState(panelId: panelId) { $0.listeningPorts = [] } } remoteDetectedSurfaceIds.removeAll() } @@ -9746,7 +10870,9 @@ final class Workspace: Identifiable, ObservableObject { private func rememberTerminalConfigInheritanceSource(_ terminalPanel: TerminalPanel) { lastTerminalConfigInheritancePanelId = terminalPanel.id - if let sourceSurface = terminalPanel.surface.surface, + if let sourceSurface = terminalPanel.surface.liveSurfaceForGhosttyAccess( + reason: "terminal.config.rememberInheritance" + ), let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) { let existing = terminalInheritanceFontPointsByPanelId[terminalPanel.id] if existing == nil || abs((existing ?? runtimePoints) - runtimePoints) > 0.05 { @@ -9792,8 +10918,7 @@ final class Workspace: Identifiable, ObservableObject { } if let preferredPaneId, - let selectedSurfaceId = bonsplitController.selectedTab(inPane: preferredPaneId)?.id, - let selectedPanelId = panelIdFromSurfaceId(selectedSurfaceId), + let selectedPanelId = selectedSurfaceId(inPane: preferredPaneId), let selectedTerminalPanel = terminalPanel(for: selectedPanelId) { appendCandidate(selectedTerminalPanel) } @@ -9807,9 +10932,8 @@ final class Workspace: Identifiable, ObservableObject { } if let preferredPaneId { - for tab in bonsplitController.tabs(inPane: preferredPaneId) { - guard let panelId = panelIdFromSurfaceId(tab.id), - let terminalPanel = terminalPanel(for: panelId) else { continue } + for panelId in surfaceIds(inPane: preferredPaneId) { + guard let terminalPanel = terminalPanel(for: panelId) else { continue } appendCandidate(terminalPanel) } } @@ -9839,7 +10963,7 @@ final class Workspace: Identifiable, ObservableObject { inPane preferredPaneId: PaneID? = nil ) -> CmuxSurfaceConfigTemplate? { // Walk candidates in priority order and use the first panel that still exposes - // a runtime surface pointer. + // a registered live runtime surface pointer. for terminalPanel in terminalPanelConfigInheritanceCandidates( preferredPanelId: preferredPanelId, inPane: preferredPaneId @@ -9850,11 +10974,13 @@ final class Workspace: Identifiable, ObservableObject { // ghostty_surface_inherited_config or cmuxCurrentSurfaceFontSizePoints // is still reading through the pointer. let surface = terminalPanel.surface - guard let sourceSurface = surface.surface else { continue } - var config = cmuxInheritedSurfaceConfig( + guard let sourceSurface = surface.liveSurfaceForGhosttyAccess( + reason: "terminal.config.inherit" + ) else { continue } + guard var config = cmuxInheritedSurfaceConfig( sourceSurface: sourceSurface, context: GHOSTTY_SURFACE_CONTEXT_SPLIT - ) + ) else { continue } if let rootedFontPoints = resolvedTerminalInheritanceFontPoints( for: terminalPanel, sourceSurface: sourceSurface, @@ -9876,7 +11002,7 @@ final class Workspace: Identifiable, ObservableObject { var config = CmuxSurfaceConfigTemplate() config.fontSize = fallbackFontPoints #if DEBUG - cmuxDebugLog( + dlog( "zoom.inherit fallback=lastKnownFont context=split font=\(String(format: "%.2f", fallbackFontPoints))" ) #endif @@ -9888,7 +11014,7 @@ final class Workspace: Identifiable, ObservableObject { /// Create a new split with a terminal panel @discardableResult - func newTerminalSplit( + private func newTerminalSplit( from panelId: UUID, orientation: SplitOrientation, insertFirst: Bool = false, @@ -9903,11 +11029,9 @@ final class Workspace: Identifiable, ObservableObject { ) #endif // Find the pane containing the source panel - guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } var sourcePaneId: PaneID? - for paneId in bonsplitController.allPaneIds { - let tabs = bonsplitController.tabs(inPane: paneId) - if tabs.contains(where: { $0.id == sourceTabId }) { + for paneId in splitController.allPaneIds { + if containsSurface(panelId, inPane: paneId) { sourcePaneId = paneId break } @@ -9938,7 +11062,7 @@ final class Workspace: Identifiable, ObservableObject { // then its requested startup cwd if shell integration has not reported // back yet, and finally fall back to the workspace's current directory. let splitWorkingDirectory: String? = { - if let panelDirectory = panelDirectories[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines), + if let panelDirectory = surfaceStateSnapshot(panelId: panelId).directory?.trimmingCharacters(in: .whitespacesAndNewlines), !panelDirectory.isEmpty { return panelDirectory } @@ -9952,8 +11076,8 @@ final class Workspace: Identifiable, ObservableObject { return workspaceDirectory.isEmpty ? nil : workspaceDirectory }() #if DEBUG - cmuxDebugLog( - "split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") requestedDir=\(terminalPanel(for: panelId)?.requestedWorkingDirectory ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")" + dlog( + "split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(surfaceStateSnapshot(panelId: panelId).directory ?? "nil") requestedDir=\(terminalPanel(for: panelId)?.requestedWorkingDirectory ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")" ) #endif @@ -9967,8 +11091,9 @@ final class Workspace: Identifiable, ObservableObject { initialCommand: remoteTerminalStartupCommand ) configureTerminalPanel(newPanel) + installTerminalPanelSubscription(newPanel) panels[newPanel.id] = newPanel - panelTitles[newPanel.id] = newPanel.displayTitle + seedInitialTerminalPanelTitle(newPanel, fallbackDirectory: splitWorkingDirectory) if remoteTerminalStartupCommand != nil { trackRemoteTerminalSurface(newPanel.id) } @@ -9981,29 +11106,22 @@ final class Workspace: Identifiable, ObservableObject { ) #endif - // Pre-generate the bonsplit tab ID so we can install the panel mapping before bonsplit - // mutates layout state (avoids transient "Empty Panel" flashes during split). - let newTab = Bonsplit.Tab( - title: newPanel.displayTitle, - icon: newPanel.displayIcon, - kind: SurfaceKind.terminal, - isDirty: newPanel.isDirty, - isPinned: false - ) - surfaceIdToPanelId[newTab.id] = newPanel.id - let previousFocusedPanelId = focusedPanelId - - // Capture the source terminal's hosted view before bonsplit mutates focusedPaneId, + // Capture the source terminal's hosted view before WorkspaceSplit mutates focusedPaneId, // so we can hand it to focusPanel as the "move focus FROM" view. let previousHostedView = focusedTerminalPanel?.hostedView // Create the split with the new tab already present in the new pane. isProgrammaticSplit = true defer { isProgrammaticSplit = false } - guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { + guard splitController.splitPane( + paneId, + orientation: orientation, + withTabId: TabID(id: newPanel.id), + insertFirst: insertFirst, + focusNewPane: focus + ) != nil else { panels.removeValue(forKey: newPanel.id) - panelTitles.removeValue(forKey: newPanel.id) - surfaceIdToPanelId.removeValue(forKey: newTab.id) + removeSurfaceState(panelId: newPanel.id) if remoteTerminalStartupCommand != nil { untrackRemoteTerminalSurface(newPanel.id) } @@ -10026,15 +11144,6 @@ final class Workspace: Identifiable, ObservableObject { if focus { previousHostedView?.suppressReparentFocus() focusPanel(newPanel.id, previousHostedView: previousHostedView) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() - } - } else { - preserveFocusAfterNonFocusSplit( - preferredPanelId: previousFocusedPanelId, - splitPanelId: newPanel.id, - previousHostedView: previousHostedView - ) } #if DEBUG dlog( @@ -10058,16 +11167,13 @@ final class Workspace: Identifiable, ObservableObject { /// true = force focus/selection of the new surface, /// false = never focus (used for internal placeholder repair paths). @discardableResult - func newTerminalSurface( + private func newTerminalSurface( inPane paneId: PaneID, focus: Bool? = nil, workingDirectory: String? = nil, - initialInput: String? = nil, startupEnvironment: [String: String] = [:] ) -> TerminalPanel? { - let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) - let previousFocusedPanelId = focusedPanelId - let previousHostedView = focusedTerminalPanel?.hostedView + let shouldFocusNewTab = focus ?? (splitController.focusedPaneId == paneId) var inheritedConfig = inheritedTerminalConfig(inPane: paneId) let remoteTerminalStartupCommand = remoteTerminalStartupCommand() @@ -10088,28 +11194,27 @@ final class Workspace: Identifiable, ObservableObject { workingDirectory: workingDirectory, portOrdinal: portOrdinal, initialCommand: remoteTerminalStartupCommand, - initialInput: initialInput, additionalEnvironment: startupEnvironment ) configureTerminalPanel(newPanel) + installTerminalPanelSubscription(newPanel) panels[newPanel.id] = newPanel - panelTitles[newPanel.id] = newPanel.displayTitle + seedInitialTerminalPanelTitle(newPanel, fallbackDirectory: workingDirectory) if remoteTerminalStartupCommand != nil { trackRemoteTerminalSurface(newPanel.id) } seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) - // Create tab in bonsplit - guard let newTabId = bonsplitController.createTab( - title: newPanel.displayTitle, - icon: newPanel.displayIcon, - kind: SurfaceKind.terminal, - isDirty: newPanel.isDirty, + // Create tab in WorkspaceSplit + guard let newTabId = splitController.createTab( + id: TabID(id: newPanel.id), + title: panelTitle(panelId: newPanel.id) ?? newPanel.displayTitle, isPinned: false, - inPane: paneId + inPane: paneId, + select: shouldFocusNewTab ) else { panels.removeValue(forKey: newPanel.id) - panelTitles.removeValue(forKey: newPanel.id) + removeSurfaceState(panelId: newPanel.id) if remoteTerminalStartupCommand != nil { untrackRemoteTerminalSurface(newPanel.id) } @@ -10117,22 +11222,15 @@ final class Workspace: Identifiable, ObservableObject { return nil } - surfaceIdToPanelId[newTabId] = newPanel.id - // bonsplit's createTab may not reliably emit didSelectTab, and its internal selection + // WorkspaceSplit's createTab may not reliably emit didSelectTab, and its internal selection // updates can be deferred. Force a deterministic selection + focus path so the new // surface becomes interactive immediately (no "frozen until pane switch" state). if shouldFocusNewTab { - bonsplitController.focusPane(paneId) - bonsplitController.selectTab(newTabId) + splitController.focusPane(paneId) + splitController.selectTab(newTabId) newPanel.focus() applyTabSelection(tabId: newTabId, inPane: paneId) - } else { - preserveFocusAfterNonFocusSplit( - preferredPanelId: previousFocusedPanelId, - splitPanelId: newPanel.id, - previousHostedView: previousHostedView - ) } owningTabManager?.scheduleInitialWorkspaceGitMetadataRefreshIfPossible( @@ -10152,9 +11250,253 @@ final class Workspace: Identifiable, ObservableObject { return command } + enum WorkspaceLayoutCommand { + case splitTerminal( + fromPanelId: UUID, + orientation: SplitOrientation, + insertFirst: Bool = false, + focus: Bool = true + ) + case createTerminal( + inPane: PaneID, + focus: Bool? = nil, + workingDirectory: String? = nil, + startupEnvironment: [String: String] = [:] + ) + case splitBrowser( + fromPanelId: UUID, + orientation: SplitOrientation, + insertFirst: Bool = false, + url: URL? = nil, + preferredProfileID: UUID? = nil, + focus: Bool = true + ) + case createBrowser( + inPane: PaneID, + url: URL? = nil, + focus: Bool = true, + insertAtEnd: Bool = false, + preferredProfileID: UUID? = nil, + bypassInsecureHTTPHostOnce: String? = nil + ) + case splitMarkdown( + fromPanelId: UUID, + orientation: SplitOrientation, + insertFirst: Bool = false, + filePath: String, + focus: Bool = true + ) + case createMarkdown( + inPane: PaneID, + filePath: String, + focus: Bool? = nil + ) + case moveSurface(panelId: UUID, toPane: PaneID, atIndex: Int? = nil, focus: Bool = true) + case reorderSurface(panelId: UUID, toIndex: Int) + case toggleSplitZoom(panelId: UUID) + } + + enum WorkspaceLayoutCommandResult { + case terminal(TerminalPanel) + case browser(BrowserPanel) + case markdown(MarkdownPanel) + case success(Bool) + case none + + var terminalPanel: TerminalPanel? { + guard case .terminal(let panel) = self else { return nil } + return panel + } + + var browserPanel: BrowserPanel? { + guard case .browser(let panel) = self else { return nil } + return panel + } + + var markdownPanel: MarkdownPanel? { + guard case .markdown(let panel) = self else { return nil } + return panel + } + + var boolValue: Bool { + if case .success(let value) = self { + return value + } + return false + } + } + + @discardableResult + func performLayoutCommand(_ command: WorkspaceLayoutCommand) -> WorkspaceLayoutCommandResult { + switch command { + case .splitTerminal(let panelId, let orientation, let insertFirst, let focus): + return newTerminalSplit( + from: panelId, + orientation: orientation, + insertFirst: insertFirst, + focus: focus + ).map(WorkspaceLayoutCommandResult.terminal) ?? .none + case .createTerminal(let paneId, let focus, let workingDirectory, let startupEnvironment): + return newTerminalSurface( + inPane: paneId, + focus: focus, + workingDirectory: workingDirectory, + startupEnvironment: startupEnvironment + ).map(WorkspaceLayoutCommandResult.terminal) ?? .none + case .splitBrowser(let panelId, let orientation, let insertFirst, let url, let preferredProfileID, let focus): + return newBrowserSplit( + from: panelId, + orientation: orientation, + insertFirst: insertFirst, + url: url, + preferredProfileID: preferredProfileID, + focus: focus + ).map(WorkspaceLayoutCommandResult.browser) ?? .none + case .createBrowser(let paneId, let url, let focus, let insertAtEnd, let preferredProfileID, let bypassInsecureHTTPHostOnce): + return newBrowserSurface( + inPane: paneId, + url: url, + focus: focus, + insertAtEnd: insertAtEnd, + preferredProfileID: preferredProfileID, + bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce + ).map(WorkspaceLayoutCommandResult.browser) ?? .none + case .splitMarkdown(let panelId, let orientation, let insertFirst, let filePath, let focus): + return newMarkdownSplit( + from: panelId, + orientation: orientation, + insertFirst: insertFirst, + filePath: filePath, + focus: focus + ).map(WorkspaceLayoutCommandResult.markdown) ?? .none + case .createMarkdown(let paneId, let filePath, let focus): + return newMarkdownSurface( + inPane: paneId, + filePath: filePath, + focus: focus + ).map(WorkspaceLayoutCommandResult.markdown) ?? .none + case .moveSurface(let panelId, let paneId, let index, let focus): + return .success(moveSurface(panelId: panelId, toPane: paneId, atIndex: index, focus: focus)) + case .reorderSurface(let panelId, let index): + return .success(reorderSurface(panelId: panelId, toIndex: index)) + case .toggleSplitZoom(let panelId): + return .success(toggleSplitZoom(panelId: panelId)) + } + } + + @discardableResult + func splitTerminalPanel( + fromPanelId panelId: UUID, + orientation: SplitOrientation, + insertFirst: Bool = false, + focus: Bool = true + ) -> TerminalPanel? { + performLayoutCommand( + .splitTerminal( + fromPanelId: panelId, + orientation: orientation, + insertFirst: insertFirst, + focus: focus + ) + ).terminalPanel + } + + @discardableResult + func createTerminalPanel( + inPane paneId: PaneID, + focus: Bool? = nil, + workingDirectory: String? = nil, + startupEnvironment: [String: String] = [:] + ) -> TerminalPanel? { + performLayoutCommand( + .createTerminal( + inPane: paneId, + focus: focus, + workingDirectory: workingDirectory, + startupEnvironment: startupEnvironment + ) + ).terminalPanel + } + + @discardableResult + func splitBrowserPanel( + fromPanelId panelId: UUID, + orientation: SplitOrientation, + insertFirst: Bool = false, + url: URL? = nil, + preferredProfileID: UUID? = nil, + focus: Bool = true + ) -> BrowserPanel? { + performLayoutCommand( + .splitBrowser( + fromPanelId: panelId, + orientation: orientation, + insertFirst: insertFirst, + url: url, + preferredProfileID: preferredProfileID, + focus: focus + ) + ).browserPanel + } + + @discardableResult + func createBrowserPanel( + inPane paneId: PaneID, + url: URL? = nil, + focus: Bool = true, + insertAtEnd: Bool = false, + preferredProfileID: UUID? = nil, + bypassInsecureHTTPHostOnce: String? = nil + ) -> BrowserPanel? { + performLayoutCommand( + .createBrowser( + inPane: paneId, + url: url, + focus: focus, + insertAtEnd: insertAtEnd, + preferredProfileID: preferredProfileID, + bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce + ) + ).browserPanel + } + + @discardableResult + func splitMarkdownPanel( + fromPanelId panelId: UUID, + orientation: SplitOrientation, + insertFirst: Bool = false, + filePath: String, + focus: Bool = true + ) -> MarkdownPanel? { + performLayoutCommand( + .splitMarkdown( + fromPanelId: panelId, + orientation: orientation, + insertFirst: insertFirst, + filePath: filePath, + focus: focus + ) + ).markdownPanel + } + + @discardableResult + func createMarkdownPanel( + inPane paneId: PaneID, + filePath: String, + focus: Bool? = nil + ) -> MarkdownPanel? { + performLayoutCommand( + .createMarkdown( + inPane: paneId, + filePath: filePath, + focus: focus + ) + ).markdownPanel + } + /// Create a new browser panel split @discardableResult - func newBrowserSplit( + private func newBrowserSplit( from panelId: UUID, orientation: SplitOrientation, insertFirst: Bool = false, @@ -10163,11 +11505,9 @@ final class Workspace: Identifiable, ObservableObject { focus: Bool = true ) -> BrowserPanel? { // Find the pane containing the source panel - guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } var sourcePaneId: PaneID? - for paneId in bonsplitController.allPaneIds { - let tabs = bonsplitController.tabs(inPane: paneId) - if tabs.contains(where: { $0.id == sourceTabId }) { + for paneId in splitController.allPaneIds { + if containsSurface(panelId, inPane: paneId) { sourcePaneId = paneId break } @@ -10188,28 +11528,21 @@ final class Workspace: Identifiable, ObservableObject { remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil ) panels[browserPanel.id] = browserPanel - panelTitles[browserPanel.id] = browserPanel.displayTitle - - // Pre-generate the bonsplit tab ID so the mapping exists before the split lands. - let newTab = Bonsplit.Tab( - title: browserPanel.displayTitle, - icon: browserPanel.displayIcon, - kind: SurfaceKind.browser, - isDirty: browserPanel.isDirty, - isLoading: browserPanel.isLoading, - isPinned: false - ) - surfaceIdToPanelId[newTab.id] = browserPanel.id - let previousFocusedPanelId = focusedPanelId + updateSurfaceState(panelId: browserPanel.id) { $0.title = browserPanel.displayTitle } // Create the split with the browser tab already present. // Mark this split as programmatic so didSplitPane doesn't auto-create a terminal. isProgrammaticSplit = true defer { isProgrammaticSplit = false } - guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { - surfaceIdToPanelId.removeValue(forKey: newTab.id) + guard splitController.splitPane( + paneId, + orientation: orientation, + withTabId: TabID(id: browserPanel.id), + insertFirst: insertFirst, + focusNewPane: focus + ) != nil else { panels.removeValue(forKey: browserPanel.id) - panelTitles.removeValue(forKey: browserPanel.id) + removeSurfaceState(panelId: browserPanel.id) return nil } setPreferredBrowserProfileID(browserPanel.profileID) @@ -10219,15 +11552,6 @@ final class Workspace: Identifiable, ObservableObject { if focus { previousHostedView?.suppressReparentFocus() focusPanel(browserPanel.id) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() - } - } else { - preserveFocusAfterNonFocusSplit( - preferredPanelId: previousFocusedPanelId, - splitPanelId: browserPanel.id, - previousHostedView: previousHostedView - ) } installBrowserPanelSubscription(browserPanel) @@ -10241,20 +11565,16 @@ final class Workspace: Identifiable, ObservableObject { /// true = force focus/selection of the new surface, /// false = never focus (used for internal placeholder repair paths). @discardableResult - func newBrowserSurface( + private func newBrowserSurface( inPane paneId: PaneID, url: URL? = nil, - initialRequest: URLRequest? = nil, focus: Bool? = nil, insertAtEnd: Bool = false, preferredProfileID: UUID? = nil, bypassInsecureHTTPHostOnce: String? = nil ) -> BrowserPanel? { - let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) + let shouldFocusNewTab = focus ?? (splitController.focusedPaneId == paneId) let sourcePanelId = effectiveSelectedPanelId(inPane: paneId) - let previousFocusedPanelId = focusedPanelId - let previousHostedView = focusedTerminalPanel?.hostedView - let browserPanel = BrowserPanel( workspaceId: id, profileID: resolvedNewBrowserProfileID( @@ -10262,50 +11582,40 @@ final class Workspace: Identifiable, ObservableObject { sourcePanelId: sourcePanelId ), initialURL: url, - initialRequest: initialRequest, bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce, proxyEndpoint: remoteProxyEndpoint, isRemoteWorkspace: isRemoteWorkspace, remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil ) panels[browserPanel.id] = browserPanel - panelTitles[browserPanel.id] = browserPanel.displayTitle + updateSurfaceState(panelId: browserPanel.id) { $0.title = browserPanel.displayTitle } - guard let newTabId = bonsplitController.createTab( + guard let newTabId = splitController.createTab( + id: TabID(id: browserPanel.id), title: browserPanel.displayTitle, - icon: browserPanel.displayIcon, - kind: SurfaceKind.browser, - isDirty: browserPanel.isDirty, - isLoading: browserPanel.isLoading, isPinned: false, - inPane: paneId + inPane: paneId, + select: shouldFocusNewTab ) else { panels.removeValue(forKey: browserPanel.id) - panelTitles.removeValue(forKey: browserPanel.id) + removeSurfaceState(panelId: browserPanel.id) return nil } - surfaceIdToPanelId[newTabId] = browserPanel.id setPreferredBrowserProfileID(browserPanel.profileID) // Keyboard/browser-open paths want "new tab at end" regardless of global new-tab placement. if insertAtEnd { - let targetIndex = max(0, bonsplitController.tabs(inPane: paneId).count - 1) - _ = bonsplitController.reorderTab(newTabId, toIndex: targetIndex) + let targetIndex = splitController.tabIds(inPane: paneId).count + _ = splitController.reorderTab(newTabId, toIndex: targetIndex) } // Match terminal behavior: enforce deterministic selection + focus. if shouldFocusNewTab { - bonsplitController.focusPane(paneId) - bonsplitController.selectTab(newTabId) + splitController.focusPane(paneId) + splitController.selectTab(newTabId) browserPanel.focus() applyTabSelection(tabId: newTabId, inPane: paneId) - } else { - preserveFocusAfterNonFocusSplit( - preferredPanelId: previousFocusedPanelId, - splitPanelId: browserPanel.id, - previousHostedView: previousHostedView - ) } installBrowserPanelSubscription(browserPanel) @@ -10349,11 +11659,9 @@ final class Workspace: Identifiable, ObservableObject { filePath: String, focus: Bool = true ) -> MarkdownPanel? { - guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } var sourcePaneId: PaneID? - for paneId in bonsplitController.allPaneIds { - let tabs = bonsplitController.tabs(inPane: paneId) - if tabs.contains(where: { $0.id == sourceTabId }) { + for paneId in splitController.allPaneIds { + if containsSurface(panelId, inPane: paneId) { sourcePaneId = paneId break } @@ -10363,25 +11671,19 @@ final class Workspace: Identifiable, ObservableObject { let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath) panels[markdownPanel.id] = markdownPanel - panelTitles[markdownPanel.id] = markdownPanel.displayTitle - - let newTab = Bonsplit.Tab( - title: markdownPanel.displayTitle, - icon: markdownPanel.displayIcon, - kind: SurfaceKind.markdown, - isDirty: markdownPanel.isDirty, - isLoading: false, - isPinned: false - ) - surfaceIdToPanelId[newTab.id] = markdownPanel.id - let previousFocusedPanelId = focusedPanelId + updateSurfaceState(panelId: markdownPanel.id) { $0.title = markdownPanel.displayTitle } isProgrammaticSplit = true defer { isProgrammaticSplit = false } - guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { - surfaceIdToPanelId.removeValue(forKey: newTab.id) + guard splitController.splitPane( + paneId, + orientation: orientation, + withTabId: TabID(id: markdownPanel.id), + insertFirst: insertFirst, + focusNewPane: focus + ) != nil else { panels.removeValue(forKey: markdownPanel.id) - panelTitles.removeValue(forKey: markdownPanel.id) + removeSurfaceState(panelId: markdownPanel.id) return nil } @@ -10389,15 +11691,6 @@ final class Workspace: Identifiable, ObservableObject { if focus { previousHostedView?.suppressReparentFocus() focusPanel(markdownPanel.id) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() - } - } else { - preserveFocusAfterNonFocusSplit( - preferredPanelId: previousFocusedPanelId, - splitPanelId: markdownPanel.id, - previousHostedView: previousHostedView - ) } installMarkdownPanelSubscription(markdownPanel) @@ -10405,44 +11698,32 @@ final class Workspace: Identifiable, ObservableObject { } @discardableResult - func newMarkdownSurface( + private func newMarkdownSurface( inPane paneId: PaneID, filePath: String, focus: Bool? = nil ) -> MarkdownPanel? { - let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) - let previousFocusedPanelId = focusedPanelId - let previousHostedView = focusedTerminalPanel?.hostedView - + let shouldFocusNewTab = focus ?? (splitController.focusedPaneId == paneId) let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath) panels[markdownPanel.id] = markdownPanel - panelTitles[markdownPanel.id] = markdownPanel.displayTitle + updateSurfaceState(panelId: markdownPanel.id) { $0.title = markdownPanel.displayTitle } - guard let newTabId = bonsplitController.createTab( + guard let newTabId = splitController.createTab( + id: TabID(id: markdownPanel.id), title: markdownPanel.displayTitle, - icon: markdownPanel.displayIcon, - kind: SurfaceKind.markdown, - isDirty: markdownPanel.isDirty, - isLoading: false, isPinned: false, - inPane: paneId + inPane: paneId, + select: shouldFocusNewTab ) else { panels.removeValue(forKey: markdownPanel.id) - panelTitles.removeValue(forKey: markdownPanel.id) + removeSurfaceState(panelId: markdownPanel.id) return nil } - surfaceIdToPanelId[newTabId] = markdownPanel.id if shouldFocusNewTab { - bonsplitController.focusPane(paneId) - bonsplitController.selectTab(newTabId) + splitController.focusPane(paneId) + splitController.selectTab(newTabId) applyTabSelection(tabId: newTabId, inPane: paneId) - } else { - preserveFocusAfterNonFocusSplit( - preferredPanelId: previousFocusedPanelId, - splitPanelId: markdownPanel.id, - previousHostedView: previousHostedView - ) } installMarkdownPanelSubscription(markdownPanel) @@ -10453,70 +11734,62 @@ final class Workspace: Identifiable, ObservableObject { /// Called before the workspace is removed from TabManager to ensure child /// processes receive SIGHUP even if ARC deallocation is delayed. func teardownAllPanels() { - // Hide portal-hosted content up front so a workspace being torn down - // cannot keep drawing above the next selected/restored workspace while - // panel close work is still unwinding. - hideAllTerminalPortalViews() - hideAllBrowserPortalViews() - let panelEntries = Array(panels) for (panelId, panel) in panelEntries { - removePendingTerminalInputObservers(forPanelId: panelId) panelSubscriptions.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) panel.close() } panels.removeAll(keepingCapacity: false) - surfaceIdToPanelId.removeAll(keepingCapacity: false) + surfaceRegistry.removeAllSurfaces() panelSubscriptions.removeAll(keepingCapacity: false) pendingRemoteTerminalChildExitSurfaceIds.removeAll(keepingCapacity: false) pruneSurfaceMetadata(validSurfaceIds: []) restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) - pendingTerminalInputObserversByPanelId.removeAll(keepingCapacity: false) terminalInheritanceFontPointsByPanelId.removeAll(keepingCapacity: false) lastTerminalConfigInheritancePanelId = nil lastTerminalConfigInheritanceFontPoints = nil } /// Close a panel. - /// Returns true when a bonsplit tab close request was issued. + /// Returns true when a WorkspaceSplit tab close request was issued. func closePanel(_ panelId: UUID, force: Bool = false) -> Bool { if let tabId = surfaceIdFromPanelId(panelId) { if force { forceCloseTabIds.insert(tabId) } - // Close the tab in bonsplit (this triggers delegate callback) - return bonsplitController.closeTab(tabId) + // Close the tab in WorkspaceSplit (this triggers delegate callback) + return splitController.closeTab(tabId) } // Mapping can transiently drift during split-tree mutations. If the target panel is // currently focused (or is the active terminal first responder), close whichever tab - // bonsplit marks selected in that focused pane. + // WorkspaceSplit marks selected in that focused pane. let firstResponderPanelId = cmuxOwningGhosttyView( for: NSApp.keyWindow?.firstResponder ?? NSApp.mainWindow?.firstResponder )?.terminalSurface?.id let targetIsActive = focusedPanelId == panelId || firstResponderPanelId == panelId guard targetIsActive, - let focusedPane = bonsplitController.focusedPaneId, - let selected = bonsplitController.selectedTab(inPane: focusedPane) else { + let focusedPane = splitController.focusedPaneId, + let selected = splitController.selectedTabId(inPane: focusedPane) else { #if DEBUG - cmuxDebugLog( + dlog( "surface.close.fallback.skip panel=\(panelId.uuidString.prefix(5)) " + "focusedPanel=\(focusedPanelId?.uuidString.prefix(5) ?? "nil") " + "firstResponderPanel=\(firstResponderPanelId?.uuidString.prefix(5) ?? "nil") " + - "focusedPane=\(bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil")" + "focusedPane=\(splitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil")" ) #endif return false } if force { - forceCloseTabIds.insert(selected.id) + forceCloseTabIds.insert(selected) } - let closed = bonsplitController.closeTab(selected.id) + let closed = splitController.closeTab(selected) #if DEBUG - cmuxDebugLog( + dlog( "surface.close.fallback panel=\(panelId.uuidString.prefix(5)) " + "selectedTab=\(String(describing: selected.id).prefix(5)) " + "closed=\(closed ? 1 : 0)" @@ -10526,16 +11799,14 @@ final class Workspace: Identifiable, ObservableObject { } func paneId(forPanelId panelId: UUID) -> PaneID? { - guard let tabId = surfaceIdFromPanelId(panelId) else { return nil } - return bonsplitController.allPaneIds.first { paneId in - bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabId }) + return splitController.allPaneIds.first { paneId in + containsSurface(panelId, inPane: paneId) } } func indexInPane(forPanelId panelId: UUID) -> Int? { - guard let tabId = surfaceIdFromPanelId(panelId), - let paneId = paneId(forPanelId: panelId) else { return nil } - return bonsplitController.tabs(inPane: paneId).firstIndex(where: { $0.id == tabId }) + guard let paneId = paneId(forPanelId: panelId) else { return nil } + return surfaceIds(inPane: paneId).firstIndex(of: panelId) } /// Returns the nearest right-side sibling pane for browser placement. @@ -10544,10 +11815,10 @@ final class Workspace: Identifiable, ObservableObject { func preferredBrowserTargetPane(fromPanelId panelId: UUID) -> PaneID? { guard let sourcePane = paneId(forPanelId: panelId) else { return nil } let sourcePaneId = sourcePane.id.uuidString - let tree = bonsplitController.treeSnapshot() + let tree = treeSnapshot() guard let path = browserPathToPane(targetPaneId: sourcePaneId, node: tree) else { return nil } - let layout = bonsplitController.layoutSnapshot() + let layout = splitController.layoutSnapshot() let paneFrameById = Dictionary(uniqueKeysWithValues: layout.panes.map { ($0.paneId, $0.frame) }) let sourceFrame = paneFrameById[sourcePaneId] let sourceCenterY = sourceFrame.map { $0.y + ($0.height * 0.5) } ?? 0 @@ -10575,7 +11846,7 @@ final class Workspace: Identifiable, ObservableObject { for candidate in sorted { guard let candidateUUID = UUID(uuidString: candidate.id), candidateUUID != sourcePane.id, - let pane = bonsplitController.allPaneIds.first(where: { $0.id == candidateUUID }) else { + let pane = splitController.allPaneIds.first(where: { $0.id == candidateUUID }) else { continue } return pane @@ -10589,13 +11860,13 @@ final class Workspace: Identifiable, ObservableObject { /// When a workspace is already split, sidebar PR opens should reuse an existing pane /// instead of creating additional right splits. func topRightBrowserReusePane() -> PaneID? { - let paneIds = bonsplitController.allPaneIds + let paneIds = splitController.allPaneIds guard paneIds.count > 1 else { return nil } let paneById = Dictionary(uniqueKeysWithValues: paneIds.map { ($0.id.uuidString, $0) }) var paneBounds: [String: CGRect] = [:] browserCollectNormalizedPaneBounds( - node: bonsplitController.treeSnapshot(), + node: treeSnapshot(), availableRect: CGRect(x: 0, y: 0, width: 1, height: 1), into: &paneBounds ) @@ -10719,22 +11990,22 @@ final class Workspace: Identifiable, ObservableObject { let anchorPaneId: UUID? } - private func stageClosedBrowserRestoreSnapshotIfNeeded(for tab: Bonsplit.Tab, inPane pane: PaneID) { - guard let panelId = panelIdFromSurfaceId(tab.id), + private func stageClosedBrowserRestoreSnapshotIfNeeded(for tabId: TabID, inPane pane: PaneID) { + guard let panelId = panel(for: tabId)?.id, let browserPanel = browserPanel(for: panelId), - let tabIndex = bonsplitController.tabs(inPane: pane).firstIndex(where: { $0.id == tab.id }) else { - pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tab.id) + let tabIndex = splitController.tabIds(inPane: pane).firstIndex(of: tabId) else { + pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tabId) return } let fallbackPlan = browserCloseFallbackPlan( forPaneId: pane.id.uuidString, - in: bonsplitController.treeSnapshot() + in: treeSnapshot() ) let resolvedURL = browserPanel.currentURL ?? browserPanel.preferredURLStringForOmnibar().flatMap(URL.init(string:)) - pendingClosedBrowserRestoreSnapshots[tab.id] = ClosedBrowserPanelRestoreSnapshot( + pendingClosedBrowserRestoreSnapshots[tabId] = ClosedBrowserPanelRestoreSnapshot( workspaceId: id, url: resolvedURL, profileID: browserPanel.profileID, @@ -10825,31 +12096,137 @@ final class Workspace: Identifiable, ObservableObject { @discardableResult func moveSurface(panelId: UUID, toPane paneId: PaneID, atIndex index: Int? = nil, focus: Bool = true) -> Bool { guard let tabId = surfaceIdFromPanelId(panelId) else { return false } - guard bonsplitController.allPaneIds.contains(paneId) else { return false } - guard bonsplitController.moveTab(tabId, toPane: paneId, atIndex: index) else { return false } + guard splitController.allPaneIds.contains(paneId) else { return false } + guard splitController.moveTab(tabId, toPane: paneId, atIndex: index) else { return false } if focus { - bonsplitController.focusPane(paneId) - bonsplitController.selectTab(tabId) + splitController.focusPane(paneId) + splitController.selectTab(tabId) focusPanel(panelId) } else { scheduleFocusReconcile() } - scheduleTerminalGeometryReconcile() return true } + @discardableResult + func splitSurface( + panelId: UUID, + inPane targetPane: PaneID? = nil, + orientation: SplitOrientation, + insertFirst: Bool, + focusNewPane: Bool = true + ) -> PaneID? { + guard panels[panelId] != nil, + let tabId = surfaceIdFromPanelId(panelId) else { return nil } + let targetPaneId = targetPane ?? paneId(forPanelId: panelId) + + let newPaneId = splitController.splitPane( + targetPaneId, + orientation: orientation, + movingTab: tabId, + insertFirst: insertFirst, + focusNewPane: focusNewPane + ) + guard let newPaneId else { return nil } + if focusNewPane { + focusPanel(panelId) + } else { + scheduleFocusReconcile() + } + return newPaneId + } + + @discardableResult + func splitSurfaceByDrag( + panelId: UUID, + inPane targetPane: PaneID? = nil, + orientation: SplitOrientation, + insertFirst: Bool, + focusNewPane: Bool = true + ) -> PaneID? { + let isBrowserDrag = browserPanel(for: panelId) != nil + let sourcePaneId = paneId(forPanelId: panelId) + let targetPaneId = targetPane ?? sourcePaneId + let replacementPanelId = prepareReplacementPanelForSelfSplitIfNeeded( + movingPanelId: panelId, + sourcePaneId: sourcePaneId, + targetPaneId: targetPaneId + ) + if isBrowserDrag { + } + + guard let newPaneId = splitSurface( + panelId: panelId, + inPane: targetPaneId, + orientation: orientation, + insertFirst: insertFirst, + focusNewPane: focusNewPane + ) else { + if let replacementPanelId { + _ = closePanel(replacementPanelId, force: true) + } + if isBrowserDrag { + } + return nil + } + + if replacementPanelId != nil { + scheduleFocusReconcile() + } + + if isBrowserDrag { + } + + return newPaneId + } + + private func prepareReplacementPanelForSelfSplitIfNeeded( + movingPanelId panelId: UUID, + sourcePaneId: PaneID?, + targetPaneId: PaneID? + ) -> UUID? { + guard let sourcePaneId, + let targetPaneId, + sourcePaneId == targetPaneId, + splitController.tabIds(inPane: sourcePaneId).count == 1 else { + return nil + } + + if terminalPanel(for: panelId) != nil { + return createTerminalPanel(inPane: sourcePaneId, focus: false)?.id + } + + if let browser = browserPanel(for: panelId) { + return createBrowserPanel( + inPane: sourcePaneId, + url: URL(string: "about:blank"), + focus: false, + preferredProfileID: browser.profileID + )?.id + } + + if let markdown = markdownPanel(for: panelId) { + return createMarkdownPanel( + inPane: sourcePaneId, + filePath: markdown.filePath, + focus: false + )?.id + } + + return nil + } + @discardableResult func reorderSurface(panelId: UUID, toIndex index: Int) -> Bool { guard let tabId = surfaceIdFromPanelId(panelId) else { return false } - guard bonsplitController.reorderTab(tabId, toIndex: index) else { return false } + guard splitController.reorderTab(tabId, toIndex: index) else { return false } if let paneId = paneId(forPanelId: panelId) { applyTabSelection(tabId: tabId, inPane: paneId) } else { scheduleFocusReconcile() } - scheduleTerminalGeometryReconcile() return true } @@ -10861,7 +12238,7 @@ final class Workspace: Identifiable, ObservableObject { && activeRemoteTerminalSurfaceIds.count == 1 #if DEBUG let detachStart = ProcessInfo.processInfo.systemUptime - cmuxDebugLog( + dlog( "split.detach.begin ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + "tab=\(tabId.uuid.uuidString.prefix(5)) activeDetachTxn=\(activeDetachCloseTransactions) " + "pendingDetached=\(pendingDetachedSurfaces.count)" @@ -10872,12 +12249,12 @@ final class Workspace: Identifiable, ObservableObject { forceCloseTabIds.insert(tabId) activeDetachCloseTransactions += 1 defer { activeDetachCloseTransactions = max(0, activeDetachCloseTransactions - 1) } - guard bonsplitController.closeTab(tabId) else { + guard splitController.closeTab(tabId) else { detachingTabIds.remove(tabId) pendingDetachedSurfaces.removeValue(forKey: tabId) forceCloseTabIds.remove(tabId) #if DEBUG - cmuxDebugLog( + dlog( "split.detach.fail ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + "tab=\(tabId.uuid.uuidString.prefix(5)) reason=closeTabRejected elapsedMs=\(debugElapsedMs(since: detachStart))" ) @@ -10893,7 +12270,7 @@ final class Workspace: Identifiable, ObservableObject { } } #if DEBUG - cmuxDebugLog( + dlog( "split.detach.end ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + "tab=\(tabId.uuid.uuidString.prefix(5)) transfer=\(detached != nil ? 1 : 0) " + "elapsedMs=\(debugElapsedMs(since: detachStart))" @@ -10911,14 +12288,14 @@ final class Workspace: Identifiable, ObservableObject { ) -> UUID? { #if DEBUG let attachStart = ProcessInfo.processInfo.systemUptime - cmuxDebugLog( + dlog( "split.attach.begin ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + "pane=\(paneId.id.uuidString.prefix(5)) index=\(index.map(String.init) ?? "nil") focus=\(focus ? 1 : 0)" ) #endif - guard bonsplitController.allPaneIds.contains(paneId) else { + guard splitController.allPaneIds.contains(paneId) else { #if DEBUG - cmuxDebugLog( + dlog( "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + "reason=invalidPane elapsedMs=\(debugElapsedMs(since: attachStart))" ) @@ -10927,7 +12304,7 @@ final class Workspace: Identifiable, ObservableObject { } guard panels[detached.panelId] == nil else { #if DEBUG - cmuxDebugLog( + dlog( "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + "reason=panelExists elapsedMs=\(debugElapsedMs(since: attachStart))" ) @@ -10950,56 +12327,32 @@ final class Workspace: Identifiable, ObservableObject { } if let directory = detached.directory { - panelDirectories[detached.panelId] = directory + updateSurfaceState(panelId: detached.panelId) { $0.directory = directory } } - if let ttyName = detached.ttyName?.trimmingCharacters(in: .whitespacesAndNewlines), !ttyName.isEmpty { - surfaceTTYNames[detached.panelId] = ttyName - } else { - surfaceTTYNames.removeValue(forKey: detached.panelId) + let trimmedTTYName = detached.ttyName?.trimmingCharacters(in: .whitespacesAndNewlines) + updateSurfaceState(panelId: detached.panelId) { state in + state.ttyName = trimmedTTYName?.isEmpty == false ? trimmedTTYName : nil + state.title = detached.cachedTitle + state.customTitle = detached.customTitle + state.isPinned = detached.isPinned + state.isManuallyUnread = detached.manuallyUnread + state.manualUnreadMarkedAt = detached.manuallyUnread ? .distantPast : nil } syncRemotePortScanTTYs() - if let cachedTitle = detached.cachedTitle { - panelTitles[detached.panelId] = cachedTitle - } - if let customTitle = detached.customTitle { - panelCustomTitles[detached.panelId] = customTitle - } - if detached.isPinned { - pinnedPanelIds.insert(detached.panelId) - } else { - pinnedPanelIds.remove(detached.panelId) - } - if detached.manuallyUnread { - manualUnreadPanelIds.insert(detached.panelId) - manualUnreadMarkedAt[detached.panelId] = .distantPast - } else { - manualUnreadPanelIds.remove(detached.panelId) - manualUnreadMarkedAt.removeValue(forKey: detached.panelId) - } - guard let newTabId = bonsplitController.createTab( + guard let newTabId = splitController.createTab( + id: TabID(id: detached.panelId), title: detached.title, - hasCustomTitle: detached.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false, - icon: detached.icon, - iconImageData: detached.iconImageData, - kind: detached.kind, - isDirty: detached.panel.isDirty, - isLoading: detached.isLoading, isPinned: detached.isPinned, - inPane: paneId + inPane: paneId, + select: focus ) else { panels.removeValue(forKey: detached.panelId) - panelDirectories.removeValue(forKey: detached.panelId) - surfaceTTYNames.removeValue(forKey: detached.panelId) + removeSurfaceState(panelId: detached.panelId) syncRemotePortScanTTYs() - panelTitles.removeValue(forKey: detached.panelId) - panelCustomTitles.removeValue(forKey: detached.panelId) - pinnedPanelIds.remove(detached.panelId) - manualUnreadPanelIds.remove(detached.panelId) - manualUnreadMarkedAt.removeValue(forKey: detached.panelId) panelSubscriptions.removeValue(forKey: detached.panelId) #if DEBUG - cmuxDebugLog( + dlog( "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + "reason=createTabFailed elapsedMs=\(debugElapsedMs(since: attachStart))" ) @@ -11007,7 +12360,6 @@ final class Workspace: Identifiable, ObservableObject { return nil } - surfaceIdToPanelId[newTabId] = detached.panelId let didAdoptWorkspaceRemoteTracking = detached.isRemoteTerminal && detached.remoteRelayPort == remoteConfiguration?.relayPort @@ -11024,24 +12376,22 @@ final class Workspace: Identifiable, ObservableObject { transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: detached.panelId) } if let index { - _ = bonsplitController.reorderTab(newTabId, toIndex: index) + _ = splitController.reorderTab(newTabId, toIndex: index) } syncPinnedStateForTab(newTabId, panelId: detached.panelId) - syncUnreadBadgeStateForPanel(detached.panelId) normalizePinnedTabs(in: paneId) if focus { - bonsplitController.focusPane(paneId) - bonsplitController.selectTab(newTabId) + splitController.focusPane(paneId) + splitController.selectTab(newTabId) detached.panel.focus() applyTabSelection(tabId: newTabId, inPane: paneId) } else { scheduleFocusReconcile() } - scheduleTerminalGeometryReconcile() #if DEBUG - cmuxDebugLog( + dlog( "split.attach.end ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + "tab=\(newTabId.uuid.uuidString.prefix(5)) pane=\(paneId.id.uuidString.prefix(5)) " + "index=\(index.map(String.init) ?? "nil") focus=\(focus ? 1 : 0) " + @@ -11052,103 +12402,15 @@ final class Workspace: Identifiable, ObservableObject { } // MARK: - Focus Management - private func preserveFocusAfterNonFocusSplit( - preferredPanelId: UUID?, - splitPanelId: UUID, - previousHostedView: GhosttySurfaceScrollView? - ) { - guard let preferredPanelId, panels[preferredPanelId] != nil else { - clearNonFocusSplitFocusReassert() - scheduleFocusReconcile() - return - } - - let generation = beginNonFocusSplitFocusReassert( - preferredPanelId: preferredPanelId, - splitPanelId: splitPanelId - ) - - // Bonsplit splitPane focuses the newly created pane and may emit one delayed - // didSelect/didFocus callback. Re-assert focus over multiple turns so model - // focus and AppKit first responder stay aligned with non-focus-intent splits. - reassertFocusAfterNonFocusSplit( - generation: generation, - preferredPanelId: preferredPanelId, - splitPanelId: splitPanelId, - previousHostedView: previousHostedView, - allowPreviousHostedView: true - ) - - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.reassertFocusAfterNonFocusSplit( - generation: generation, - preferredPanelId: preferredPanelId, - splitPanelId: splitPanelId, - previousHostedView: previousHostedView, - allowPreviousHostedView: false - ) - - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.reassertFocusAfterNonFocusSplit( - generation: generation, - preferredPanelId: preferredPanelId, - splitPanelId: splitPanelId, - previousHostedView: previousHostedView, - allowPreviousHostedView: false - ) - self.scheduleFocusReconcile() - self.clearNonFocusSplitFocusReassert(generation: generation) - } - } - } - - private func reassertFocusAfterNonFocusSplit( - generation: UInt64, - preferredPanelId: UUID, - splitPanelId: UUID, - previousHostedView: GhosttySurfaceScrollView?, - allowPreviousHostedView: Bool - ) { - guard matchesPendingNonFocusSplitFocusReassert( - generation: generation, - preferredPanelId: preferredPanelId, - splitPanelId: splitPanelId - ) else { - return - } - - guard panels[preferredPanelId] != nil else { - clearNonFocusSplitFocusReassert(generation: generation) - return - } - - if focusedPanelId == splitPanelId { - focusPanel( - preferredPanelId, - previousHostedView: allowPreviousHostedView ? previousHostedView : nil - ) - return - } - - guard focusedPanelId == preferredPanelId, - let terminalPanel = terminalPanel(for: preferredPanelId) else { - return - } - terminalPanel.hostedView.ensureFocus(for: id, surfaceId: preferredPanelId) - } - func focusPanel( _ panelId: UUID, previousHostedView: GhosttySurfaceScrollView? = nil, trigger: FocusPanelTrigger = .standard ) { - markExplicitFocusIntent(on: panelId) #if DEBUG - let pane = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" + let pane = splitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" let triggerLabel = trigger == .terminalFirstResponder ? "firstResponder" : "standard" - cmuxDebugLog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane) trigger=\(triggerLabel)") + dlog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane) trigger=\(triggerLabel)") FocusLogStore.shared.append( "Workspace.focusPanel panelId=\(panelId.uuidString) focusedPane=\(pane) trigger=\(triggerLabel)" ) @@ -11160,29 +12422,29 @@ final class Workspace: Identifiable, ObservableObject { // responder when focusing another terminal (helps avoid "highlighted but typing goes to // another pane" after heavy split/tab mutations). // When a caller passes an explicit previousHostedView (e.g. during split creation where - // bonsplit has already mutated focusedPaneId), prefer it over the derived value. + // WorkspaceSplit has already mutated focusedPaneId), prefer it over the derived value. let previousTerminalHostedView = previousHostedView ?? focusedTerminalPanel?.hostedView - // `selectTab` does not necessarily move bonsplit's focused pane. For programmatic focus + // `selectTab` does not necessarily move WorkspaceSplit's focused pane. For programmatic focus // (socket API, notification click, etc.), ensure the target tab's pane becomes focused // so `focusedPanelId` and follow-on focus logic are coherent. - let targetPaneId = bonsplitController.allPaneIds.first(where: { paneId in - bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabId }) + let targetPaneId = splitController.allPaneIds.first(where: { paneId in + splitController.tabIds(inPane: paneId).contains(tabId) }) let selectionAlreadyConverged: Bool = { guard let targetPaneId else { return false } - return bonsplitController.focusedPaneId == targetPaneId && - bonsplitController.selectedTab(inPane: targetPaneId)?.id == tabId + return splitController.focusedPaneId == targetPaneId && + splitController.selectedTabId(inPane: targetPaneId) == tabId }() let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged #if DEBUG let targetPaneShort = targetPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil" - let focusedPaneShort = bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil" - let selectedTabShort = bonsplitController.focusedPaneId - .flatMap { bonsplitController.selectedTab(inPane: $0)?.id } + let focusedPaneShort = splitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil" + let selectedTabShort = splitController.focusedPaneId + .flatMap { splitController.selectedTabId(inPane: $0) } .map { String($0.uuid.uuidString.prefix(5)) } ?? "nil" let currentPanelShort = currentlyFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - cmuxDebugLog( + dlog( "focus.panel.begin workspace=\(id.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) trigger=\(String(describing: trigger)) " + "targetPane=\(targetPaneShort) focusedPane=\(focusedPaneShort) selectedTab=\(selectedTabShort) " + @@ -11190,7 +12452,7 @@ final class Workspace: Identifiable, ObservableObject { "currentPanel=\(currentPanelShort)" ) if shouldSuppressReentrantRefocus { - cmuxDebugLog( + dlog( "focus.panel.skipReentrant panel=\(panelId.uuidString.prefix(5)) " + "reason=firstResponderAlreadyConverged" ) @@ -11199,22 +12461,22 @@ final class Workspace: Identifiable, ObservableObject { if let targetPaneId, !selectionAlreadyConverged { #if DEBUG - cmuxDebugLog( + dlog( "focus.panel.focusPane workspace=\(id.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) pane=\(targetPaneId.id.uuidString.prefix(5))" ) #endif - bonsplitController.focusPane(targetPaneId) + splitController.focusPane(targetPaneId) } if !selectionAlreadyConverged { #if DEBUG - cmuxDebugLog( + dlog( "focus.panel.selectTab workspace=\(id.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) tab=\(tabId.uuid.uuidString.prefix(5))" ) #endif - bonsplitController.selectTab(tabId) + splitController.selectTab(tabId) } if let targetPaneId { @@ -11231,14 +12493,6 @@ final class Workspace: Identifiable, ObservableObject { if let browserPanel = panels[panelId] as? BrowserPanel { maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger) } - - if trigger == .terminalFirstResponder, - panels[panelId] is TerminalPanel { - beginEventDrivenLayoutFollowUp( - reason: "workspace.focusPanel.terminal", - terminalFocusPanelId: panelId - ) - } } private func maybeAutoFocusBrowserAddressBarOnPanelFocus( @@ -11283,12 +12537,12 @@ final class Workspace: Identifiable, ObservableObject { prev.unfocus() } - bonsplitController.navigateFocus(direction: direction) + splitController.navigateFocus(direction: direction) // Always reconcile selection/focus after navigation so AppKit first-responder and - // bonsplit's focused pane stay aligned, even through split tree mutations. - if let paneId = bonsplitController.focusedPaneId, - let tabId = bonsplitController.selectedTab(inPane: paneId)?.id { + // WorkspaceSplit's focused pane stay aligned, even through split tree mutations. + if let paneId = splitController.focusedPaneId, + let tabId = splitController.selectedTabId(inPane: paneId) { applyTabSelection(tabId: tabId, inPane: paneId) } @@ -11298,80 +12552,76 @@ final class Workspace: Identifiable, ObservableObject { /// Select the next surface in the currently focused pane func selectNextSurface() { - bonsplitController.selectNextTab() + splitController.selectNextTab() - if let paneId = bonsplitController.focusedPaneId, - let tabId = bonsplitController.selectedTab(inPane: paneId)?.id { + if let paneId = splitController.focusedPaneId, + let tabId = splitController.selectedTabId(inPane: paneId) { applyTabSelection(tabId: tabId, inPane: paneId) } } /// Select the previous surface in the currently focused pane func selectPreviousSurface() { - bonsplitController.selectPreviousTab() + splitController.selectPreviousTab() - if let paneId = bonsplitController.focusedPaneId, - let tabId = bonsplitController.selectedTab(inPane: paneId)?.id { + if let paneId = splitController.focusedPaneId, + let tabId = splitController.selectedTabId(inPane: paneId) { applyTabSelection(tabId: tabId, inPane: paneId) } } /// Select a surface by index in the currently focused pane func selectSurface(at index: Int) { - guard let focusedPaneId = bonsplitController.focusedPaneId else { return } - let tabs = bonsplitController.tabs(inPane: focusedPaneId) - guard index >= 0 && index < tabs.count else { return } - bonsplitController.selectTab(tabs[index].id) + guard let focusedPaneId = splitController.focusedPaneId else { return } + let tabIds = splitController.tabIds(inPane: focusedPaneId) + guard index >= 0 && index < tabIds.count else { return } + splitController.selectTab(tabIds[index]) - if let tabId = bonsplitController.selectedTab(inPane: focusedPaneId)?.id { + if let tabId = splitController.selectedTabId(inPane: focusedPaneId) { applyTabSelection(tabId: tabId, inPane: focusedPaneId) } } /// Select the last surface in the currently focused pane func selectLastSurface() { - guard let focusedPaneId = bonsplitController.focusedPaneId else { return } - let tabs = bonsplitController.tabs(inPane: focusedPaneId) - guard let last = tabs.last else { return } - bonsplitController.selectTab(last.id) + guard let focusedPaneId = splitController.focusedPaneId else { return } + let tabIds = splitController.tabIds(inPane: focusedPaneId) + guard let last = tabIds.last else { return } + splitController.selectTab(last) - if let tabId = bonsplitController.selectedTab(inPane: focusedPaneId)?.id { + if let tabId = splitController.selectedTabId(inPane: focusedPaneId) { applyTabSelection(tabId: tabId, inPane: focusedPaneId) } } /// Create a new terminal surface in the currently focused pane @discardableResult - func newTerminalSurfaceInFocusedPane(focus: Bool? = nil, initialInput: String? = nil) -> TerminalPanel? { - guard let focusedPaneId = bonsplitController.focusedPaneId else { return nil } - return newTerminalSurface(inPane: focusedPaneId, focus: focus, initialInput: initialInput) + func newTerminalSurfaceInFocusedPane(focus: Bool? = nil) -> TerminalPanel? { + guard let focusedPaneId = splitController.focusedPaneId else { return nil } + return createTerminalPanel(inPane: focusedPaneId, focus: focus) } @discardableResult func clearSplitZoom() -> Bool { - bonsplitController.clearPaneZoom() + splitController.clearPaneZoom() + } + + @discardableResult + func closePane(_ paneId: PaneID) -> Bool { + splitController.closePane(paneId) } @discardableResult func toggleSplitZoom(panelId: UUID) -> Bool { - let wasSplitZoomed = bonsplitController.isSplitZoomed guard let paneId = paneId(forPanelId: panelId) else { return false } - guard bonsplitController.togglePaneZoom(inPane: paneId) else { return false } + guard splitController.togglePaneZoom(inPane: paneId) else { return false } focusPanel(panelId) - reconcileTerminalPortalVisibilityForCurrentRenderedLayout() - reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: "workspace.toggleSplitZoom") if let browserPanel = browserPanel(for: panelId) { browserPanel.preparePortalHostReplacementForNextDistinctClaim( inPane: paneId, reason: "workspace.toggleSplitZoom" ) } - beginEventDrivenLayoutFollowUp( - reason: "workspace.toggleSplitZoom", - browserPanelId: browserPanel(for: panelId) != nil ? panelId : nil, - browserExitFocusPanelId: (wasSplitZoomed && !bonsplitController.isSplitZoomed) ? panelId : nil, - includeGeometry: true - ) return true } @@ -11430,7 +12680,7 @@ final class Workspace: Identifiable, ObservableObject { if shouldFocus { focusPanel(panelId) } - let isSplit = bonsplitController.allPaneIds.count > 1 || panels.count > 1 + let isSplit = splitController.allPaneIds.count > 1 || panels.count > 1 if requiresSplit && !isSplit { return } @@ -11448,26 +12698,6 @@ final class Workspace: Identifiable, ObservableObject { requestAttentionFlash(panelId: panelId, reason: .debug) } - // MARK: - Portal Lifecycle - - /// Hide all terminal portal views for this workspace. - /// Called before the workspace is unmounted to prevent portal-hosted terminal - /// views from covering browser panes in the newly selected workspace. - func hideAllTerminalPortalViews() { - for panel in panels.values { - guard let terminal = panel as? TerminalPanel else { continue } - terminal.hostedView.setVisibleInUI(false) - TerminalWindowPortalRegistry.hideHostedView(terminal.hostedView) - } - } - - func hideAllBrowserPortalViews() { - for panel in panels.values { - guard let browser = panel as? BrowserPanel else { continue } - browser.hideBrowserPortalView(source: "workspaceRetire") - } - } - // MARK: - Utility /// Writes a small shell wrapper that prints a banner ("remote ssh ended — target X"), @@ -11533,10 +12763,10 @@ final class Workspace: Identifiable, ObservableObject { /// Create a new terminal panel (used when replacing the last panel) @discardableResult - func createReplacementTerminalPanel() -> TerminalPanel { + func createReplacementTerminalPanel() -> TerminalPanel? { let inheritedConfig = inheritedTerminalConfig( preferredPanelId: focusedPanelId, - inPane: bonsplitController.focusedPaneId + inPane: splitController.focusedPaneId ) // If the previous surface was a remote ssh terminal that just exited, spawn a // local shell that first prints a clearly-coloured banner explaining what happened. @@ -11554,22 +12784,24 @@ final class Workspace: Identifiable, ObservableObject { initialCommand: replacementInitialCommand ) configureTerminalPanel(newPanel) + installTerminalPanelSubscription(newPanel) panels[newPanel.id] = newPanel - panelTitles[newPanel.id] = newPanel.displayTitle + seedInitialTerminalPanelTitle(newPanel) seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) - // Create tab in bonsplit - if let newTabId = bonsplitController.createTab( - title: newPanel.displayTitle, - icon: newPanel.displayIcon, - kind: SurfaceKind.terminal, - isDirty: newPanel.isDirty, + // Create tab in WorkspaceSplit + if splitController.createTab( + id: TabID(id: newPanel.id), + title: panelTitle(panelId: newPanel.id) ?? newPanel.displayTitle, isPinned: false - ) { - surfaceIdToPanelId[newTabId] = newPanel.id + ) != nil { + return newPanel } - return newPanel + panels.removeValue(forKey: newPanel.id) + removeSurfaceState(panelId: newPanel.id) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) + return nil } /// Check if any panel needs close confirmation @@ -11588,646 +12820,118 @@ final class Workspace: Identifiable, ObservableObject { isReconcilingFocusState = true defer { isReconcilingFocusState = false } - // Source of truth: bonsplit focused pane + selected tab. + // Source of truth: WorkspaceSplit focused pane + selected tab. // AppKit first responder must converge to this model state, not the other way around. var targetPanelId: UUID? - if let focusedPane = bonsplitController.focusedPaneId, - let focusedTab = bonsplitController.selectedTab(inPane: focusedPane), - let mappedPanelId = panelIdFromSurfaceId(focusedTab.id), + if let focusedPane = splitController.focusedPaneId, + let focusedTabId = splitController.selectedTabId(inPane: focusedPane), + let mappedPanelId = panel(for: focusedTabId)?.id, panels[mappedPanelId] != nil { targetPanelId = mappedPanelId } else { - for pane in bonsplitController.allPaneIds { - guard let selectedTab = bonsplitController.selectedTab(inPane: pane), - let mappedPanelId = panelIdFromSurfaceId(selectedTab.id), - panels[mappedPanelId] != nil else { continue } - bonsplitController.focusPane(pane) - bonsplitController.selectTab(selectedTab.id) - targetPanelId = mappedPanelId - break - } - } - - if targetPanelId == nil, let fallbackPanelId = panels.keys.first { - targetPanelId = fallbackPanelId - if let fallbackTabId = surfaceIdFromPanelId(fallbackPanelId), - let fallbackPane = bonsplitController.allPaneIds.first(where: { paneId in - bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == fallbackTabId }) - }) { - bonsplitController.focusPane(fallbackPane) - bonsplitController.selectTab(fallbackTabId) - } - } - - guard let targetPanelId, let targetPanel = panels[targetPanelId] else { return } - - for (panelId, panel) in panels where panelId != targetPanelId { - panel.unfocus() - } - - targetPanel.focus() - if let terminalPanel = targetPanel as? TerminalPanel { - terminalPanel.hostedView.ensureFocus(for: id, surfaceId: targetPanelId) - } - if let dir = panelDirectories[targetPanelId] { - currentDirectory = dir - } - gitBranch = panelGitBranches[targetPanelId] - pullRequest = panelPullRequests[targetPanelId] - } - - /// Reconcile focus/first-responder convergence. - /// Coalesce to the next main-queue turn so bonsplit selection/pane mutations settle first. - private func scheduleFocusReconcile() { -#if DEBUG - if isDetachingCloseTransaction { - debugFocusReconcileScheduledDuringDetachCount += 1 - } -#endif - guard !focusReconcileScheduled else { return } - focusReconcileScheduled = true - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.focusReconcileScheduled = false - self.reconcileFocusState() - } - } - - private func beginEventDrivenLayoutFollowUp( - reason: String, - browserPanelId: UUID? = nil, - browserExitFocusPanelId: UUID? = nil, - terminalFocusPanelId: UUID? = nil, - includeGeometry: Bool = false - ) { - layoutFollowUpReason = reason - if let browserPanelId { - layoutFollowUpBrowserPanelId = browserPanelId - } - if let browserExitFocusPanelId { - layoutFollowUpBrowserExitFocusPanelId = browserExitFocusPanelId - } - if let terminalFocusPanelId { - layoutFollowUpTerminalFocusPanelId = terminalFocusPanelId - } - layoutFollowUpNeedsGeometryPass = layoutFollowUpNeedsGeometryPass || includeGeometry - layoutFollowUpStalledAttemptCount = 0 - // Invalidate any pending retry whose delay was computed from a stale stall count. - // Incrementing the version causes old closures to exit early; clearing the flag - // allows scheduleLayoutFollowUpAttempt() below to enqueue a fresh asyncAfter(0). - layoutFollowUpAttemptVersion &+= 1 - layoutFollowUpAttemptScheduled = false - - if layoutFollowUpTimeoutWorkItem == nil { - installLayoutFollowUpObservers() - } - refreshLayoutFollowUpTimeout() - // Use async scheduling instead of a synchronous call here. beginEventDrivenLayoutFollowUp - // is often invoked from splitTabBar(_:didChangeGeometry:), which fires from inside - // SwiftUI's .onChange(of: geometry) during an active layout pass. Calling - // attemptEventDrivenLayoutFollowUp() synchronously in that context causes - // flushWorkspaceWindowLayouts() → displayIfNeeded() to be called re-entrantly, - // incrementing AppKit's per-window constraint-pass counter on every display cycle - // until it exceeds the limit and crashes with NSGenericException. - // scheduleLayoutFollowUpAttempt() defers via asyncAfter(0) so the flush always - // happens after the current layout pass completes. - scheduleLayoutFollowUpAttempt() - } - - private func installLayoutFollowUpObservers() { - guard layoutFollowUpTimeoutWorkItem == nil else { return } - - let enqueueAttempt: () -> Void = { [weak self] in - self?.scheduleLayoutFollowUpAttempt() - } - - layoutFollowUpObservers.append(NotificationCenter.default.addObserver( - forName: NSWindow.didUpdateNotification, - object: nil, - queue: .main - ) { _ in - enqueueAttempt() - }) - layoutFollowUpObservers.append(NotificationCenter.default.addObserver( - forName: .terminalSurfaceDidBecomeReady, - object: nil, - queue: .main - ) { _ in - enqueueAttempt() - }) - layoutFollowUpObservers.append(NotificationCenter.default.addObserver( - forName: .terminalSurfaceHostedViewDidMoveToWindow, - object: nil, - queue: .main - ) { _ in - enqueueAttempt() - }) - layoutFollowUpObservers.append(NotificationCenter.default.addObserver( - forName: .terminalPortalVisibilityDidChange, - object: nil, - queue: .main - ) { _ in - enqueueAttempt() - }) - layoutFollowUpObservers.append(NotificationCenter.default.addObserver( - forName: .browserPortalRegistryDidChange, - object: nil, - queue: .main - ) { _ in - enqueueAttempt() - }) - layoutFollowUpObservers.append(NotificationCenter.default.addObserver( - forName: .ghosttyDidBecomeFirstResponderSurface, - object: nil, - queue: .main - ) { _ in - enqueueAttempt() - }) - layoutFollowUpObservers.append(NotificationCenter.default.addObserver( - forName: .browserDidBecomeFirstResponderWebView, - object: nil, - queue: .main - ) { _ in - enqueueAttempt() - }) - layoutFollowUpPanelsCancellable = $panels - .map { _ in () } - .sink { _ in - enqueueAttempt() - } - } - - private func refreshLayoutFollowUpTimeout() { - layoutFollowUpTimeoutWorkItem?.cancel() - let workItem = DispatchWorkItem { [weak self] in - self?.clearLayoutFollowUp() - } - layoutFollowUpTimeoutWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: workItem) - } - - private func clearLayoutFollowUp() { - layoutFollowUpTimeoutWorkItem?.cancel() - layoutFollowUpTimeoutWorkItem = nil - layoutFollowUpObservers.forEach { NotificationCenter.default.removeObserver($0) } - layoutFollowUpObservers.removeAll() - layoutFollowUpPanelsCancellable?.cancel() - layoutFollowUpPanelsCancellable = nil - layoutFollowUpReason = nil - layoutFollowUpTerminalFocusPanelId = nil - layoutFollowUpBrowserPanelId = nil - layoutFollowUpBrowserExitFocusPanelId = nil - layoutFollowUpNeedsGeometryPass = false - layoutFollowUpAttemptVersion &+= 1 - layoutFollowUpAttemptScheduled = false - layoutFollowUpStalledAttemptCount = 0 - } - - private func scheduleLayoutFollowUpAttempt() { - guard layoutFollowUpTimeoutWorkItem != nil else { return } - guard !layoutFollowUpAttemptScheduled else { return } - - layoutFollowUpAttemptScheduled = true - let delay = layoutFollowUpBackoffDelay() - let version = layoutFollowUpAttemptVersion - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self else { return } - guard self.layoutFollowUpAttemptVersion == version else { return } - self.layoutFollowUpAttemptScheduled = false - self.attemptEventDrivenLayoutFollowUp() - } - } - - private func layoutFollowUpBackoffDelay() -> TimeInterval { - guard layoutFollowUpStalledAttemptCount > 0 else { return 0 } - let baseDelay: TimeInterval = 0.01 - let exponent = min(layoutFollowUpStalledAttemptCount - 1, 5) - return min(0.25, baseDelay * pow(2.0, Double(exponent))) - } - - private func flushWorkspaceWindowLayouts() { - for window in NSApp.windows { - window.contentView?.layoutSubtreeIfNeeded() - window.contentView?.displayIfNeeded() - } - } - - private func browserPortalAnchorReady(for browserPanel: BrowserPanel) -> Bool { - let anchorView = browserPanel.portalAnchorView - return - anchorView.window != nil && - anchorView.superview != nil && - anchorView.bounds.width > 1 && - anchorView.bounds.height > 1 - } - - private func browserPortalReady(for browserPanel: BrowserPanel) -> Bool { - browserPortalAnchorReady(for: browserPanel) && - browserPanel.webView.window != nil && - browserPanel.webView.superview != nil && - BrowserWindowPortalRegistry.isWebView(browserPanel.webView, boundTo: browserPanel.portalAnchorView) - } - - private func browserSplitZoomExitFocusNeedsFollowUp(panelId: UUID) -> Bool { - guard let browserPanel = browserPanel(for: panelId), - let paneId = paneId(forPanelId: panelId), - let tabId = surfaceIdFromPanelId(panelId) else { - return false - } - let selectionConverged = - bonsplitController.focusedPaneId == paneId && - bonsplitController.selectedTab(inPane: paneId)?.id == tabId - return !selectionConverged || !browserPortalAnchorReady(for: browserPanel) - } - - private func terminalFocusNeedsFollowUp() -> Bool { - guard let panelId = layoutFollowUpTerminalFocusPanelId, - let terminalPanel = terminalPanel(for: panelId) else { - return false - } - return focusedPanelId != panelId || !terminalPanel.hostedView.isSurfaceViewFirstResponder() - } - - private func browserPanelNeedsFollowUp() -> Bool { - guard let panelId = layoutFollowUpBrowserPanelId, - let browserPanel = browserPanel(for: panelId) else { - return false - } - return !browserPortalReady(for: browserPanel) - } - - private func attemptEventDrivenLayoutFollowUp() { - guard layoutFollowUpTimeoutWorkItem != nil, !isAttemptingLayoutFollowUp else { return } - isAttemptingLayoutFollowUp = true - defer { isAttemptingLayoutFollowUp = false } - - flushWorkspaceWindowLayouts() - - let geometryPendingBefore = layoutFollowUpNeedsGeometryPass - let terminalPortalPendingBefore = terminalPortalVisibilityNeedsFollowUp() - let browserVisibilityPendingBefore = browserPortalVisibilityNeedsFollowUp() - let terminalFocusPendingBefore = terminalFocusNeedsFollowUp() - let browserPanelPendingBefore = browserPanelNeedsFollowUp() - let browserExitPendingBefore = layoutFollowUpBrowserExitFocusPanelId != nil - - if layoutFollowUpNeedsGeometryPass { - layoutFollowUpNeedsGeometryPass = reconcileTerminalGeometryPass() - } - - if let terminalFocusPanelId = layoutFollowUpTerminalFocusPanelId { - if let terminalPanel = terminalPanel(for: terminalFocusPanelId), - focusedPanelId == terminalFocusPanelId { - terminalPanel.hostedView.ensureFocus(for: id, surfaceId: terminalFocusPanelId) - if terminalPanel.hostedView.isSurfaceViewFirstResponder() { - layoutFollowUpTerminalFocusPanelId = nil - } - } else if terminalPanel(for: terminalFocusPanelId) == nil { - layoutFollowUpTerminalFocusPanelId = nil - } - } - - reconcileTerminalPortalVisibilityForCurrentRenderedLayout() - let terminalPortalPending = terminalPortalVisibilityNeedsFollowUp() - - let reason = layoutFollowUpReason ?? "workspace.layout" - reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: reason) - let browserVisibilityPending = browserPortalVisibilityNeedsFollowUp() - - if let browserPanelId = layoutFollowUpBrowserPanelId { - if let browserPanel = browserPanel(for: browserPanelId) { - let anchorReady = browserPortalAnchorReady(for: browserPanel) - let wasReady = browserPortalReady(for: browserPanel) - if anchorReady && !wasReady { - BrowserWindowPortalRegistry.synchronizeForAnchor(browserPanel.portalAnchorView) - } - let isReady = browserPortalReady(for: browserPanel) - if isReady, - (!wasReady || BrowserWindowPortalRegistry.debugSnapshot(for: browserPanel.webView)?.containerHidden == true) { - BrowserWindowPortalRegistry.refresh( - webView: browserPanel.webView, - reason: reason - ) - } - if isReady { - layoutFollowUpBrowserPanelId = nil - } - } else { - layoutFollowUpBrowserPanelId = nil - } - } - - if let browserExitFocusPanelId = layoutFollowUpBrowserExitFocusPanelId { - if browserSplitZoomExitFocusNeedsFollowUp(panelId: browserExitFocusPanelId) { - if browserPanel(for: browserExitFocusPanelId) != nil { - focusPanel(browserExitFocusPanelId) - scheduleFocusReconcile() - } else { - layoutFollowUpBrowserExitFocusPanelId = nil - } - } else { - layoutFollowUpBrowserExitFocusPanelId = nil - } - } - - let terminalFocusPending = terminalFocusNeedsFollowUp() - let browserPanelPending = browserPanelNeedsFollowUp() - let browserExitPending = layoutFollowUpBrowserExitFocusPanelId != nil - let needsMoreWork = - layoutFollowUpNeedsGeometryPass || - terminalPortalPending || - browserVisibilityPending || - terminalFocusPending || - browserPanelPending || - browserExitPending - - if !needsMoreWork { - clearLayoutFollowUp() - return - } - - let didMakeProgress = - (geometryPendingBefore && !layoutFollowUpNeedsGeometryPass) || - (terminalPortalPendingBefore && !terminalPortalPending) || - (browserVisibilityPendingBefore && !browserVisibilityPending) || - (terminalFocusPendingBefore && !terminalFocusPending) || - (browserPanelPendingBefore && !browserPanelPending) || - (browserExitPendingBefore && !browserExitPending) - - if didMakeProgress { - layoutFollowUpStalledAttemptCount = 0 - scheduleLayoutFollowUpAttempt() - } else { - layoutFollowUpStalledAttemptCount += 1 - } - } - - /// Reconcile remaining terminal view geometries after split topology changes. - /// This keeps AppKit bounds and Ghostty surface sizes in sync in the next runloop turn. - private func reconcileTerminalGeometryPass() -> Bool { - var needsFollowUpPass = false - - // Flush pending AppKit layout first so terminal-host bounds reflect latest split topology. - for window in NSApp.windows { - window.contentView?.layoutSubtreeIfNeeded() - } - - for panel in panels.values { - guard let terminalPanel = panel as? TerminalPanel else { continue } - let hostedView = terminalPanel.hostedView - let hasUsableBounds = hostedView.bounds.width > 1 && hostedView.bounds.height > 1 - let hasSurface = terminalPanel.surface.surface != nil - let isAttached = hostedView.window != nil && hostedView.superview != nil - - // Split close/reparent churn can transiently detach a surviving terminal view. - // Force one SwiftUI representable update so the portal binding reattaches it. - if !isAttached || !hasUsableBounds || !hasSurface { - terminalPanel.requestViewReattach() - needsFollowUpPass = true - } - - hostedView.reconcileGeometryNow() - // Re-check surface after reconcileGeometryNow() which can trigger AppKit - // layout and view lifecycle changes that free surfaces (#432). - if terminalPanel.surface.surface != nil { - terminalPanel.surface.forceRefresh() - } - if terminalPanel.surface.surface == nil, isAttached && hasUsableBounds { - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() - needsFollowUpPass = true - } - } - - return needsFollowUpPass - } - - private func scheduleTerminalGeometryReconcile() { - beginEventDrivenLayoutFollowUp( - reason: "workspace.geometry", - includeGeometry: true - ) - } - - private func renderedVisiblePanelIdsForCurrentLayout() -> Set { - let renderedPaneIds = bonsplitController.zoomedPaneId.map { [$0] } ?? bonsplitController.allPaneIds - var visiblePanelIds: Set = [] - - for paneId in renderedPaneIds { - let selectedTab = bonsplitController.selectedTab(inPane: paneId) ?? bonsplitController.tabs(inPane: paneId).first - guard let selectedTab, - let panelId = panelIdFromSurfaceId(selectedTab.id), - panels[panelId] != nil else { - continue - } - visiblePanelIds.insert(panelId) - } - - if let focusedPanelId, - panels[focusedPanelId] != nil, - let focusedPaneId = paneId(forPanelId: focusedPanelId), - renderedPaneIds.contains(where: { $0.id == focusedPaneId.id }) { - visiblePanelIds.insert(focusedPanelId) - } - - return visiblePanelIds - } - - @discardableResult - private func reconcileTerminalPortalVisibilityForCurrentRenderedLayout() -> Bool { - let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() - var didChange = false - - for panel in panels.values { - guard let terminalPanel = panel as? TerminalPanel else { continue } - let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id) - if terminalPanel.hostedView.debugPortalVisibleInUI != shouldBeVisible { - terminalPanel.hostedView.setVisibleInUI(shouldBeVisible) - didChange = true - } - let shouldBeActive = shouldBeVisible && focusedPanelId == terminalPanel.id - if terminalPanel.hostedView.debugPortalActive != shouldBeActive { - terminalPanel.hostedView.setActive(shouldBeActive) - didChange = true - } - TerminalWindowPortalRegistry.updateEntryVisibility( - for: terminalPanel.hostedView, - visibleInUI: shouldBeVisible - ) - } - - return didChange - } - - private func terminalPortalVisibilityNeedsFollowUp() -> Bool { - let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() - - for panel in panels.values { - guard let terminalPanel = panel as? TerminalPanel else { continue } - let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id) - let hostedView = terminalPanel.hostedView - - if shouldBeVisible { - if hostedView.isHidden || hostedView.window == nil || hostedView.superview == nil { - return true - } - } else if !hostedView.isHidden { - return true - } - } - - return false - } - - @discardableResult - private func reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: String) -> Bool { - let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() - var didChange = false - - for panel in panels.values { - guard let browserPanel = panel as? BrowserPanel else { continue } - let shouldBeVisible = visiblePanelIds.contains(browserPanel.id) - let anchorView = browserPanel.portalAnchorView - let snapshot = BrowserWindowPortalRegistry.debugSnapshot(for: browserPanel.webView) - if shouldBeVisible { - if snapshot?.visibleInUI == false { - BrowserWindowPortalRegistry.updateEntryVisibility( - for: browserPanel.webView, - visibleInUI: true, - zPriority: 2 - ) - didChange = true - } - let anchorReady = browserPortalAnchorReady(for: browserPanel) - let portalReady = browserPortalReady(for: browserPanel) - if anchorReady && !portalReady { - BrowserWindowPortalRegistry.synchronizeForAnchor(anchorView) - if browserPortalReady(for: browserPanel) { - BrowserWindowPortalRegistry.refresh( - webView: browserPanel.webView, - reason: reason - ) - didChange = true - } - } else if anchorReady && snapshot?.containerHidden == true { - BrowserWindowPortalRegistry.refresh( - webView: browserPanel.webView, - reason: reason - ) - didChange = true - } - } else { - let portalNeedsHide = - snapshot?.visibleInUI == true || - snapshot?.containerHidden == false - if portalNeedsHide { - if snapshot?.visibleInUI == true { - BrowserWindowPortalRegistry.updateEntryVisibility( - for: browserPanel.webView, - visibleInUI: false, - zPriority: 0 - ) - } - BrowserWindowPortalRegistry.hide( - webView: browserPanel.webView, - source: reason - ) - didChange = true - } + for pane in splitController.allPaneIds { + guard let selectedTabId = splitController.selectedTabId(inPane: pane), + let mappedPanelId = panel(for: selectedTabId)?.id, + panels[mappedPanelId] != nil else { continue } + splitController.focusPane(pane) + splitController.selectTab(selectedTabId) + targetPanelId = mappedPanelId + break } } - return didChange - } + if targetPanelId == nil, let fallbackPanelId = panels.keys.first { + targetPanelId = fallbackPanelId + if let fallbackTabId = surfaceIdFromPanelId(fallbackPanelId), + let fallbackPane = splitController.allPaneIds.first(where: { paneId in + splitController.tabIds(inPane: paneId).contains(fallbackTabId) + }) { + splitController.focusPane(fallbackPane) + splitController.selectTab(fallbackTabId) + } + } - private func browserPortalVisibilityNeedsFollowUp() -> Bool { - let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() + guard let targetPanelId, let targetPanel = panels[targetPanelId] else { return } - for panel in panels.values { - guard let browserPanel = panel as? BrowserPanel else { continue } - guard visiblePanelIds.contains(browserPanel.id) else { continue } - let anchorView = browserPanel.portalAnchorView - let anchorReady = - anchorView.window != nil && - anchorView.superview != nil && - anchorView.bounds.width > 1 && - anchorView.bounds.height > 1 - if !anchorReady || - browserPanel.webView.window == nil || - browserPanel.webView.superview == nil || - !BrowserWindowPortalRegistry.isWebView(browserPanel.webView, boundTo: anchorView) { - return true - } + for (panelId, panel) in panels where panelId != targetPanelId { + panel.unfocus() } - return false + targetPanel.focus() + if let terminalPanel = targetPanel as? TerminalPanel { + terminalPanel.hostedView.ensureFocus(for: id, surfaceId: targetPanelId) + } + let state = surfaceStateSnapshot(panelId: targetPanelId) + if let dir = state.directory { + currentDirectory = dir + } + gitBranch = state.gitBranch + pullRequest = state.pullRequest } - private func scheduleMovedTerminalRefresh(panelId: UUID) { - guard terminalPanel(for: panelId) != nil else { return } - - // Force an NSViewRepresentable update after drag/move reparenting. This keeps - // portal host binding current when a pane auto-closes during tab moves. - terminalPanel(for: panelId)?.requestViewReattach() + /// Reconcile focus/first-responder convergence. + /// Coalesce to the next main-queue turn so WorkspaceSplit selection/pane mutations settle first. + private func scheduleFocusReconcile() { +#if DEBUG + if isDetachingCloseTransaction { + debugFocusReconcileScheduledDuringDetachCount += 1 + } +#endif + guard !focusReconcileScheduled else { return } + focusReconcileScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.focusReconcileScheduled = false + self.reconcileFocusState() + } + } - let runRefreshPass: (TimeInterval) -> Void = { [weak self] delay in - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - guard let self, let panel = self.terminalPanel(for: panelId) else { return } - panel.hostedView.reconcileGeometryNow() - if panel.surface.surface != nil { - panel.surface.forceRefresh() - } - if panel.surface.surface == nil { - panel.surface.requestBackgroundSurfaceStartIfNeeded() - } + private func scheduleTerminalGeometryReconcile() { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + for terminalPanel in self.panels.values.compactMap({ $0 as? TerminalPanel }) { + _ = terminalPanel.hostedView.reconcileGeometryNow() } } - - // Run once immediately and once on the next turn so rapid split close/reparent - // sequences still get a post-layout redraw. - runRefreshPass(0) - runRefreshPass(0.03) } private func closeTabs(_ tabIds: [TabID], skipPinned: Bool = true) { for tabId in tabIds { - if skipPinned, - let panelId = panelIdFromSurfaceId(tabId), - pinnedPanelIds.contains(panelId) { + if skipPinned, surfaceStateSnapshot(panelId: tabId.id).isPinned { continue } - _ = bonsplitController.closeTab(tabId) + _ = splitController.closeTab(tabId) } } private func tabIdsToLeft(of anchorTabId: TabID, inPane paneId: PaneID) -> [TabID] { - let tabs = bonsplitController.tabs(inPane: paneId) - guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }) else { return [] } - return Array(tabs.prefix(index).map(\.id)) + let tabIds = splitController.tabIds(inPane: paneId) + guard let index = tabIds.firstIndex(of: anchorTabId) else { return [] } + return Array(tabIds.prefix(index)) } private func tabIdsToRight(of anchorTabId: TabID, inPane paneId: PaneID) -> [TabID] { - let tabs = bonsplitController.tabs(inPane: paneId) - guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }), - index + 1 < tabs.count else { return [] } - return Array(tabs.suffix(from: index + 1).map(\.id)) + let tabIds = splitController.tabIds(inPane: paneId) + guard let index = tabIds.firstIndex(of: anchorTabId), + index + 1 < tabIds.count else { return [] } + return Array(tabIds.suffix(from: index + 1)) } private func tabIdsToCloseOthers(of anchorTabId: TabID, inPane paneId: PaneID) -> [TabID] { - bonsplitController.tabs(inPane: paneId) - .map(\.id) - .filter { $0 != anchorTabId } + splitController.tabIds(inPane: paneId).filter { $0 != anchorTabId } } private func createTerminalToRight(of anchorTabId: TabID, inPane paneId: PaneID) { let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId) - guard let newPanel = newTerminalSurface(inPane: paneId, focus: true) else { return } + guard let newPanel = createTerminalPanel(inPane: paneId, focus: true) else { return } _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex) } private func createBrowserToRight(of anchorTabId: TabID, inPane paneId: PaneID, url: URL? = nil) { let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId) - let preferredProfileID = panelIdFromSurfaceId(anchorTabId).flatMap { browserPanel(for: $0)?.profileID } - guard let newPanel = newBrowserSurface( + let preferredProfileID = browserPanel(for: anchorTabId)?.profileID + guard let newPanel = createBrowserPanel( inPane: paneId, url: url, focus: true, @@ -12237,10 +12941,9 @@ final class Workspace: Identifiable, ObservableObject { } private func duplicateBrowserToRight(anchorTabId: TabID, inPane paneId: PaneID) { - guard let panelId = panelIdFromSurfaceId(anchorTabId), - let browser = browserPanel(for: panelId) else { return } + guard let browser = browserPanel(for: anchorTabId) else { return } let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId) - guard let newPanel = newBrowserSurface( + guard let newPanel = createBrowserPanel( inPane: paneId, url: browser.currentURL, focus: true, @@ -12250,13 +12953,14 @@ final class Workspace: Identifiable, ObservableObject { } private func promptRenamePanel(tabId: TabID) { - guard let panelId = panelIdFromSurfaceId(tabId), - let panel = panels[panelId] else { return } + let panelId = tabId.id + guard let panel = panel(for: tabId) else { return } let alert = NSAlert() alert.messageText = String(localized: "alert.renameTab.title", defaultValue: "Rename Tab") alert.informativeText = String(localized: "alert.renameTab.message", defaultValue: "Enter a custom name for this tab.") - let currentTitle = panelCustomTitles[panelId] ?? panelTitles[panelId] ?? panel.displayTitle + let state = surfaceStateSnapshot(panelId: panelId) + let currentTitle = state.customTitle ?? state.title ?? panel.displayTitle let input = NSTextField(string: currentTitle) input.placeholderString = String(localized: "alert.renameTab.placeholder", defaultValue: "Tab name") input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) @@ -12281,7 +12985,8 @@ final class Workspace: Identifiable, ObservableObject { } private func promptMovePanel(tabId: TabID) { - guard let panelId = panelIdFromSurfaceId(tabId), + let panelId = tabId.id + guard panel(for: tabId) != nil, let app = AppDelegate.shared else { return } let currentWindowId = app.tabManagerFor(tabId: id).flatMap { app.windowId(for: $0) } @@ -12361,94 +13066,7 @@ final class Workspace: Identifiable, ObservableObject { } } - private func handleSessionDrop( - entry: SessionEntry, - destination: BonsplitController.ExternalTabDropRequest.Destination - ) -> Bool { - let inputWithReturn = entry.resumeCommand + "\n" - switch destination { - case .insert(let paneId, _): - let panel = newTerminalSurface( - inPane: paneId, - focus: true, - workingDirectory: entry.cwd, - initialInput: inputWithReturn - ) - return panel != nil - case .split(let paneId, let orientation, let insertFirst): - let panel = splitPaneWithNewTerminal( - targetPane: paneId, - orientation: orientation, - insertFirst: insertFirst, - workingDirectory: entry.cwd, - initialInput: inputWithReturn - ) - return panel != nil - } - } - - /// Split `paneId` and place a brand-new terminal in the resulting pane. - /// Used by the session-index drop path; mirrors `newTerminalSplit(from:...)` but - /// targets a destination pane directly rather than inheriting from a source panel. - @discardableResult - func splitPaneWithNewTerminal( - targetPane paneId: PaneID, - orientation: SplitOrientation, - insertFirst: Bool, - workingDirectory: String?, - initialInput: String? - ) -> TerminalPanel? { - let inheritedConfig = inheritedTerminalConfig(inPane: paneId) - - let newPanel = TerminalPanel( - workspaceId: id, - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: inheritedConfig, - workingDirectory: workingDirectory, - portOrdinal: portOrdinal, - initialInput: initialInput - ) - configureTerminalPanel(newPanel) - panels[newPanel.id] = newPanel - panelTitles[newPanel.id] = newPanel.displayTitle - seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) - - let newTab = Bonsplit.Tab( - title: newPanel.displayTitle, - icon: newPanel.displayIcon, - kind: SurfaceKind.terminal, - isDirty: newPanel.isDirty, - isPinned: false - ) - surfaceIdToPanelId[newTab.id] = newPanel.id - - isProgrammaticSplit = true - defer { isProgrammaticSplit = false } - guard bonsplitController.splitPane( - paneId, - orientation: orientation, - withTab: newTab, - insertFirst: insertFirst - ) != nil else { - panels.removeValue(forKey: newPanel.id) - panelTitles.removeValue(forKey: newPanel.id) - surfaceIdToPanelId.removeValue(forKey: newTab.id) - terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) - return nil - } - - bonsplitController.selectTab(newTab.id) - newPanel.focus() - return newPanel - } - - private func handleExternalTabDrop(_ request: BonsplitController.ExternalTabDropRequest) -> Bool { - // Session-index drag → spawn a brand new terminal at the destination instead - // of moving an existing tab. - if let entry = SessionDragRegistry.shared.consume(id: request.tabId.uuid) { - return handleSessionDrop(entry: entry, destination: request.destination) - } - + func handleExternalTabDrop(_ request: WorkspaceLayoutExternalTabDropRequest) -> Bool { guard let app = AppDelegate.shared else { return false } #if DEBUG let dropStart = ProcessInfo.processInfo.systemUptime @@ -12479,12 +13097,12 @@ final class Workspace: Identifiable, ObservableObject { } #if DEBUG - cmuxDebugLog( + dlog( "split.externalDrop.begin ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " + "sourcePane=\(request.sourcePaneId.id.uuidString.prefix(5)) destination=\(destinationLabel)" ) #endif - let moved = app.moveBonsplitTab( + let moved = app.moveSplitTab( tabId: request.tabId.uuid, toWorkspace: id, targetPane: targetPane, @@ -12494,7 +13112,7 @@ final class Workspace: Identifiable, ObservableObject { focusWindow: true ) #if DEBUG - cmuxDebugLog( + dlog( "split.externalDrop.end ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " + "moved=\(moved ? 1 : 0) elapsedMs=\(debugElapsedMs(since: dropStart))" ) @@ -12504,14 +13122,86 @@ final class Workspace: Identifiable, ObservableObject { } -// MARK: - BonsplitDelegate +// MARK: - WorkspaceLayoutInteractionHandlers + +extension Workspace { + var layoutInteractionHandlers: WorkspaceLayoutInteractionHandlers { + WorkspaceLayoutInteractionHandlers( + notifyGeometryChangeHandler: { [weak self] isDragging in + self?.splitController.notifyGeometryChange(isDragging: isDragging) + }, + setContainerFrameHandler: { [weak self] frame in + self?.splitController.setContainerFrame(frame) + }, + setDividerPositionHandler: { [weak self] position, splitId in + self?.setDividerPosition(Double(position), forSplit: splitId) ?? false + }, + consumeSplitEntryAnimationHandler: { [weak self] splitId in + self?.consumeSplitEntryAnimation(splitId) + }, + beginTabDragHandler: { [weak self] tabId, sourcePaneId in + self?.splitController.beginTabDrag(tabId: tabId, sourcePaneId: sourcePaneId) + }, + clearDragStateHandler: { [weak self] in + self?.splitController.clearDragState() + }, + focusPaneHandler: { [weak self] paneId in + self?.focusPane(paneId) ?? false + }, + selectTabHandler: { [weak self] tabId in + self?.splitController.selectTab(tabId) + }, + requestCloseTabHandler: { [weak self] tabId, paneId in + guard let self else { return false } + self.markExplicitClose(surfaceId: tabId) + return self.splitController.closeTab(tabId, inPane: paneId) + }, + togglePaneZoomHandler: { [weak self] paneId in + guard let self else { return false } + if let panelId = self.selectedSurfaceId(inPane: paneId) { + return self.toggleSplitZoom(panelId: panelId) + } + return self.splitController.togglePaneZoom(inPane: paneId) + }, + requestTabContextActionHandler: { [weak self] action, tabId, paneId in + self?.workspaceSplit(didRequestTabContextAction: action, for: tabId, inPane: paneId) + }, + requestNewTabHandler: { [weak self] kind, paneId in + self?.workspaceSplit(didRequestNewTab: kind, inPane: paneId) + }, + splitPaneHandler: { [weak self] paneId, orientation in + self?.splitController.splitPane(paneId, orientation: orientation) + }, + splitPaneMovingTabHandler: { [weak self] paneId, orientation, tabId, insertFirst, focusNewPane in + self?.splitSurfaceByDrag( + panelId: tabId.id, + inPane: paneId, + orientation: orientation, + insertFirst: insertFirst, + focusNewPane: focusNewPane + ) + }, + moveTabHandler: { [weak self] tabId, paneId, index in + self?.splitController.moveTab(tabId, toPane: paneId, atIndex: index) ?? false + }, + handleExternalTabDropHandler: { [weak self] request in + self?.handleExternalTabDrop(request) ?? false + }, + handleFileDropHandler: { [weak self] urls, paneId in + self?.handlePaneFileDrop(urls: urls, in: paneId) ?? false + } + ) + } +} + +// MARK: - WorkspaceLayoutDelegate -extension Workspace: BonsplitDelegate { +extension Workspace: WorkspaceLayoutDelegate { @MainActor private func shouldCloseWorkspaceOnLastSurface(for tabId: TabID) -> Bool { let manager = owningTabManager ?? AppDelegate.shared?.tabManagerFor(tabId: id) ?? AppDelegate.shared?.tabManager guard panels.count <= 1, - panelIdFromSurfaceId(tabId) != nil, + hasLivePanel(for: tabId), let manager, manager.tabs.contains(where: { $0.id == id }) else { return false @@ -12521,39 +13211,31 @@ extension Workspace: BonsplitDelegate { @MainActor private func confirmClosePanel(for tabId: TabID) async -> Bool { - let title = String(localized: "dialog.closeTab.title", defaultValue: "Close tab?") + let alert = NSAlert() + + alert.messageText = String(localized: "dialog.closeTab.title", defaultValue: "Close tab?") + let panelName: String? = { - guard let panelId = panelIdFromSurfaceId(tabId) else { return nil } - if let custom = panelCustomTitles[panelId], !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let panelId = tabId.id + guard panels[panelId] != nil else { return nil } + let state = surfaceStateSnapshot(panelId: panelId) + if let custom = state.customTitle, !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return custom } - if let title = panelTitles[panelId], !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if let title = state.title, !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return title } - if let dir = panelDirectories[panelId], !dir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if let dir = state.directory, !dir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return (dir as NSString).lastPathComponent } return nil }() - let message: String if let panelName { - message = String(localized: "dialog.closeTab.messageNamed", defaultValue: "This will close \"\(panelName)\".") + alert.informativeText = String(localized: "dialog.closeTab.messageNamed", defaultValue: "This will close \"\(panelName)\".") } else { - message = String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab.") - } - - if let confirmCloseHandler = ( - owningTabManager - ?? AppDelegate.shared?.tabManagerFor(tabId: id) - ?? AppDelegate.shared?.tabManager - )?.confirmCloseHandler { - return confirmCloseHandler(title, message, false) + alert.informativeText = String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab.") } - - let alert = NSAlert() - alert.messageText = title - alert.informativeText = message alert.alertStyle = .warning alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close")) alert.addButton(withTitle: String(localized: "dialog.closeTab.cancel", defaultValue: "Cancel")) @@ -12581,7 +13263,7 @@ extension Workspace: BonsplitDelegate { } /// Apply the side-effects of selecting a tab (unfocus others, focus this panel, update state). - /// bonsplit doesn't always emit didSelectTab for programmatic selection paths (e.g. createTab). + /// WorkspaceSplit doesn't always emit didSelectTab for programmatic selection paths (e.g. createTab). private func applyTabSelection( tabId: TabID, inPane pane: PaneID, @@ -12620,11 +13302,14 @@ extension Workspace: BonsplitDelegate { /// Hide browser portals for tabs that are no longer selected in the given pane. private func hideBrowserPortalsForDeselectedTabs(inPane pane: PaneID, selectedTabId: TabID) { - for tab in bonsplitController.tabs(inPane: pane) { - guard tab.id != selectedTabId else { continue } - guard let panelId = panelIdFromSurfaceId(tab.id), - let browserPanel = panels[panelId] as? BrowserPanel else { continue } - browserPanel.hideBrowserPortalView(source: "tabDeselected") + for surfaceId in surfaceIds(inPane: pane) { + guard surfaceId != selectedTabId.id else { continue } + guard let browserPanel = browserPanel(for: surfaceId) else { continue } + browserPanel.setBrowserPortalVisibility( + visibleInUI: false, + zPriority: 0, + source: "tabDeselected" + ) } } @@ -12637,45 +13322,46 @@ extension Workspace: BonsplitDelegate { ) { let previousFocusedPanelId = focusedPanelId #if DEBUG - let focusedPaneBefore = bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil" - let selectedTabBefore = bonsplitController.focusedPaneId - .flatMap { bonsplitController.selectedTab(inPane: $0)?.id } + let focusedPaneBefore = splitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil" + let selectedTabBefore = splitController.focusedPaneId + .flatMap { splitController.selectedTabId(inPane: $0) } .map { String($0.uuid.uuidString.prefix(5)) } ?? "nil" - cmuxDebugLog( + dlog( "focus.split.apply.begin workspace=\(id.uuidString.prefix(5)) " + "pane=\(pane.id.uuidString.prefix(5)) tab=\(tabId.uuid.uuidString.prefix(5)) " + "focusedPane=\(focusedPaneBefore) selectedTab=\(selectedTabBefore) " + "reassert=\(reassertAppKitFocus ? 1 : 0)" ) #endif - if bonsplitController.allPaneIds.contains(pane) { - if bonsplitController.focusedPaneId != pane { - bonsplitController.focusPane(pane) + if splitController.allPaneIds.contains(pane) { + if splitController.focusedPaneId != pane { + splitController.focusPane(pane) } - if bonsplitController.tabs(inPane: pane).contains(where: { $0.id == tabId }), - bonsplitController.selectedTab(inPane: pane)?.id != tabId { - bonsplitController.selectTab(tabId) + if splitController.tabIds(inPane: pane).contains(tabId), + splitController.selectedTabId(inPane: pane) != tabId { + splitController.selectTab(tabId) } } let focusedPane: PaneID let selectedTabId: TabID - if let currentPane = bonsplitController.focusedPaneId, - let currentTabId = bonsplitController.selectedTab(inPane: currentPane)?.id { + if let currentPane = splitController.focusedPaneId, + let currentTabId = splitController.selectedTabId(inPane: currentPane) { focusedPane = currentPane selectedTabId = currentTabId - } else if bonsplitController.tabs(inPane: pane).contains(where: { $0.id == tabId }) { + } else if splitController.tabIds(inPane: pane).contains(tabId) { focusedPane = pane selectedTabId = tabId - bonsplitController.focusPane(focusedPane) - bonsplitController.selectTab(selectedTabId) + splitController.focusPane(focusedPane) + splitController.selectTab(selectedTabId) } else { return } // Focus the selected panel, but keep the previously focused terminal active while a // newly created split terminal is still unattached. - guard let selectedPanelId = panelIdFromSurfaceId(selectedTabId) else { + let selectedPanelId = selectedTabId.id + guard panels[selectedPanelId] != nil else { return } let effectiveFocusedPanelId = effectiveSelectedPanelId(inPane: focusedPane) ?? selectedPanelId @@ -12685,22 +13371,21 @@ extension Workspace: BonsplitDelegate { if debugStressPreloadSelectionDepth > 0 { if let terminalPanel = panel as? TerminalPanel { - terminalPanel.requestViewReattach() - scheduleTerminalGeometryReconcile() + _ = terminalPanel.hostedView.reconcileGeometryNow() terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() } return } - if shouldTreatCurrentEventAsExplicitFocusIntent() { - markExplicitFocusIntent(on: effectiveFocusedPanelId) - } let activationIntent = focusIntent ?? panel.preferredFocusIntentForActivation() panel.prepareFocusIntentForActivation(activationIntent) let panelId = effectiveFocusedPanelId + let focusTransaction = focusTransactions.begin( + target: focusTarget(panelId: panelId, panel: panel, intent: activationIntent), + reason: "applyTabSelection" + ) syncPinnedStateForTab(selectedTabId, panelId: selectedPanelId) - syncUnreadBadgeStateForPanel(selectedPanelId) // Unfocus all other panels for (id, p) in panels where id != effectiveFocusedPanelId { @@ -12708,7 +13393,7 @@ extension Workspace: BonsplitDelegate { } // Explicitly hide browser portals for deselected tabs in this pane. - // Bonsplit's keepAllAlive mode hides non-selected tabs via SwiftUI .opacity(0), + // WorkspaceSplit's keepAllAlive mode hides non-selected tabs via SwiftUI .opacity(0), // but portal-hosted WKWebViews render at the window level in AppKit and are not // affected by SwiftUI opacity. Without an explicit hide, the deselected browser's // portal layer can remain visible above the newly selected tab. @@ -12738,8 +13423,9 @@ extension Workspace: BonsplitDelegate { if let terminalPanel = panel as? TerminalPanel { rememberTerminalConfigInheritanceSource(terminalPanel) } - let isManuallyUnread = manualUnreadPanelIds.contains(panelId) - let markedAt = manualUnreadMarkedAt[panelId] + let surfaceState = surfaceStateSnapshot(panelId: panelId) + let isManuallyUnread = surfaceState.isManuallyUnread + let markedAt = surfaceState.manualUnreadMarkedAt if Self.shouldClearManualUnread( previousFocusedPanelId: previousFocusedPanelId, nextFocusedPanelId: panelId, @@ -12757,14 +13443,14 @@ extension Workspace: BonsplitDelegate { } } - // Converge AppKit first responder with bonsplit's selected tab in the focused pane. + // Converge AppKit first responder with WorkspaceSplit's selected tab in the focused pane. // Without this, keyboard input can remain on a different terminal than the blue tab indicator. if reassertAppKitFocus, let terminalPanel = panel as? TerminalPanel { if shouldMoveTerminalSurfaceFocus(for: activationIntent), !terminalPanel.hostedView.isSurfaceViewFirstResponder() { #if DEBUG let previousExists = previousTerminalHostedView != nil ? 1 : 0 - cmuxDebugLog( + dlog( "focus.split.moveFocus workspace=\(id.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) previousExists=\(previousExists) " + "to=\(panelId.uuidString.prefix(5))" @@ -12773,7 +13459,7 @@ extension Workspace: BonsplitDelegate { terminalPanel.hostedView.moveFocus(from: previousTerminalHostedView) } #if DEBUG - cmuxDebugLog( + dlog( "focus.split.ensureFocus workspace=\(id.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) pane=\(focusedPane.id.uuidString.prefix(5)) " + "tab=\(selectedTabId.uuid.uuidString.prefix(5)) intent=\(String(describing: activationIntent))" @@ -12785,15 +13471,25 @@ extension Workspace: BonsplitDelegate { if shouldRestoreFocusIntentAfterActivation(activationIntent) { _ = panel.restoreFocusIntent(activationIntent) } + if let focusWindow = activationWindow(for: panel) { + yieldForeignOwnedFocusIfNeeded( + in: focusWindow, + targetPanelId: panelId, + targetIntent: activationIntent + ) + } - surfaceTabBarDirectory = configTrackingDirectory(for: panelId) + focusTransactions.end( + focusTransaction, + actual: focusActual(for: panel, panelId: panelId, in: activationWindow(for: panel)) + ) // Update current directory if this is a terminal - if let dir = panelDirectories[panelId] { + if let dir = surfaceState.directory { currentDirectory = dir } - gitBranch = panelGitBranches[panelId] - pullRequest = panelPullRequests[panelId] + gitBranch = surfaceState.gitBranch + pullRequest = surfaceState.pullRequest // Post notification NotificationCenter.default.post( @@ -12806,7 +13502,7 @@ extension Workspace: BonsplitDelegate { ) #if DEBUG let prevPanelShort = previousFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - cmuxDebugLog( + dlog( "focus.split.apply.end workspace=\(id.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) type=\(String(describing: type(of: panel))) " + "focusedPane=\(focusedPane.id.uuidString.prefix(5)) selectedTab=\(selectedTabId.uuid.uuidString.prefix(5)) " + @@ -12815,6 +13511,65 @@ extension Workspace: BonsplitDelegate { #endif } + private func focusTarget( + panelId: UUID, + panel: any Panel, + intent: PanelFocusIntent + ) -> WorkspaceFocusTarget { + switch intent { + case .panel: + return .panel(panelId) + case .terminal(.surface): + return .terminalSurface(panelId) + case .terminal(.findField): + return .terminalFindField(panelId) + case .browser(.webView): + return .browserWebContent(panelId) + case .browser(.addressBar): + return .browserAddressBar( + panelId, + requestId: (panel as? BrowserPanel)?.pendingAddressBarFocusRequestId + ) + case .browser(.findField): + return .browserFindField( + panelId, + requestId: (panel as? BrowserPanel)?.pendingFindFieldFocusRequestId + ) + } + } + + private func focusActual( + for panel: any Panel, + panelId: UUID, + in window: NSWindow? + ) -> WorkspaceFocusActual { + if let browserPanel = panel as? BrowserPanel { + return browserPanel.actualFocus(in: window) + } + + guard let window, let responder = window.firstResponder else { + return .none + } + guard let ownedIntent = panel.ownedFocusIntent(for: responder, in: window) else { + return .none + } + + switch ownedIntent { + case .panel: + return .panel(panelId) + case .terminal(.surface): + return .terminalSurface(panelId) + case .terminal(.findField): + return .terminalFindField(panelId) + case .browser(.webView): + return .browserWebContent(panelId) + case .browser(.addressBar): + return .browserAddressBar(panelId) + case .browser(.findField): + return .browserFindField(panelId) + } + } + private func activatePanel( _ panel: any Panel, focusIntent: PanelFocusIntent, @@ -12861,7 +13616,7 @@ extension Workspace: BonsplitDelegate { for (panelId, panel) in panels where panelId != targetPanelId { guard let ownedIntent = panel.ownedFocusIntent(for: firstResponder, in: window) else { continue } #if DEBUG - cmuxDebugLog( + dlog( "focus.handoff.begin workspace=\(id.uuidString.prefix(5)) " + "fromPanel=\(panelId.uuidString.prefix(5)) toPanel=\(targetPanelId.uuidString.prefix(5)) " + "fromIntent=\(String(describing: ownedIntent)) toIntent=\(String(describing: targetIntent))" @@ -12908,37 +13663,6 @@ extension Workspace: BonsplitDelegate { } } - private func beginNonFocusSplitFocusReassert( - preferredPanelId: UUID, - splitPanelId: UUID - ) -> UInt64 { - nonFocusSplitFocusReassertGeneration &+= 1 - let generation = nonFocusSplitFocusReassertGeneration - pendingNonFocusSplitFocusReassert = PendingNonFocusSplitFocusReassert( - generation: generation, - preferredPanelId: preferredPanelId, - splitPanelId: splitPanelId - ) - return generation - } - - private func matchesPendingNonFocusSplitFocusReassert( - generation: UInt64, - preferredPanelId: UUID, - splitPanelId: UUID - ) -> Bool { - guard let pending = pendingNonFocusSplitFocusReassert else { return false } - return pending.generation == generation && - pending.preferredPanelId == preferredPanelId && - pending.splitPanelId == splitPanelId - } - - private func clearNonFocusSplitFocusReassert(generation: UInt64? = nil) { - guard let pending = pendingNonFocusSplitFocusReassert else { return } - if let generation, pending.generation != generation { return } - pendingNonFocusSplitFocusReassert = nil - } - private func shouldTreatCurrentEventAsExplicitFocusIntent() -> Bool { guard let eventType = NSApp.currentEvent?.type else { return false } switch eventType { @@ -12951,162 +13675,120 @@ extension Workspace: BonsplitDelegate { } } - private func markExplicitFocusIntent(on panelId: UUID) { - guard let pending = pendingNonFocusSplitFocusReassert, - pending.splitPanelId == panelId else { - return - } - pendingNonFocusSplitFocusReassert = nil - } - - func splitTabBar(_ controller: BonsplitController, shouldCloseTab tab: Bonsplit.Tab, inPane pane: PaneID) -> Bool { + func workspaceSplit(shouldCloseTab tabId: TabID, inPane pane: PaneID) -> Bool { func recordPostCloseSelection() { - let tabs = controller.tabs(inPane: pane) - guard let idx = tabs.firstIndex(where: { $0.id == tab.id }) else { - postCloseSelectTabId.removeValue(forKey: tab.id) + let tabs = splitController.tabIds(inPane: pane) + guard let idx = tabs.firstIndex(of: tabId) else { + postCloseSelectTabId.removeValue(forKey: tabId) return } let target: TabID? = { - if idx + 1 < tabs.count { return tabs[idx + 1].id } - if idx > 0 { return tabs[idx - 1].id } + if idx + 1 < tabs.count { return tabs[idx + 1] } + if idx > 0 { return tabs[idx - 1] } return nil }() if let target { - postCloseSelectTabId[tab.id] = target + postCloseSelectTabId[tabId] = target } else { - postCloseSelectTabId.removeValue(forKey: tab.id) + postCloseSelectTabId.removeValue(forKey: tabId) } + } - let explicitUserClose = explicitUserCloseTabIds.remove(tab.id) != nil + let explicitUserClose = explicitUserCloseTabIds.remove(tabId) != nil - if forceCloseTabIds.contains(tab.id) { - stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane) + if forceCloseTabIds.contains(tabId) { + stageClosedBrowserRestoreSnapshotIfNeeded(for: tabId, inPane: pane) recordPostCloseSelection() return true } - let closeConfirmationManager = owningTabManager - ?? AppDelegate.shared?.tabManagerFor(tabId: id) - ?? AppDelegate.shared?.tabManager - if let closeConfirmationManager, closeConfirmationManager.isCloseConfirmationInFlight { - clearStagedClosedBrowserRestoreSnapshot(for: tab.id) - return false - } - - if let panelId = panelIdFromSurfaceId(tab.id), - pinnedPanelIds.contains(panelId) { - clearStagedClosedBrowserRestoreSnapshot(for: tab.id) + if surfaceStateSnapshot(panelId: tabId.id).isPinned { + clearStagedClosedBrowserRestoreSnapshot(for: tabId) NSSound.beep() return false } - if explicitUserClose && shouldCloseWorkspaceOnLastSurface(for: tab.id) { - clearStagedClosedBrowserRestoreSnapshot(for: tab.id) + if explicitUserClose && shouldCloseWorkspaceOnLastSurface(for: tabId) { + clearStagedClosedBrowserRestoreSnapshot(for: tabId) owningTabManager?.closeWorkspaceWithConfirmation(self) return false } // Check if the panel needs close confirmation - guard let panelId = panelIdFromSurfaceId(tab.id), - let terminalPanel = terminalPanel(for: panelId) else { - stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane) + let panelId = tabId.id + guard let terminalPanel = terminalPanel(for: tabId) else { + stageClosedBrowserRestoreSnapshotIfNeeded(for: tabId, inPane: pane) recordPostCloseSelection() return true } - // If confirmation is required, Bonsplit will call into this delegate and we must return false. + // If confirmation is required, WorkspaceSplit will call into this delegate and we must return false. // Show an app-level confirmation, then re-attempt the close with forceCloseTabIds to bypass // this gating on the second pass. if panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) { - clearStagedClosedBrowserRestoreSnapshot(for: tab.id) - if pendingCloseConfirmTabIds.contains(tab.id) { - return false - } - - let confirmationManager = owningTabManager ?? AppDelegate.shared?.tabManagerFor(tabId: id) ?? AppDelegate.shared?.tabManager - if let confirmationManager, !confirmationManager.beginCloseConfirmationSession() { + clearStagedClosedBrowserRestoreSnapshot(for: tabId) + if pendingCloseConfirmTabIds.contains(tabId) { return false } - pendingCloseConfirmTabIds.insert(tab.id) - let tabId = tab.id + pendingCloseConfirmTabIds.insert(tabId) DispatchQueue.main.async { [weak self] in - guard let self else { - confirmationManager?.endCloseConfirmationSession() - return - } + guard let self else { return } Task { @MainActor in - defer { - self.pendingCloseConfirmTabIds.remove(tabId) - confirmationManager?.endCloseConfirmationSession() - } + defer { self.pendingCloseConfirmTabIds.remove(tabId) } // If the tab disappeared while we were scheduling, do nothing. - guard self.panelIdFromSurfaceId(tabId) != nil else { return } + guard self.hasLivePanel(for: tabId) else { return } let confirmed = await self.confirmClosePanel(for: tabId) guard confirmed else { return } self.forceCloseTabIds.insert(tabId) - self.bonsplitController.closeTab(tabId) + self.splitController.closeTab(tabId) } } return false } - clearStagedClosedBrowserRestoreSnapshot(for: tab.id) + clearStagedClosedBrowserRestoreSnapshot(for: tabId) recordPostCloseSelection() return true } - func splitTabBar(_ controller: BonsplitController, didCloseTab tabId: TabID, fromPane pane: PaneID) { + func workspaceSplit(didCloseTab tabId: TabID, fromPane pane: PaneID) { forceCloseTabIds.remove(tabId) let selectTabId = postCloseSelectTabId.removeValue(forKey: tabId) let closedBrowserRestoreSnapshot = pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tabId) let isDetaching = detachingTabIds.remove(tabId) != nil || isDetachingCloseTransaction - // Clean up our panel - guard let panelId = panelIdFromSurfaceId(tabId) else { - #if DEBUG - NSLog("[Workspace] didCloseTab: no panelId for tabId") - #endif - scheduleTerminalGeometryReconcile() + let panelId = tabId.id + guard let panel = panels[panelId] else { if !isDetaching { scheduleFocusReconcile() } return } - #if DEBUG - NSLog("[Workspace] didCloseTab panelId=\(panelId) remainingPanels=\(panels.count - 1) remainingPanes=\(controller.allPaneIds.count)") - #endif - - let panel = panels[panelId] - removePendingTerminalInputObservers(forPanelId: panelId) let transferredRemoteCleanupConfiguration = transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: panelId) - if isDetaching, let panel { - let browserPanel = panel as? BrowserPanel - let cachedTitle = panelTitles[panelId] + if isDetaching { + let state = surfaceStateSnapshot(panelId: panelId) + let cachedTitle = state.title let transferFallbackTitle = cachedTitle ?? panel.displayTitle pendingDetachedSurfaces[tabId] = DetachedSurfaceTransfer( panelId: panelId, panel: panel, title: resolvedPanelTitle(panelId: panelId, fallback: transferFallbackTitle), - icon: panel.displayIcon, - iconImageData: browserPanel?.faviconPNGData, - kind: surfaceKind(for: panel), - isLoading: browserPanel?.isLoading ?? false, - isPinned: pinnedPanelIds.contains(panelId), - directory: panelDirectories[panelId], - ttyName: surfaceTTYNames[panelId], + isPinned: state.isPinned, + directory: state.directory, + ttyName: state.ttyName, cachedTitle: cachedTitle, - customTitle: panelCustomTitles[panelId], - manuallyUnread: manualUnreadPanelIds.contains(panelId), + customTitle: state.customTitle, + manuallyUnread: state.isManuallyUnread, isRemoteTerminal: activeRemoteTerminalSurfaceIds.contains(panelId), remoteRelayPort: activeRemoteTerminalSurfaceIds.contains(panelId) ? remoteConfiguration?.relayPort @@ -13117,29 +13799,18 @@ extension Workspace: BonsplitDelegate { if let closedBrowserRestoreSnapshot { onClosedBrowserPanel?(closedBrowserRestoreSnapshot) } - panel?.close() + panel.close() } panels.removeValue(forKey: panelId) + surfaceRegistry.removeSurface(surfaceId: panelId) untrackRemoteTerminalSurface(panelId) pendingRemoteTerminalChildExitSurfaceIds.remove(panelId) - surfaceIdToPanelId.removeValue(forKey: tabId) - panelDirectories.removeValue(forKey: panelId) - panelGitBranches.removeValue(forKey: panelId) - panelPullRequests.removeValue(forKey: panelId) - panelTitles.removeValue(forKey: panelId) - panelCustomTitles.removeValue(forKey: panelId) - pinnedPanelIds.remove(panelId) - manualUnreadPanelIds.remove(panelId) - manualUnreadMarkedAt.removeValue(forKey: panelId) + removeSurfaceState(panelId: panelId) panelSubscriptions.removeValue(forKey: panelId) panelShellActivityStates.removeValue(forKey: panelId) - surfaceTTYNames.removeValue(forKey: panelId) syncRemotePortScanTTYs() restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) - restoredAgentSnapshotsByPanelId.removeValue(forKey: panelId) - restoredAgentAutoResumePendingPanelIds.remove(panelId) - invalidatedRestoredAgentFingerprintsByPanelId.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) terminalInheritanceFontPointsByPanelId.removeValue(forKey: panelId) if lastTerminalConfigInheritancePanelId == panelId { @@ -13167,13 +13838,13 @@ extension Workspace: BonsplitDelegate { dlog("replacement.banner.fire target=\(pendingReplacementBannerRemoteTarget ?? "nil")") #endif let replacement = createReplacementTerminalPanel() - if let replacementTabId = surfaceIdFromPanelId(replacement.id), - let replacementPane = bonsplitController.allPaneIds.first { - bonsplitController.focusPane(replacementPane) - bonsplitController.selectTab(replacementTabId) + if let replacement, + let replacementTabId = surfaceIdFromPanelId(replacement.id), + let replacementPane = splitController.allPaneIds.first { + splitController.focusPane(replacementPane) + splitController.selectTab(replacementTabId) applyTabSelection(tabId: replacementTabId, inPane: replacementPane) } - scheduleTerminalGeometryReconcile() scheduleFocusReconcile() return } @@ -13185,34 +13856,32 @@ extension Workspace: BonsplitDelegate { pendingReplacementBannerRemoteTarget = nil if let selectTabId, - bonsplitController.allPaneIds.contains(pane), - bonsplitController.tabs(inPane: pane).contains(where: { $0.id == selectTabId }), - bonsplitController.focusedPaneId == pane { + splitController.allPaneIds.contains(pane), + splitController.tabIds(inPane: pane).contains(selectTabId), + splitController.focusedPaneId == pane { // Keep selection/focus convergence in the same close transaction to avoid a transient // frame where the pane has no selected content. - bonsplitController.selectTab(selectTabId) + splitController.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 + } else if let focusedPane = splitController.focusedPaneId, + let focusedTabId = splitController.selectedTabId(inPane: focusedPane) { + // When closing the last tab in a pane, WorkspaceSplit 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) { + if splitController.allPaneIds.contains(pane) { normalizePinnedTabs(in: pane) } - scheduleTerminalGeometryReconcile() if !isDetaching { scheduleFocusReconcile() } } - func splitTabBar(_ controller: BonsplitController, didSelectTab tab: Bonsplit.Tab, inPane pane: PaneID) { - applyTabSelection(tabId: tab.id, inPane: pane) + func workspaceSplit(didSelectTab tabId: TabID, inPane pane: PaneID) { + applyTabSelection(tabId: tabId, inPane: pane) } - func splitTabBar(_ controller: BonsplitController, didMoveTab tab: Bonsplit.Tab, fromPane source: PaneID, toPane destination: PaneID) { + func workspaceSplit(didMoveTab tabId: TabID, fromPane source: PaneID, toPane destination: PaneID) { #if DEBUG let now = ProcessInfo.processInfo.systemUptime let sincePrev: String @@ -13223,36 +13892,33 @@ extension Workspace: BonsplitDelegate { } debugLastDidMoveTabTimestamp = now debugDidMoveTabEventCount += 1 - let movedPanelId = panelIdFromSurfaceId(tab.id) + let movedPanelId = panel(for: tabId)?.id let movedPanel = movedPanelId?.uuidString.prefix(5) ?? "unknown" - let selectedBefore = controller.selectedTab(inPane: destination) - .map { String(String(describing: $0.id).prefix(5)) } ?? "nil" - let focusedPaneBefore = controller.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" + let selectedBefore = splitController.selectedTabId(inPane: destination) + .map { String(String(describing: $0).prefix(5)) } ?? "nil" + let focusedPaneBefore = splitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" let focusedPanelBefore = focusedPanelId?.uuidString.prefix(5) ?? "nil" - cmuxDebugLog( + dlog( "split.moveTab idx=\(debugDidMoveTabEventCount) dtSincePrevMs=\(sincePrev) panel=\(movedPanel) " + "from=\(source.id.uuidString.prefix(5)) to=\(destination.id.uuidString.prefix(5)) " + - "sourceTabs=\(controller.tabs(inPane: source).count) destTabs=\(controller.tabs(inPane: destination).count)" + "sourceTabs=\(splitController.tabIds(inPane: source).count) destTabs=\(splitController.tabIds(inPane: destination).count)" ) - cmuxDebugLog( + dlog( "split.moveTab.state.before idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " + "destSelected=\(selectedBefore) focusedPane=\(focusedPaneBefore) focusedPanel=\(focusedPanelBefore)" ) #endif - applyTabSelection(tabId: tab.id, inPane: destination) + applyTabSelection(tabId: tabId, inPane: destination) #if DEBUG - let movedPanelIdAfter = panelIdFromSurfaceId(tab.id) + let movedPanelIdAfter = panel(for: tabId)?.id #endif - if let movedPanelId = panelIdFromSurfaceId(tab.id) { - scheduleMovedTerminalRefresh(panelId: movedPanelId) - } #if DEBUG - let selectedAfter = controller.selectedTab(inPane: destination) - .map { String(String(describing: $0.id).prefix(5)) } ?? "nil" - let focusedPaneAfter = controller.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" + let selectedAfter = splitController.selectedTabId(inPane: destination) + .map { String(String(describing: $0).prefix(5)) } ?? "nil" + let focusedPaneAfter = splitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" let focusedPanelAfter = focusedPanelId?.uuidString.prefix(5) ?? "nil" let movedPanelFocused = (movedPanelIdAfter != nil && movedPanelIdAfter == focusedPanelId) ? 1 : 0 - cmuxDebugLog( + dlog( "split.moveTab.state.after idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " + "destSelected=\(selectedAfter) focusedPane=\(focusedPaneAfter) focusedPanel=\(focusedPanelAfter) " + "movedFocused=\(movedPanelFocused)" @@ -13260,124 +13926,107 @@ extension Workspace: BonsplitDelegate { #endif normalizePinnedTabs(in: source) normalizePinnedTabs(in: destination) - scheduleTerminalGeometryReconcile() if !isDetachingCloseTransaction { scheduleFocusReconcile() } } - func splitTabBar(_ controller: BonsplitController, didFocusPane pane: PaneID) { + func workspaceSplit(didFocusPane pane: PaneID) { // When a pane is focused, focus its selected tab's panel - guard let tab = controller.selectedTab(inPane: pane) else { return } + guard let tabId = splitController.selectedTabId(inPane: pane) else { return } #if DEBUG FocusLogStore.shared.append( - "Workspace.didFocusPane paneId=\(pane.id.uuidString) tabId=\(tab.id) focusedPane=\(controller.focusedPaneId?.id.uuidString ?? "nil")" + "Workspace.didFocusPane paneId=\(pane.id.uuidString) tabId=\(tabId) focusedPane=\(splitController.focusedPaneId?.id.uuidString ?? "nil")" ) #endif - applyTabSelection(tabId: tab.id, inPane: pane) + applyTabSelection(tabId: tabId, inPane: pane) // Apply window background for terminal - if let panelId = panelIdFromSurfaceId(tab.id), - let terminalPanel = panels[panelId] as? TerminalPanel { + if let terminalPanel = terminalPanel(for: tabId) { terminalPanel.applyWindowBackgroundIfActive() } } - func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) { + func workspaceSplit(didClosePane paneId: PaneID) { let closedPanelIds = pendingPaneClosePanelIds.removeValue(forKey: paneId.id) ?? [] let shouldScheduleFocusReconcile = !isDetachingCloseTransaction if !closedPanelIds.isEmpty { for panelId in closedPanelIds { - removePendingTerminalInputObservers(forPanelId: panelId) panels[panelId]?.close() panels.removeValue(forKey: panelId) + surfaceRegistry.removeSurface(surfaceId: panelId) untrackRemoteTerminalSurface(panelId) pendingRemoteTerminalChildExitSurfaceIds.remove(panelId) - panelDirectories.removeValue(forKey: panelId) - panelGitBranches.removeValue(forKey: panelId) - panelPullRequests.removeValue(forKey: panelId) - panelTitles.removeValue(forKey: panelId) - panelCustomTitles.removeValue(forKey: panelId) - pinnedPanelIds.remove(panelId) - manualUnreadPanelIds.remove(panelId) + removeSurfaceState(panelId: panelId) panelSubscriptions.removeValue(forKey: panelId) panelShellActivityStates.removeValue(forKey: panelId) - surfaceTTYNames.removeValue(forKey: panelId) - surfaceListeningPorts.removeValue(forKey: panelId) restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) - restoredAgentSnapshotsByPanelId.removeValue(forKey: panelId) - restoredAgentAutoResumePendingPanelIds.remove(panelId) - invalidatedRestoredAgentFingerprintsByPanelId.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) } syncRemotePortScanTTYs() - let closedSet = Set(closedPanelIds) - surfaceIdToPanelId = surfaceIdToPanelId.filter { !closedSet.contains($0.value) } recomputeListeningPorts() clearRemoteConfigurationIfWorkspaceBecameLocal() - if let focusedPane = bonsplitController.focusedPaneId, - let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id { + if let focusedPane = splitController.focusedPaneId, + let focusedTabId = splitController.selectedTabId(inPane: focusedPane) { applyTabSelection(tabId: focusedTabId, inPane: focusedPane) } else if shouldScheduleFocusReconcile { scheduleFocusReconcile() } } - scheduleTerminalGeometryReconcile() if shouldScheduleFocusReconcile { scheduleFocusReconcile() } } - func splitTabBar(_ controller: BonsplitController, shouldClosePane pane: PaneID) -> Bool { + func workspaceSplit(shouldClosePane pane: PaneID) -> Bool { // Check if any panel in this pane needs close confirmation - let tabs = controller.tabs(inPane: pane) - for tab in tabs { - if forceCloseTabIds.contains(tab.id) { continue } - if let panelId = panelIdFromSurfaceId(tab.id), - let terminalPanel = terminalPanel(for: panelId), + let tabIds = splitController.tabIds(inPane: pane) + for tabId in tabIds { + if forceCloseTabIds.contains(tabId) { continue } + let panelId = tabId.id + if let terminalPanel = terminalPanel(for: tabId), panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) { pendingPaneClosePanelIds.removeValue(forKey: pane.id) return false } } - pendingPaneClosePanelIds[pane.id] = tabs.compactMap { panelIdFromSurfaceId($0.id) } + pendingPaneClosePanelIds[pane.id] = tabIds.compactMap { panel(for: $0)?.id } return true } - func splitTabBar(_ controller: BonsplitController, didSplitPane originalPane: PaneID, newPane: PaneID, orientation: SplitOrientation) { + func workspaceSplit(didSplitPane originalPane: PaneID, newPane: PaneID, orientation: SplitOrientation) { #if DEBUG let panelKindForTab: (TabID) -> String = { tabId in - guard let panelId = self.panelIdFromSurfaceId(tabId), + guard let panelId = self.panel(for: tabId)?.id, let panel = self.panels[panelId] else { return "placeholder" } if panel is TerminalPanel { return "terminal" } if panel is BrowserPanel { return "browser" } return String(describing: type(of: panel)) } let paneKindSummary: (PaneID) -> String = { paneId in - let tabs = controller.tabs(inPane: paneId) - guard !tabs.isEmpty else { return "-" } - return tabs.map { tab in - String(panelKindForTab(tab.id).prefix(1)) + let tabIds = self.splitController.tabIds(inPane: paneId) + guard !tabIds.isEmpty else { return "-" } + return tabIds.map { tabId in + String(panelKindForTab(tabId).prefix(1)) }.joined(separator: ",") } - let originalSelectedKind = controller.selectedTab(inPane: originalPane).map { panelKindForTab($0.id) } ?? "none" - let newSelectedKind = controller.selectedTab(inPane: newPane).map { panelKindForTab($0.id) } ?? "none" - cmuxDebugLog( + let originalSelectedKind = splitController.selectedTabId(inPane: originalPane).map { panelKindForTab($0) } ?? "none" + let newSelectedKind = splitController.selectedTabId(inPane: newPane).map { panelKindForTab($0) } ?? "none" + dlog( "split.didSplit original=\(originalPane.id.uuidString.prefix(5)) new=\(newPane.id.uuidString.prefix(5)) " + "orientation=\(orientation) programmatic=\(isProgrammaticSplit ? 1 : 0) " + - "originalTabs=\(controller.tabs(inPane: originalPane).count) newTabs=\(controller.tabs(inPane: newPane).count) " + + "originalTabs=\(splitController.tabIds(inPane: originalPane).count) newTabs=\(splitController.tabIds(inPane: newPane).count) " + "originalSelected=\(originalSelectedKind) newSelected=\(newSelectedKind) " + "originalKinds=[\(paneKindSummary(originalPane))] newKinds=[\(paneKindSummary(newPane))]" ) #endif let rearmBrowserPortalHostReplacement: (PaneID, String) -> Void = { paneId, reason in - for tab in controller.tabs(inPane: paneId) { - guard let panelId = self.panelIdFromSurfaceId(tab.id), - let browserPanel = self.browserPanel(for: panelId) else { + for tabId in self.splitController.tabIds(inPane: paneId) { + guard let browserPanel = self.browserPanel(for: tabId) else { continue } browserPanel.preparePortalHostReplacementForNextDistinctClaim( @@ -13389,105 +14038,39 @@ extension Workspace: BonsplitDelegate { rearmBrowserPortalHostReplacement(originalPane, "workspace.didSplit.original") rearmBrowserPortalHostReplacement(newPane, "workspace.didSplit.new") - // Only auto-create a terminal if the split came from bonsplit UI. - // Programmatic splits via newTerminalSplit() set isProgrammaticSplit and handle their own panels. + // Only auto-create a terminal if the split came from WorkspaceSplit UI. + // Programmatic splits via the workspace split command path set isProgrammaticSplit and handle their own panels. guard !isProgrammaticSplit else { normalizePinnedTabs(in: originalPane) normalizePinnedTabs(in: newPane) - scheduleTerminalGeometryReconcile() return } - // If the new pane already has a tab, this split moved an existing tab (drag-to-split). - // - // In the "drag the only tab to split edge" case, bonsplit inserts a placeholder "Empty" - // tab in the source pane to avoid leaving it tabless. In cmux, this is undesirable: - // it creates a pane with no real surfaces and leaves an "Empty" tab in the tab bar. - // - // Replace placeholder-only source panes with a real terminal surface, then drop the - // placeholder tabs so the UI stays consistent and pane lists don't contain empties. - if !controller.tabs(inPane: newPane).isEmpty { - let originalTabs = controller.tabs(inPane: originalPane) - let hasRealSurface = originalTabs.contains { panelIdFromSurfaceId($0.id) != nil } + // If the new pane already has a tab, this split moved an existing surface. + // The source pane is allowed to become genuinely empty now, and the placeholder + // UI comes from the render snapshot instead of fake "Empty" tab state. + if !splitController.tabIds(inPane: newPane).isEmpty { #if DEBUG - cmuxDebugLog( + dlog( "split.didSplit.drag original=\(originalPane.id.uuidString.prefix(5)) " + - "new=\(newPane.id.uuidString.prefix(5)) originalTabs=\(originalTabs.count) " + - "newTabs=\(controller.tabs(inPane: newPane).count) hasRealSurface=\(hasRealSurface ? 1 : 0) " + + "new=\(newPane.id.uuidString.prefix(5)) originalTabs=\(splitController.tabIds(inPane: originalPane).count) " + + "newTabs=\(splitController.tabIds(inPane: newPane).count) " + "originalKinds=[\(paneKindSummary(originalPane))] newKinds=[\(paneKindSummary(newPane))]" ) #endif - if !hasRealSurface { - let placeholderTabs = originalTabs.filter { panelIdFromSurfaceId($0.id) == nil } -#if DEBUG - cmuxDebugLog( - "split.placeholderRepair pane=\(originalPane.id.uuidString.prefix(5)) " + - "action=reusePlaceholder placeholderCount=\(placeholderTabs.count)" - ) -#endif - if let replacementTab = placeholderTabs.first { - // Keep the existing placeholder tab identity and replace only the panel mapping. - // This avoids an extra create+close tab churn that can transiently render an - // empty pane during drag-to-split of a single-tab pane. - let inheritedConfig = inheritedTerminalConfig(inPane: originalPane) - - let replacementPanel = TerminalPanel( - workspaceId: id, - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: inheritedConfig, - portOrdinal: portOrdinal - ) - configureTerminalPanel(replacementPanel) - panels[replacementPanel.id] = replacementPanel - panelTitles[replacementPanel.id] = replacementPanel.displayTitle - seedTerminalInheritanceFontPoints(panelId: replacementPanel.id, configTemplate: inheritedConfig) - surfaceIdToPanelId[replacementTab.id] = replacementPanel.id - - bonsplitController.updateTab( - replacementTab.id, - title: replacementPanel.displayTitle, - icon: .some(replacementPanel.displayIcon), - iconImageData: .some(nil), - kind: .some(SurfaceKind.terminal), - hasCustomTitle: false, - isDirty: replacementPanel.isDirty, - showsNotificationBadge: false, - isLoading: false, - isPinned: false - ) - - for extraPlaceholder in placeholderTabs.dropFirst() { - bonsplitController.closeTab(extraPlaceholder.id) - } - } else { -#if DEBUG - cmuxDebugLog( - "split.placeholderRepair pane=\(originalPane.id.uuidString.prefix(5)) " + - "fallback=createTerminalAndDropPlaceholders" - ) -#endif - _ = newTerminalSurface(inPane: originalPane, focus: false) - for tab in controller.tabs(inPane: originalPane) { - if panelIdFromSurfaceId(tab.id) == nil { - bonsplitController.closeTab(tab.id) - } - } - } - } normalizePinnedTabs(in: originalPane) normalizePinnedTabs(in: newPane) - scheduleTerminalGeometryReconcile() return } // Mirror Cmd+D behavior: split buttons should always seed a terminal in the new pane. // When the focused source is a browser, inherit terminal config from nearby terminals // (or fall back to defaults) instead of leaving an empty selector pane. - let sourceTabId = controller.selectedTab(inPane: originalPane)?.id - let sourcePanelId = sourceTabId.flatMap { panelIdFromSurfaceId($0) } + let sourceTabId = splitController.selectedTabId(inPane: originalPane) + let sourcePanelId = sourceTabId.flatMap { hasLivePanel(for: $0) ? $0.id : nil } #if DEBUG - cmuxDebugLog( + dlog( "split.didSplit.autoCreate pane=\(newPane.id.uuidString.prefix(5)) " + "fromPane=\(originalPane.id.uuidString.prefix(5)) sourcePanel=\(sourcePanelId.map { String($0.uuidString.prefix(5)) } ?? "none")" ) @@ -13505,28 +14088,26 @@ extension Workspace: BonsplitDelegate { portOrdinal: portOrdinal ) configureTerminalPanel(newPanel) + installTerminalPanelSubscription(newPanel) panels[newPanel.id] = newPanel - panelTitles[newPanel.id] = newPanel.displayTitle + seedInitialTerminalPanelTitle(newPanel) seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) - guard let newTabId = bonsplitController.createTab( - title: newPanel.displayTitle, - icon: newPanel.displayIcon, - kind: SurfaceKind.terminal, - isDirty: newPanel.isDirty, + guard let newTabId = splitController.createTab( + id: TabID(id: newPanel.id), + title: panelTitle(panelId: newPanel.id) ?? newPanel.displayTitle, isPinned: false, inPane: newPane ) else { panels.removeValue(forKey: newPanel.id) - panelTitles.removeValue(forKey: newPanel.id) + removeSurfaceState(panelId: newPanel.id) terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return } - surfaceIdToPanelId[newTabId] = newPanel.id normalizePinnedTabs(in: newPane) #if DEBUG - cmuxDebugLog( + dlog( "split.didSplit.autoCreate.done pane=\(newPane.id.uuidString.prefix(5)) " + "panel=\(newPanel.id.uuidString.prefix(5))" ) @@ -13536,167 +14117,76 @@ extension Workspace: BonsplitDelegate { // selection so our focus/unfocus logic runs after this delegate callback returns. DispatchQueue.main.async { [weak self] in guard let self else { return } - if self.bonsplitController.focusedPaneId == newPane { - self.bonsplitController.selectTab(newTabId) + if self.splitController.focusedPaneId == newPane { + self.splitController.selectTab(newTabId) } - self.scheduleTerminalGeometryReconcile() self.scheduleFocusReconcile() } } - private func selectedTerminalPanel(inPane pane: PaneID) -> TerminalPanel? { - guard let selectedTab = bonsplitController.selectedTab(inPane: pane), - let panelId = panelIdFromSurfaceId(selectedTab.id) else { - return nil - } - return terminalPanel(for: panelId) - } - - private func executeSurfaceTabBarCommandButton(identifier: String, inPane pane: PaneID) { - guard let executable = surfaceTabBarCommandButtons[identifier], - let globalConfigPath = surfaceTabBarButtonGlobalConfigPath else { - return - } - let presentingWindow = selectedTerminalPanel(inPane: pane)?.hostedView.window - ?? NSApp.keyWindow - ?? NSApp.mainWindow - - if let workspaceCommand = executable.workspaceCommand { - bonsplitController.focusPane(pane) - if let selectedTab = bonsplitController.selectedTab(inPane: pane) { - applyTabSelection(tabId: selectedTab.id, inPane: pane) - } - - let paneDirectory = selectedTerminalPanel(inPane: pane).flatMap { terminal -> String? in - for candidate in [panelDirectories[terminal.id], terminal.requestedWorkingDirectory] { - let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) - if let trimmed, !trimmed.isEmpty { - return trimmed - } - } - return nil - } - let rawCwd = paneDirectory ?? currentDirectory - let trimmedCwd = rawCwd.trimmingCharacters(in: .whitespacesAndNewlines) - let baseCwd = trimmedCwd.isEmpty ? FileManager.default.homeDirectoryForCurrentUser.path : trimmedCwd - guard let tabManager = owningTabManager else { return } - _ = CmuxConfigExecutor.execute( - command: workspaceCommand.command, - tabManager: tabManager, - baseCwd: baseCwd, - configSourcePath: workspaceCommand.sourcePath, - globalConfigPath: globalConfigPath, - displayTitle: executable.button.title ?? executable.button.tooltip ?? workspaceCommand.command.name, - actionID: executable.button.id, - icon: executable.button.icon ?? executable.button.action.defaultButtonIcon, - iconSourcePath: executable.button.iconSourcePath, - presentingWindow: presentingWindow - ) - return - } - - guard let command = executable.button.terminalCommand else { return } - let target = executable.button.resolvedTerminalCommandTarget - let didExecute = CmuxConfigExecutor.prepareShellInputIfAuthorized( - command, - confirm: executable.button.confirm ?? false, - actionID: executable.button.id, - target: target, - configSourcePath: executable.terminalCommandSourcePath ?? surfaceTabBarButtonSourcePath, - globalConfigPath: globalConfigPath, - displayTitle: executable.button.title ?? executable.button.tooltip, - icon: executable.button.icon ?? executable.button.action.defaultButtonIcon, - iconSourcePath: executable.button.iconSourcePath, - presentingWindow: presentingWindow - ) { [weak self] shellInput in - guard let self else { return } - self.bonsplitController.focusPane(pane) - switch target { - case .currentTerminal: - self.selectedTerminalPanel(inPane: pane)?.sendInput(shellInput) - case .newTabInCurrentPane: - _ = self.newTerminalSurface(inPane: pane, focus: true, initialInput: shellInput) - } - } - guard didExecute else { - return - } - } - - func splitTabBar(_ controller: BonsplitController, didRequestNewTab kind: String, inPane pane: PaneID) { - switch kind { - case "terminal": - _ = newTerminalSurface(inPane: pane) - case "browser": - _ = newBrowserSurface(inPane: pane) - default: - _ = newTerminalSurface(inPane: pane) + func workspaceSplit(didRequestNewTab kind: WorkspaceLayoutTabKind, inPane pane: PaneID) { + switch kind.panelType { + case .terminal: + _ = performLayoutCommand(.createTerminal(inPane: pane)) + case .browser: + _ = performLayoutCommand(.createBrowser(inPane: pane)) + case .markdown: + assertionFailure("Markdown tab requests require an explicit file path") } } - func splitTabBar(_ controller: BonsplitController, didRequestCustomAction identifier: String, inPane pane: PaneID) { -#if DEBUG - cmuxDebugLog( - "split.customAction.request workspace=\(id.uuidString.prefix(5)) " + - "pane=\(pane.id.uuidString.prefix(5)) identifier=\(identifier)" - ) -#endif - executeSurfaceTabBarCommandButton(identifier: identifier, inPane: pane) - } - - func splitTabBar(_ controller: BonsplitController, didRequestTabContextAction action: TabContextAction, for tab: Bonsplit.Tab, inPane pane: PaneID) { + func workspaceSplit(didRequestTabContextAction action: TabContextAction, for tabId: TabID, inPane pane: PaneID) { switch action { case .rename: - promptRenamePanel(tabId: tab.id) + promptRenamePanel(tabId: tabId) case .clearName: - guard let panelId = panelIdFromSurfaceId(tab.id) else { return } - setPanelCustomTitle(panelId: panelId, title: nil) + guard hasLivePanel(for: tabId) else { return } + setPanelCustomTitle(panelId: tabId.id, title: nil) case .copyIdentifiers: - guard let panelId = panelIdFromSurfaceId(tab.id) else { return } - copyIdentifiersToPasteboard(surfaceId: panelId) + guard hasLivePanel(for: tabId) else { return } + copyIdentifiersToPasteboard(surfaceId: tabId.id) case .closeToLeft: - closeTabs(tabIdsToLeft(of: tab.id, inPane: pane)) + closeTabs(tabIdsToLeft(of: tabId, inPane: pane)) case .closeToRight: - closeTabs(tabIdsToRight(of: tab.id, inPane: pane)) + closeTabs(tabIdsToRight(of: tabId, inPane: pane)) case .closeOthers: - closeTabs(tabIdsToCloseOthers(of: tab.id, inPane: pane)) + closeTabs(tabIdsToCloseOthers(of: tabId, inPane: pane)) case .move: - promptMovePanel(tabId: tab.id) + promptMovePanel(tabId: tabId) + case .moveToLeftPane: + guard hasLivePanel(for: tabId), + let destinationPane = splitController.adjacentPane(to: pane, direction: .left) else { return } + _ = performLayoutCommand(.moveSurface(panelId: tabId.id, toPane: destinationPane)) + case .moveToRightPane: + guard hasLivePanel(for: tabId), + let destinationPane = splitController.adjacentPane(to: pane, direction: .right) else { return } + _ = performLayoutCommand(.moveSurface(panelId: tabId.id, toPane: destinationPane)) case .newTerminalToRight: - createTerminalToRight(of: tab.id, inPane: pane) + createTerminalToRight(of: tabId, inPane: pane) case .newBrowserToRight: - createBrowserToRight(of: tab.id, inPane: pane) + createBrowserToRight(of: tabId, inPane: pane) case .reload: - guard let panelId = panelIdFromSurfaceId(tab.id), - let browser = browserPanel(for: panelId) else { return } + guard let browser = browserPanel(for: tabId) else { return } browser.reload() case .duplicate: - duplicateBrowserToRight(anchorTabId: tab.id, inPane: pane) + duplicateBrowserToRight(anchorTabId: tabId, inPane: pane) case .togglePin: - guard let panelId = panelIdFromSurfaceId(tab.id) else { return } - let shouldPin = !pinnedPanelIds.contains(panelId) - setPanelPinned(panelId: panelId, pinned: shouldPin) + guard hasLivePanel(for: tabId) else { return } + let shouldPin = !surfaceStateSnapshot(panelId: tabId.id).isPinned + setPanelPinned(panelId: tabId.id, pinned: shouldPin) case .markAsRead: - guard let panelId = panelIdFromSurfaceId(tab.id) else { return } - clearManualUnread(panelId: panelId) + guard hasLivePanel(for: tabId) else { return } + clearManualUnread(panelId: tabId.id) case .markAsUnread: - guard let panelId = panelIdFromSurfaceId(tab.id) else { return } - markPanelUnread(panelId) + guard hasLivePanel(for: tabId) else { return } + markPanelUnread(tabId.id) case .toggleZoom: - guard let panelId = panelIdFromSurfaceId(tab.id) else { return } - toggleSplitZoom(panelId: panelId) + guard hasLivePanel(for: tabId) else { return } + _ = performLayoutCommand(.toggleSplitZoom(panelId: tabId.id)) @unknown default: break } } - func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) { - tmuxLayoutSnapshot = snapshot - scheduleTerminalGeometryReconcile() - if !isDetachingCloseTransaction { - scheduleFocusReconcile() - } - } - // No post-close polling refresh loop: we rely on view invariants and Ghostty's wakeups. } diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 5248b088d804..c0df9cfe504c 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -1,15 +1,15 @@ import SwiftUI import Foundation import AppKit -import Bonsplit +import Observation enum TmuxOverlayExperimentTarget: String, CaseIterable, Codable, Sendable { case surface - case bonsplitPane + case workspacePane case tmuxActivePane var usesWorkspacePaneOverlay: Bool { - self == .bonsplitPane + self == .workspacePane } var usesTmuxActivePaneOverlay: Bool { @@ -36,16 +36,22 @@ struct TmuxOverlayExperimentSettings { static func target(enabled: Bool, rawValue: String?) -> TmuxOverlayExperimentTarget { guard enabled else { return .surface } - guard let rawValue, - let target = TmuxOverlayExperimentTarget(rawValue: rawValue) else { + guard let rawValue else { return defaultTarget } - return target + if let target = TmuxOverlayExperimentTarget(rawValue: rawValue) { + return target + } + // Backward compatibility for persisted pre-rename values. + if rawValue == "bonsplitPane" { + return .workspacePane + } + return defaultTarget } } private enum WorkspaceTitlebarInteractionMetrics { - // Keep in sync with Bonsplit's tab bar height so the monitor only covers + // Keep in sync with WorkspaceSplit's tab bar height so the monitor only covers // the minimal-mode titlebar strip. static let minimalModeTopStripHeight: CGFloat = 30 } @@ -102,11 +108,12 @@ struct TmuxWorkspacePaneOverlayRenderState: Equatable { } @MainActor -final class TmuxWorkspacePaneOverlayModel: ObservableObject { - @Published private(set) var unreadRects: [CGRect] = [] - @Published private(set) var flashRect: CGRect? - @Published private(set) var flashStartedAt: Date? - @Published private(set) var flashReason: WorkspaceAttentionFlashReason? +@Observable +final class TmuxWorkspacePaneOverlayModel { + private(set) var unreadRects: [CGRect] = [] + private(set) var flashRect: CGRect? + private(set) var flashStartedAt: Date? + private(set) var flashReason: WorkspaceAttentionFlashReason? private var lastWorkspaceId: UUID? private var lastFlashToken: UInt64? @@ -220,7 +227,21 @@ struct TmuxWorkspacePaneOverlayView: View { } } -/// View that renders a Workspace's content using BonsplitView +@MainActor +struct TmuxWorkspacePaneOverlayObservedView: View { + let model: TmuxWorkspacePaneOverlayModel + + var body: some View { + TmuxWorkspacePaneOverlayView( + unreadRects: model.unreadRects, + flashRect: model.flashRect, + flashStartedAt: model.flashStartedAt, + flashReason: model.flashReason + ) + } +} + +/// View that renders a Workspace's content using WorkspaceLayoutView struct WorkspaceContentView: View { @ObservedObject var workspace: Workspace let isWorkspaceVisible: Bool @@ -248,114 +269,50 @@ struct WorkspaceContentView: View { isSelectedInPane: Bool, isFocused: Bool ) -> Bool { - guard isWorkspaceVisible else { return false } - // During pane/tab reparenting, Bonsplit can transiently report selected=false - // for the currently focused panel. Keep focused content visible to avoid blank frames. - return isSelectedInPane || isFocused + WorkspaceLayoutRenderContext( + notificationStore: nil, + isWorkspaceVisible: isWorkspaceVisible, + isWorkspaceInputActive: false, + isMinimalMode: false, + appearance: PanelAppearance( + dividerColor: .clear, + unfocusedOverlayNSColor: .clear, + unfocusedOverlayOpacity: 0 + ), + workspacePortalPriority: 0, + usesWorkspacePaneOverlay: false, + showSplitButtons: false + ).panelVisibleInUI( + isSelectedInPane: isSelectedInPane, + isFocused: isFocused + ) } var body: some View { let appearance = PanelAppearance.fromConfig(config) - let isSplit = workspace.bonsplitController.allPaneIds.count > 1 || - workspace.panels.count > 1 let usesWorkspacePaneOverlay = TmuxOverlayExperimentSettings.target().usesWorkspacePaneOverlay + let renderContext = WorkspaceLayoutRenderContext( + notificationStore: notificationStore, + isWorkspaceVisible: isWorkspaceVisible, + isWorkspaceInputActive: isWorkspaceInputActive, + isMinimalMode: isMinimalMode, + appearance: appearance, + workspacePortalPriority: workspacePortalPriority, + usesWorkspacePaneOverlay: usesWorkspacePaneOverlay, + showSplitButtons: workspace.showsSplitButtons + ) + let renderSnapshot = workspace.makeLayoutRenderSnapshot(context: renderContext) - // 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 = isWorkspaceInputActive }() - - - // Wire up file drop handling so bonsplit's PaneDragContainerView can forward - // Finder file drops to the correct terminal panel. - let _ = { - workspace.bonsplitController.onFileDrop = { [weak workspace] urls, paneId in - guard let workspace else { return false } - // Find the focused panel in this pane and drop the files into it. - guard let tabId = workspace.bonsplitController.selectedTab(inPane: paneId)?.id, - let panelId = workspace.panelIdFromSurfaceId(tabId), - let panel = workspace.panels[panelId] as? TerminalPanel else { return false } - return panel.hostedView.handleDroppedURLs(urls) - } - }() - - let bonsplitView = BonsplitView(controller: workspace.bonsplitController) { tab, paneId in - // Content for each tab in bonsplit - let _ = Self.debugPanelLookup(tab: tab, workspace: workspace) - if let panel = workspace.panel(for: tab.id) { - let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id - let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id - let isVisibleInUI = Self.panelVisibleInUI( - isWorkspaceVisible: isWorkspaceVisible, - isSelectedInPane: isSelectedInPane, - isFocused: isFocused - ) - let showsNotificationRing = Workspace.shouldShowUnreadIndicator( - hasUnreadNotification: notificationStore.hasVisibleNotificationIndicator( - forTabId: workspace.id, - surfaceId: panel.id - ), - isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id) - ) - PanelContentView( - panel: panel, - paneId: paneId, - isFocused: isFocused, - isSelectedInPane: isSelectedInPane, - isVisibleInUI: isVisibleInUI, - portalPriority: workspacePortalPriority, - isSplit: isSplit, - appearance: appearance, - hasUnreadNotification: showsNotificationRing && !usesWorkspacePaneOverlay, - onFocus: { - // Keep bonsplit focus in sync with the AppKit first responder for the - // active workspace. This prevents divergence between the blue focused-tab - // indicator and where keyboard input/flash-focus actually lands. - guard isWorkspaceInputActive else { return } - guard workspace.panels[panel.id] != nil else { return } - workspace.focusPanel(panel.id, trigger: .terminalFirstResponder) - }, - onRequestPanelFocus: { - guard isWorkspaceInputActive else { return } - guard workspace.panels[panel.id] != nil else { return } - AppDelegate.shared?.noteMainPanelKeyboardFocusIntent( - workspaceId: workspace.id, - panelId: panel.id, - in: NSApp.keyWindow ?? NSApp.mainWindow - ) - workspace.focusPanel(panel.id) - }, - onTriggerFlash: { workspace.triggerDebugFlash(panelId: panel.id) } - ) - .onTapGesture { - workspace.bonsplitController.focusPane(paneId) - } - } else { - // Fallback for tabs without panels (shouldn't happen normally) - EmptyPanelView(workspace: workspace, paneId: paneId) - } - } emptyPane: { paneId in - // Empty pane content - EmptyPanelView(workspace: workspace, paneId: paneId) - .onTapGesture { - workspace.bonsplitController.focusPane(paneId) - } - } + let splitView = WorkspaceLayoutView( + host: workspace.layoutInteractionHandlers, + renderSnapshot: renderSnapshot, + surfaceRegistry: workspace.surfaceRegistry + ) .internalOnlyTabDrag() - // Split zoom swaps Bonsplit between the full split tree and a single pane view. - // Recreate the Bonsplit subtree on zoom enter/exit so stale pre-zoom pane chrome - // cannot remain stacked above portal-hosted browser content. - .id(splitZoomRenderIdentity) .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { - syncBonsplitNotificationBadges() refreshGhosttyAppearanceConfig(reason: "onAppear") } - .onChange(of: notificationStore.notifications) { _, _ in - syncBonsplitNotificationBadges() - } - .onChange(of: workspace.manualUnreadPanelIds) { _, _ in - syncBonsplitNotificationBadges() - } .onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in GhosttyConfig.invalidateLoadCache() refreshGhosttyAppearanceConfig(reason: "ghosttyConfigDidReload") @@ -383,45 +340,41 @@ struct WorkspaceContentView: View { } Group { - if isMinimalMode && !isFullScreen { - bonsplitView - .ignoresSafeArea(.container, edges: .top) + if isWorkspaceVisible { + if isMinimalMode && !isFullScreen { + splitView + .ignoresSafeArea(.container, edges: .top) + } else { + splitView + } } else { - bonsplitView + // Keep the split host mounted offscreen so runtime surfaces can + // background-prime before the workspace becomes visible. + splitView + .opacity(0.001) + .allowsHitTesting(false) + .accessibilityHidden(true) } } - } - - private func syncBonsplitNotificationBadges() { - let manualUnread = workspace.manualUnreadPanelIds - - for paneId in workspace.bonsplitController.allPaneIds { - for tab in workspace.bonsplitController.tabs(inPane: paneId) { - let panelId = workspace.panelIdFromSurfaceId(tab.id) - let expectedKind = panelId.flatMap { workspace.panelKind(panelId: $0) } - let expectedPinned = panelId.map { workspace.isPanelPinned($0) } ?? false - let shouldShow = panelId.map { - notificationStore.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: $0) || - manualUnread.contains($0) - } ?? false - let kindUpdate: String?? = expectedKind.map { .some($0) } - - if tab.showsNotificationBadge != shouldShow || - tab.isPinned != expectedPinned || - (expectedKind != nil && tab.kind != expectedKind) { - workspace.bonsplitController.updateTab( - tab.id, - kind: kindUpdate, - showsNotificationBadge: shouldShow, - isPinned: expectedPinned - ) - } - } + .onAppear { + #if DEBUG + startupLog( + "startup.view.appear workspace=\(workspace.id.uuidString.prefix(5)) " + + "visible=\(isWorkspaceVisible ? 1 : 0) inputActive=\(isWorkspaceInputActive ? 1 : 0) " + + "minimal=\(isMinimalMode ? 1 : 0) fullScreen=\(isFullScreen ? 1 : 0) " + + "panels=\(workspace.panels.count) focused=\(workspace.focusedPanelId?.uuidString.prefix(5) ?? "nil")" + ) + #endif + } + .onChange(of: isWorkspaceVisible) { _, newValue in + #if DEBUG + startupLog( + "startup.view.visibility workspace=\(workspace.id.uuidString.prefix(5)) " + + "visible=\(newValue ? 1 : 0) panels=\(workspace.panels.count) " + + "focused=\(workspace.focusedPanelId?.uuidString.prefix(5) ?? "nil")" + ) + #endif } - } - - private var splitZoomRenderIdentity: String { - workspace.bonsplitController.zoomedPaneId.map { "zoom:\($0.id.uuidString)" } ?? "unzoomed" } private static let tmuxWorkspacePaneTopChromeHeight: CGFloat = 30 @@ -489,16 +442,17 @@ struct WorkspaceContentView: View { return layoutSnapshot.panes.compactMap { pane in guard let selectedTabId = pane.selectedTabId, let tabUUID = UUID(uuidString: selectedTabId), - let panelId = workspace.panelIdFromSurfaceId(TabID(uuid: tabUUID)) else { + let panel = workspace.panel(for: TabID(uuid: tabUUID)) else { return nil } + let panelId = panel.id let shouldShowUnread = Workspace.shouldShowUnreadIndicator( hasUnreadNotification: notificationStore.hasVisibleNotificationIndicator( forTabId: workspace.id, surfaceId: panelId ), - isManuallyUnread: workspace.manualUnreadPanelIds.contains(panelId) + isManuallyUnread: workspace.isPanelManuallyUnread(panelId) ) guard shouldShowUnread else { return nil } @@ -681,7 +635,7 @@ struct WorkspaceContentView: View { ) } logTheme( - "theme refresh end workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) chromeBg=\(workspace.bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil")" + "theme refresh end workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) chromeBg=\(workspace.chromeBackgroundHex ?? "nil")" ) } @@ -691,35 +645,9 @@ struct WorkspaceContentView: View { } } -extension WorkspaceContentView { - #if DEBUG - static func debugPanelLookup(tab: Bonsplit.Tab, workspace: Workspace) { - let found = workspace.panel(for: tab.id) != nil - if !found { - let ts = ISO8601DateFormatter().string(from: Date()) - let line = "[\(ts)] PANEL NOT FOUND for tabId=\(tab.id) ws=\(workspace.id) panelCount=\(workspace.panels.count)\n" - let logPath = "/tmp/cmux-panel-debug.log" - if let handle = FileHandle(forWritingAtPath: logPath) { - defer { try? handle.close() } - guard (try? handle.seekToEnd()) != nil else { return } - try? handle.write(contentsOf: Data(line.utf8)) - } else { - FileManager.default.createFile(atPath: logPath, contents: line.data(using: .utf8)) - } - } - } - #else - static func debugPanelLookup(tab: Bonsplit.Tab, workspace: Workspace) { - _ = tab - _ = workspace - } - #endif -} - /// View shown for empty panes struct EmptyPanelView: View { - @ObservedObject var workspace: Workspace - let paneId: PaneID + let descriptor: WorkspacePlaceholderPaneContent @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared private struct ShortcutHint: View { @@ -735,26 +663,6 @@ struct EmptyPanelView: View { } } - private func focusPane() { - workspace.bonsplitController.focusPane(paneId) - } - - private func createTerminal() { - #if DEBUG - cmuxDebugLog("emptyPane.newTerminal pane=\(paneId.id.uuidString.prefix(5))") - #endif - focusPane() - _ = workspace.newTerminalSurface(inPane: paneId) - } - - private func createBrowser() { - #if DEBUG - cmuxDebugLog("emptyPane.newBrowser pane=\(paneId.id.uuidString.prefix(5))") - #endif - focusPane() - _ = workspace.newBrowserSurface(inPane: paneId) - } - private var newSurfaceShortcut: StoredShortcut { let _ = keyboardShortcutSettingsObserver.revision return KeyboardShortcutSettings.shortcut(for: .newSurface) @@ -807,14 +715,14 @@ struct EmptyPanelView: View { title: "Terminal", systemImage: "terminal.fill", shortcut: newSurfaceShortcut, - action: createTerminal + action: descriptor.onCreateTerminal ) emptyPaneActionButton( title: "Browser", systemImage: "globe", shortcut: openBrowserShortcut, - action: createBrowser + action: descriptor.onCreateBrowser ) } } diff --git a/Sources/WorkspaceSplitAppBridge.swift b/Sources/WorkspaceSplitAppBridge.swift new file mode 100644 index 000000000000..c76e27fd267c --- /dev/null +++ b/Sources/WorkspaceSplitAppBridge.swift @@ -0,0 +1,85 @@ +import Foundation + +struct WorkspaceLayoutRenderContext { + let notificationStore: TerminalNotificationStore? + let isWorkspaceVisible: Bool + let isWorkspaceInputActive: Bool + let isMinimalMode: Bool + let appearance: PanelAppearance + let workspacePortalPriority: Int + let usesWorkspacePaneOverlay: Bool + let showSplitButtons: Bool + + func panelVisibleInUI(isSelectedInPane: Bool, isFocused: Bool) -> Bool { + guard isWorkspaceVisible else { return false } + return isSelectedInPane || isFocused + } + + func panelPresentationFacts( + paneId: PaneID, + panelId: UUID, + isSelectedInPane: Bool, + isFocused: Bool + ) -> WorkspacePanelPresentationFacts { + WorkspacePanelPresentationFacts( + paneId: paneId, + panelId: panelId, + isWorkspaceVisible: isWorkspaceVisible, + isWorkspaceInputActive: isWorkspaceInputActive, + isSelectedInPane: isSelectedInPane, + isFocused: isFocused + ) + } +} + +extension WorkspaceLayoutTabKind { + init?(_ panelType: PanelType?) { + guard let panelType else { return nil } + switch panelType { + case .terminal: + self = .terminal + case .browser: + self = .browser + case .markdown: + self = .markdown + } + } + + var panelType: PanelType { + switch self { + case .terminal: + .terminal + case .browser: + .browser + case .markdown: + .markdown + } + } +} + +extension WorkspaceTerminalPaneAppearance { + init(_ appearance: PanelAppearance) { + self.init( + unfocusedOverlayNSColor: appearance.unfocusedOverlayNSColor, + unfocusedOverlayOpacity: appearance.unfocusedOverlayOpacity + ) + } +} + +extension WorkspaceSurfaceLifecycleFacts { + init(_ facts: TerminalViewportLifecycleFacts) { + self.init( + isVisibleInUI: facts.isVisibleInUI, + isWindowed: facts.isWindowed, + hasUsableGeometry: facts.hasUsableGeometry, + hasRuntime: facts.hasRuntime, + hasPresentedFrame: facts.hasPresentedFrame + ) + } +} + +extension WorkspaceSurfacePresentationFacts { + static func terminal(_ facts: TerminalViewportLifecycleFacts) -> WorkspaceSurfacePresentationFacts { + terminal(WorkspaceSurfaceLifecycleFacts(facts)) + } +} diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 84a69df59a76..95fbe4bb11db 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -1,7 +1,7 @@ import AppKit +import Observation import SwiftUI import Darwin -import Bonsplit import UniformTypeIdentifiers @main @@ -9,7 +9,11 @@ struct cmuxApp: App { @StateObject private var tabManager: TabManager @StateObject private var notificationStore = TerminalNotificationStore.shared @StateObject private var sidebarState = SidebarState() + @StateObject private var sidebarSelectionState = SidebarSelectionState() + @StateObject private var fileExplorerState = FileExplorerState() + @StateObject private var cmuxConfigStore = CmuxConfigStore() @StateObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared + private let primaryWindowId = UUID() @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue @AppStorage("titlebarControlsStyle") private var titlebarControlsStyle = TitlebarControlsStyle.classic.rawValue @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @@ -186,14 +190,31 @@ struct cmuxApp: App { var body: some Scene { WindowGroup { - MainWindowBootstrapView() + ContentView(updateViewModel: appDelegate.updateViewModel, windowId: primaryWindowId) + .environmentObject(tabManager) + .environmentObject(notificationStore) + .environmentObject(sidebarState) + .environmentObject(sidebarSelectionState) + .environmentObject(fileExplorerState) + .environmentObject(cmuxConfigStore) .onAppear { #if DEBUG if ProcessInfo.processInfo.environment["CMUX_UI_TEST_MODE"] == "1" { UpdateLogStore.shared.append("ui test: cmuxApp onAppear") } #endif - bootstrapMainWindowScene() + // Start the Unix socket controller for programmatic access + updateSocketController() + appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState) + appDelegate.fileExplorerState = fileExplorerState + cmuxConfigStore.wireDirectoryTracking(tabManager: tabManager) + cmuxConfigStore.loadAll() + applyAppearance() + if ProcessInfo.processInfo.environment["CMUX_UI_TEST_SHOW_SETTINGS"] == "1" { + DispatchQueue.main.async { + appDelegate.openPreferencesWindow(debugSource: "uiTestShowSettings") + } + } } .onChange(of: appearanceMode) { _ in applyAppearance() @@ -335,33 +356,6 @@ struct cmuxApp: App { Button("Debug Window Controls…") { DebugWindowControlsWindowController.shared.show() } - Button("Feed Preview…") { - FeedPreviewWindowController.shared.show() - } - Button( - String( - localized: "debug.menu.feedTextEditorDebug", - defaultValue: "Feed Text Editor Lab…" - ) - ) { - FeedTextEditorDebugWindowController.shared.show() - } - Button( - String( - localized: "debug.menu.feedButtonStyleDebug", - defaultValue: "Feed Button Style Debug…" - ) - ) { - FeedButtonStyleDebugWindowController.shared.show() - } - Button( - String( - localized: "debug.menu.startupAppearanceDebug", - defaultValue: "Startup Appearance Debug…" - ) - ) { - StartupAppearanceDebugWindowController.shared.show() - } Button("Menu Bar Extra Debug…") { MenuBarExtraDebugWindowController.shared.show() } @@ -374,6 +368,9 @@ struct cmuxApp: App { Button("Split Button Layout Debug…") { SplitButtonLayoutDebugWindowController.shared.show() } + Button("Workspace Tab Chrome Debug…") { + WorkspaceTabChromeDebugWindowController.shared.show() + } Button( String( localized: "debug.menu.tabBarBackdropLab", @@ -452,12 +449,16 @@ struct cmuxApp: App { splitCommandButton(title: String(localized: "menu.file.newWorkspace", defaultValue: "New Workspace"), shortcut: menuShortcut(for: .newTab)) { if let appDelegate = AppDelegate.shared { - appDelegate.performNewWorkspaceAction( - tabManager: activeTabManager, - debugSource: "menu.newWorkspace" - ) + if appDelegate.addWorkspaceInPreferredMainWindow(debugSource: "menu.newWorkspace") == nil { +#if DEBUG + FocusLogStore.shared.append( + "cmdn.route phase=fallback_new_window src=menu.newWorkspace reason=workspace_creation_returned_nil" + ) +#endif + appDelegate.openNewMainWindow(nil) + } } else { - activeTabManager.addWorkspace() + activeTabManager.addTab() } } @@ -514,12 +515,6 @@ struct cmuxApp: App { workspaceCommandMenuContent(manager: activeTabManager) } - splitCommandButton(title: String(localized: "menu.file.reopenPreviousSession", defaultValue: "Reopen Previous Session"), shortcut: menuShortcut(for: .reopenPreviousSession)) { - if AppDelegate.shared?.reopenPreviousSession() != true { - NSSound.beep() - } - } - splitCommandButton(title: String(localized: "menu.file.reopenClosedBrowserPanel", defaultValue: "Reopen Closed Browser Panel"), shortcut: menuShortcut(for: .reopenClosedBrowserPanel)) { _ = activeTabManager.reopenMostRecentlyClosedBrowserPanel() } @@ -528,35 +523,24 @@ struct cmuxApp: App { // Find CommandGroup(after: .textEditing) { Menu(String(localized: "menu.find.title", defaultValue: "Find")) { - let restoreFindTargetFocus = { - _ = AppDelegate.shared?.restoreFocusedMainPanelFocusFromRightSidebar( - preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow - ) - } - splitCommandButton(title: String(localized: "menu.find.find", defaultValue: "Find…"), shortcut: menuShortcut(for: .find)) { #if DEBUG - cmuxDebugLog("find.menu Cmd+F fired") + dlog("find.menu Cmd+F fired") #endif - _ = AppDelegate.shared?.performFindShortcutInActiveMainWindow( - preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow - ) + activeTabManager.startSearch() } splitCommandButton(title: String(localized: "menu.find.findNext", defaultValue: "Find Next"), shortcut: menuShortcut(for: .findNext)) { - restoreFindTargetFocus() activeTabManager.findNext() } splitCommandButton(title: String(localized: "menu.find.findPrevious", defaultValue: "Find Previous"), shortcut: menuShortcut(for: .findPrevious)) { - restoreFindTargetFocus() activeTabManager.findPrevious() } Divider() splitCommandButton(title: String(localized: "menu.find.hideFindBar", defaultValue: "Hide Find Bar"), shortcut: menuShortcut(for: .hideFind)) { - restoreFindTargetFocus() activeTabManager.hideFind() } .disabled(!(activeTabManager.isFindVisible)) @@ -564,7 +548,6 @@ struct cmuxApp: App { Divider() splitCommandButton(title: String(localized: "menu.find.useSelectionForFind", defaultValue: "Use Selection for Find"), shortcut: menuShortcut(for: .useSelectionForFind)) { - restoreFindTargetFocus() activeTabManager.searchSelection() } .disabled(!(activeTabManager.canUseSelectionForFind)) @@ -579,16 +562,6 @@ struct cmuxApp: App { } } - splitCommandButton(title: String(localized: "menu.view.focusRightSidebar", defaultValue: "Focus Right Sidebar"), shortcut: menuShortcut(for: .focusRightSidebar)) { - if AppDelegate.shared?.toggleRightSidebarKeyboardFocusInActiveMainWindow() != true { - if AppDelegate.shared?.focusRightSidebarInActiveMainWindow( - preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow - ) != true { - NSSound.beep() - } - } - } - Divider() splitCommandButton(title: String(localized: "menu.view.nextSurface", defaultValue: "Next Surface"), shortcut: menuShortcut(for: .nextSurface)) { @@ -730,10 +703,6 @@ struct cmuxApp: App { } } } - - Window(String(localized: "settings.config.windowTitle", defaultValue: "Config"), id: ConfigSettingsView.windowID) { - ConfigSettingsView() - } } private func showAboutPanel() { @@ -765,7 +734,7 @@ struct cmuxApp: App { let mode = SocketControlSettings.effectiveMode(userMode: currentSocketMode) if mode != .off { TerminalController.shared.start( - tabManager: activeTabManager, + tabManager: tabManager, socketPath: SocketControlSettings.socketPath(), accessMode: mode ) @@ -774,11 +743,6 @@ struct cmuxApp: App { } } - private func bootstrapMainWindowScene() { - appDelegate.scheduleInitialMainWindowBootstrap(debugSource: "swiftUIBootstrap") - applyAppearance() - } - private var currentSocketMode: SocketControlMode { SocketControlSettings.migrateMode(socketControlMode) } @@ -1050,35 +1014,18 @@ struct cmuxApp: App { AppDelegate.shared?.toggleNotificationsPopover(animated: false) } -#if DEBUG private func openAllDebugWindows() { BrowserImportHintDebugWindowController.shared.show() BrowserProfilePopoverDebugWindowController.shared.show() SettingsAboutTitlebarDebugWindowController.shared.show() SidebarDebugWindowController.shared.show() BackgroundDebugWindowController.shared.show() - StartupAppearanceDebugWindowController.shared.show() MenuBarExtraDebugWindowController.shared.show() - FeedPreviewWindowController.shared.show() + WorkspaceTabChromeDebugWindowController.shared.show() + TabBarBackdropLabWindowController.shared.show() +#if DEBUG FeedTextEditorDebugWindowController.shared.show() - FeedButtonStyleDebugWindowController.shared.show() - } #endif -} - -private struct MainWindowBootstrapView: View { - var body: some View { - Color.clear - .frame(width: 1, height: 1) - .background(WindowAccessor { window in - window.identifier = NSUserInterfaceItemIdentifier("cmux.bootstrap") - window.isRestorable = false - window.orderOut(nil) - Task { @MainActor [weak window] in - window?.orderOut(nil) - window?.close() - } - }) } } @@ -1093,7 +1040,8 @@ private let cmuxAuxiliaryWindowIdentifiers: Set = [ "cmux.sidebarDebug", "cmux.menubarDebug", "cmux.backgroundDebug", - "cmux.startupAppearanceDebug", + "cmux.workspaceTabChromeDebug", + "cmux.tabBarBackdropLab", ] /// Returns whether the given window should handle the standard close shortcut @@ -1263,13 +1211,14 @@ private struct SettingsAboutTitlebarDebugOptions: Equatable { } @MainActor -private final class SettingsAboutTitlebarDebugStore: ObservableObject { +@Observable +private final class SettingsAboutTitlebarDebugStore { static let shared = SettingsAboutTitlebarDebugStore() - @Published var settingsOptions = SettingsAboutTitlebarDebugOptions.defaults(for: .settings) { + var settingsOptions = SettingsAboutTitlebarDebugOptions.defaults(for: .settings) { didSet { applyToOpenWindows(for: .settings) } } - @Published var aboutOptions = SettingsAboutTitlebarDebugOptions.defaults(for: .about) { + var aboutOptions = SettingsAboutTitlebarDebugOptions.defaults(for: .about) { didSet { applyToOpenWindows(for: .about) } } @@ -1439,7 +1388,7 @@ private final class SettingsAboutTitlebarDebugWindowController: NSWindowControll } private struct SettingsAboutTitlebarDebugView: View { - @ObservedObject private var store = SettingsAboutTitlebarDebugStore.shared + private let store = SettingsAboutTitlebarDebugStore.shared var body: some View { ScrollView { @@ -1584,7 +1533,6 @@ private enum DebugWindowConfigSnapshot { shortcutHintPaneTabYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.paneHintYKey, fallback: ShortcutHintDebugSettings.defaultPaneHintY))) shortcutHintAlwaysShow=\(boolValue(defaults, key: ShortcutHintDebugSettings.alwaysShowHintsKey, fallback: ShortcutHintDebugSettings.defaultAlwaysShowHints)) shortcutHintShowOnCommandHold=\(boolValue(defaults, key: ShortcutHintDebugSettings.showHintsOnCommandHoldKey, fallback: ShortcutHintDebugSettings.defaultShowHintsOnCommandHold)) - shortcutHintShowOnControlHold=\(boolValue(defaults, key: ShortcutHintDebugSettings.showHintsOnControlHoldKey, fallback: ShortcutHintDebugSettings.defaultShowHintsOnControlHold)) """ let backgroundPayload = """ @@ -1632,7 +1580,6 @@ private enum DebugWindowConfigSnapshot { } } -#if DEBUG private final class DebugWindowControlsWindowController: NSWindowController, NSWindowDelegate { static let shared = DebugWindowControlsWindowController() @@ -1728,14 +1675,6 @@ private struct DebugWindowControlsView: View { Button("Background Debug…") { BackgroundDebugWindowController.shared.show() } - Button( - String( - localized: "debug.menu.startupAppearanceDebug", - defaultValue: "Startup Appearance Debug…" - ) - ) { - StartupAppearanceDebugWindowController.shared.show() - } Button("Menu Bar Extra Debug…") { MenuBarExtraDebugWindowController.shared.show() } @@ -1747,6 +1686,7 @@ private struct DebugWindowControlsView: View { ) { TabBarBackdropLabWindowController.shared.show() } +#if DEBUG Button( String( localized: "debug.menu.feedTextEditorDebug", @@ -1755,15 +1695,14 @@ private struct DebugWindowControlsView: View { ) { FeedTextEditorDebugWindowController.shared.show() } +#endif Button("Open All Debug Windows") { BrowserImportHintDebugWindowController.shared.show() BrowserProfilePopoverDebugWindowController.shared.show() SettingsAboutTitlebarDebugWindowController.shared.show() SidebarDebugWindowController.shared.show() BackgroundDebugWindowController.shared.show() - StartupAppearanceDebugWindowController.shared.show() MenuBarExtraDebugWindowController.shared.show() - FeedTextEditorDebugWindowController.shared.show() } } .frame(maxWidth: .infinity, alignment: .leading) @@ -1962,7 +1901,6 @@ private struct DebugWindowControlsView: View { pasteboard.setString(payload, forType: .string) } } -#endif private final class BrowserImportHintDebugWindowController: NSWindowController, NSWindowDelegate { static let shared = BrowserImportHintDebugWindowController() @@ -2482,7 +2420,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { func show(navigationTarget: SettingsNavigationTarget? = nil) { guard let window else { return } #if DEBUG - cmuxDebugLog("settings.window.show requested isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)") + dlog("settings.window.show requested isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)") #endif SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .settings) if !window.isVisible { @@ -2495,7 +2433,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { } } #if DEBUG - cmuxDebugLog("settings.window.show completed isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)") + dlog("settings.window.show completed isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)") #endif } @@ -2686,6 +2624,7 @@ private struct FileExplorerStyleDebugView: View { extension Notification.Name { static let fileExplorerStyleDidChange = Notification.Name("fileExplorerStyleDidChange") static let titlebarShortcutHintsVisibilityChanged = Notification.Name("titlebarShortcutHintsVisibilityChanged") + static let workspaceTabChromeDebugSettingsDidChange = Notification.Name("workspaceTabChromeDebugSettingsDidChange") } private final class FileExplorerStyleDebugWindowController: NSWindowController, NSWindowDelegate { @@ -3394,35 +3333,29 @@ private struct SplitButtonLayoutDebugView: View { } } -// MARK: - Tab Bar Backdrop Lab Window +// MARK: - Workspace Tab Chrome Debug Window -private final class TabBarBackdropLabWindowController: NSWindowController, NSWindowDelegate { - static let shared = TabBarBackdropLabWindowController() +final class WorkspaceTabChromeDebugWindowController: NSWindowController, NSWindowDelegate { + static let shared = WorkspaceTabChromeDebugWindowController() private init() { let window = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 1600, height: 1040), - styleMask: [.titled, .closable, .resizable, .fullSizeContentView], + contentRect: NSRect(x: 0, y: 0, width: 1560, height: 980), + styleMask: [.titled, .closable, .resizable, .utilityWindow], backing: .buffered, defer: false ) - window.title = String(localized: "debug.tabBarBackdropLab.title", defaultValue: "Tab Bar Backdrop Lab") - window.titleVisibility = .hidden - window.titlebarAppearsTransparent = true + window.title = "Workspace Tab Chrome Debug" + window.titleVisibility = .visible + window.titlebarAppearsTransparent = false window.isMovableByWindowBackground = true window.isReleasedWhenClosed = false - window.isOpaque = false - window.backgroundColor = .clear - window.collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary] - window.level = .floating - window.identifier = NSUserInterfaceItemIdentifier("cmux.tabBarBackdropLab") + window.hidesOnDeactivate = false + window.isFloatingPanel = true + window.identifier = NSUserInterfaceItemIdentifier("cmux.workspaceTabChromeDebug") window.center() - - let hostingView = NSHostingView(rootView: TabBarBackdropLabView()) - hostingView.wantsLayer = true - hostingView.layer?.backgroundColor = NSColor.clear.cgColor - window.contentView = hostingView - + window.contentView = NSHostingView(rootView: WorkspaceTabChromeDebugView()) + AppDelegate.shared?.applyWindowDecorations(to: window) super.init(window: window) window.delegate = self } @@ -3431,277 +3364,926 @@ private final class TabBarBackdropLabWindowController: NSWindowController, NSWin required init?(coder: NSCoder) { fatalError() } func show() { - window?.center() - window?.makeKeyAndOrderFront(nil) - window?.orderFrontRegardless() + guard let window else { return } + window.center() + showWindow(nil) + window.orderFrontRegardless() + window.makeKeyAndOrderFront(nil) + if #available(macOS 14.0, *) { + NSRunningApplication.current.activate(options: [.activateAllWindows]) + } else { + NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + } + if let windowIdPath = ProcessInfo.processInfo.environment["CMUX_WORKSPACE_TAB_CHROME_WINDOW_ID_FILE"], + !windowIdPath.isEmpty { + try? "\(window.windowNumber)\n".write( + to: URL(fileURLWithPath: windowIdPath), + atomically: true, + encoding: .utf8 + ) + } } } -private struct TabBarBackdropLabVariant: Identifiable { - let id: String - let title: String - let detail: String - let effect: BonsplitConfiguration.Appearance.SplitButtonBackdropEffect - let chromeHex: String - let tabBarHex: String - let splitButtonBackdropHex: String - let paneHex: String - let borderHex: String - let terminalColor: NSColor - let surfaceColor: NSColor - let separatorColor: NSColor - let opacity: CGFloat +private struct WorkspaceTabChromeDebugRenderedScenario: Identifiable { + let scenario: WorkspaceLayoutTabChromeDebugScenario + let appKitImage: NSImage + let referenceImage: NSImage + let diffImage: NSImage + let metrics: WorkspaceLayoutTabChromeDebugDiffMetrics - var renderIdentity: String { - "\(id)-\(chromeHex)-\(tabBarHex)-\(splitButtonBackdropHex)-\(paneHex)-\(borderHex)-\(String(format: "%.3f", opacity))-\(String(format: "%.1f", effect.fadeWidth))-\(String(format: "%.1f", effect.contentFadeWidth))-\(String(format: "%.1f", effect.solidWidth))-\(String(format: "%.2f", effect.fadeRampStartFraction))-\(String(format: "%.2f", effect.leadingOpacity))-\(String(format: "%.2f", effect.trailingOpacity))-\(String(format: "%.2f", effect.contentOcclusionFraction))-\(effect.masksTabContent ? 1 : 0)" - } + var id: String { scenario.id } } -private struct TabBarBackdropLabView: View { - @State private var opacity: Double - @State private var sidebarWidth: Double = 74 - @State private var sampleWidth: Double = 460 - @State private var candidateSoftness: Double = Double(Workspace.bonsplitSplitButtonBackdropSoftness) +@MainActor +private func workspaceTabChromeDebugRenderedScenario( + from scenario: WorkspaceLayoutTabChromeDebugScenario +) -> WorkspaceTabChromeDebugRenderedScenario? { + guard let appKitImage = workspaceLayoutRenderAppKitTabChromeImage(scenario: scenario), + let referenceImage = workspaceLayoutRenderReferenceTabChromeImage(scenario: scenario), + let diff = workspaceLayoutTabChromeDebugDiff( + appKitImage: appKitImage, + referenceImage: referenceImage + ) else { + return nil + } + return WorkspaceTabChromeDebugRenderedScenario( + scenario: scenario, + appKitImage: appKitImage, + referenceImage: referenceImage, + diffImage: diff.image, + metrics: diff.metrics + ) +} - init() { - let currentOpacity = Double(WindowAppearanceSnapshot.clampedOpacity(GhosttyApp.shared.defaultBackgroundOpacity)) - _opacity = State(initialValue: currentOpacity < 0.999 ? currentOpacity : 0.72) - } +@MainActor +private func workspaceTabChromeDebugRenderedScenarios() -> [WorkspaceTabChromeDebugRenderedScenario] { + workspaceLayoutTabChromeDebugScenarios().compactMap(workspaceTabChromeDebugRenderedScenario(from:)) +} - private var terminalColor: NSColor { - GhosttyApp.shared.defaultBackgroundColor.usingColorSpace(.sRGB) ?? NSColor(hex: "#646461") ?? .windowBackgroundColor - } +@MainActor +private func workspaceTabChromeDebugPreviewScenario( + isSelected: Bool, + isHovered: Bool, + isCloseHovered: Bool, + isClosePressed: Bool +) -> WorkspaceLayoutTabChromeDebugScenario { + let appearance = workspaceLayoutTabChromeDebugAppearance() + let tab = WorkspaceLayout.Tab.rendered(title: "~/fun/cmuxterm-hq", icon: "terminal.fill", kind: .terminal) + + return WorkspaceLayoutTabChromeDebugScenario( + id: "interactive-preview", + title: String( + localized: "debug.workspaceTabChrome.preview.scenarioTitle", + defaultValue: "Interactive Preview" + ), + tab: tab, + isSelected: isSelected, + isHovered: isHovered, + isCloseHovered: isCloseHovered || isClosePressed, + isClosePressed: isClosePressed, + showsZoomIndicator: false, + isZoomHovered: false, + isZoomPressed: false, + appearance: appearance + ) +} - private var surfaceColor: NSColor { - terminalColor.withAlphaComponent(CGFloat(opacity)) - } +private struct WorkspaceTabChromeDebugView: View { + @AppStorage(WorkspaceLayoutTabChromeDebugSettings.closeGlyphDXKey) + private var closeGlyphDX = WorkspaceLayoutTabChromeDebugSettings.defaultCloseGlyphDX + @AppStorage(WorkspaceLayoutTabChromeDebugSettings.closeGlyphDYKey) + private var closeGlyphDY = WorkspaceLayoutTabChromeDebugSettings.defaultCloseGlyphDY + @AppStorage(WorkspaceLayoutTabChromeDebugSettings.closeCircleDXKey) + private var closeCircleDX = WorkspaceLayoutTabChromeDebugSettings.defaultCloseCircleDX + @AppStorage(WorkspaceLayoutTabChromeDebugSettings.closeCircleDYKey) + private var closeCircleDY = WorkspaceLayoutTabChromeDebugSettings.defaultCloseCircleDY + @AppStorage(WorkspaceLayoutTabChromeDebugSettings.closeCircleSizeDeltaKey) + private var closeCircleSizeDelta = WorkspaceLayoutTabChromeDebugSettings.defaultCloseCircleSizeDelta + @State private var previewIsSelected = true + @State private var previewIsHovered = false + @State private var previewCloseHovered = true + @State private var previewClosePressed = false + @State private var previewRenderedScenario: WorkspaceTabChromeDebugRenderedScenario? + @State private var renderedScenarios: [WorkspaceTabChromeDebugRenderedScenario] = [] + @State private var overlayOpacity = 0.55 + @State private var exportStatus: String = "" - private var separatorColor: NSColor { - WindowChromeSeparatorColor.color(forChromeBackground: terminalColor) - } + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Workspace Tab Chrome") + .font(.headline) + Text("AppKit candidate vs. reference tab rendering across representative states.") + .font(.caption) + .foregroundColor(.secondary) + } - private var candidateBackdropEffect: BonsplitConfiguration.Appearance.SplitButtonBackdropEffect { - let softness = CGFloat(min(max(0, candidateSoftness), 1)) - let productionSoftness = Workspace.bonsplitSplitButtonBackdropSoftness - let production = Workspace.bonsplitSplitButtonBackdropEffect() - func interpolate(strong: CGFloat, production: CGFloat, soft: CGFloat) -> CGFloat { - if softness <= productionSoftness { - let progress = softness / productionSoftness - return strong + ((production - strong) * progress) + Spacer() + + HStack(spacing: 8) { + Text("Overlay") + Slider(value: $overlayOpacity, in: 0...1) + .frame(width: 160) + Text(String(format: "%.0f%%", overlayOpacity * 100)) + .monospacedDigit() + .frame(width: 44, alignment: .trailing) + } + + Button("Refresh") { + renderAll() + } + + Button("Export PNGs") { + exportPNGs() + } } - let progress = (softness - productionSoftness) / (1 - productionSoftness) - return production + ((soft - production) * progress) - } - return .init( - style: .translucentChrome, - fadeWidth: interpolate(strong: 20, production: production.fadeWidth, soft: 240), - contentFadeWidth: interpolate(strong: 0, production: production.contentFadeWidth, soft: 80), - solidWidth: interpolate(strong: 72, production: production.solidWidth, soft: 0), - fadeRampStartFraction: interpolate(strong: 0, production: production.fadeRampStartFraction, soft: 0.98), - leadingOpacity: production.leadingOpacity, - trailingOpacity: interpolate(strong: 1.0, production: production.trailingOpacity, soft: 0.25), - contentOcclusionFraction: interpolate(strong: 0, production: production.contentOcclusionFraction, soft: 1), - masksTabContent: true - ) - } + if !renderedScenarios.isEmpty { + let perfectCount = renderedScenarios.filter { $0.metrics.matchingPixels }.count + Text("\(perfectCount)/\(renderedScenarios.count) states pixel-perfect") + .font(.caption) + .foregroundColor(perfectCount == renderedScenarios.count ? .green : .secondary) + } - private var variants: [TabBarBackdropLabVariant] { - let chromeHex = surfaceColor.hexString(includeAlpha: true) - let paneHex = "#00000000" - let borderHex = separatorColor.hexString(includeAlpha: true) - let opacityValue = CGFloat(opacity) - let candidate = candidateBackdropEffect + if !exportStatus.isEmpty { + Text(exportStatus) + .font(.caption) + .foregroundColor(.secondary) + .textSelection(.enabled) + } - return [ - variant( - id: "candidate", - title: String(localized: "debug.tabBarBackdropLab.variant.candidate", defaultValue: "Candidate"), - detail: String(localized: "debug.tabBarBackdropLab.variant.candidate.detail", defaultValue: "Translucent chrome with tab occlusion."), - effect: candidate, - chromeHex: chromeHex, - paneHex: paneHex, - borderHex: borderHex, - opacity: opacityValue - ), - variant( - id: "candidateWideFade", - title: String(localized: "debug.tabBarBackdropLab.variant.candidateWideFade", defaultValue: "Wide fade"), - detail: String(localized: "debug.tabBarBackdropLab.variant.candidateWideFade.detail", defaultValue: "Same model with a softer edge."), - effect: .init( - style: .translucentChrome, - fadeWidth: 104, - contentFadeWidth: 46, - solidWidth: 10, - fadeRampStartFraction: 0.88, - leadingOpacity: 0, - trailingOpacity: 1.0, - masksTabContent: true - ), - chromeHex: chromeHex, - paneHex: paneHex, - borderHex: borderHex, - opacity: opacityValue - ), - variant( - id: "candidateSoftEnd", - title: String(localized: "debug.tabBarBackdropLab.variant.candidateSoftEnd", defaultValue: "Soft end"), - detail: String(localized: "debug.tabBarBackdropLab.variant.candidateSoftEnd.detail", defaultValue: "Same mask with lighter button fill."), - effect: .init( - style: .translucentChrome, - fadeWidth: 100, - contentFadeWidth: 42, - solidWidth: 14, - fadeRampStartFraction: 0.84, - leadingOpacity: 0, - trailingOpacity: 0.82, - masksTabContent: true - ), - chromeHex: chromeHex, - paneHex: paneHex, - borderHex: borderHex, - opacity: opacityValue - ), - variant( - id: "candidateTightEdge", - title: String(localized: "debug.tabBarBackdropLab.variant.candidateTightEdge", defaultValue: "Tight edge"), - detail: String(localized: "debug.tabBarBackdropLab.variant.candidateTightEdge.detail", defaultValue: "More coverage at the fade start."), - effect: .init( - style: .translucentChrome, - fadeWidth: 90, - contentFadeWidth: 34, - solidWidth: 24, - fadeRampStartFraction: 0.70, - leadingOpacity: 0.08, - trailingOpacity: 1.0, - masksTabContent: true - ), - chromeHex: chromeHex, - paneHex: paneHex, - borderHex: borderHex, - opacity: opacityValue - ), - variant( - id: "candidateLowContrast", - title: String(localized: "debug.tabBarBackdropLab.variant.candidateLowContrast", defaultValue: "Low contrast"), - detail: String(localized: "debug.tabBarBackdropLab.variant.candidateLowContrast.detail", defaultValue: "Lower-opacity solid region."), - effect: .init( - style: .translucentChrome, - fadeWidth: 102, - contentFadeWidth: 38, - solidWidth: 12, - fadeRampStartFraction: 0.86, - leadingOpacity: 0, - trailingOpacity: 0.72, - masksTabContent: true - ), - chromeHex: chromeHex, - paneHex: paneHex, - borderHex: borderHex, - opacity: opacityValue - ), - variant( - id: "translucentChrome", - title: String(localized: "debug.tabBarBackdropLab.variant.translucentChrome", defaultValue: "6 Translucent chrome"), - detail: String(localized: "debug.tabBarBackdropLab.variant.translucentChrome.detail", defaultValue: "Shows the bleed-through problem."), - effect: .init( - style: .translucentChrome, - fadeWidth: 96, - contentFadeWidth: 42, - solidWidth: 18, - fadeRampStartFraction: 0.82, - leadingOpacity: 0, - trailingOpacity: 1.0, - masksTabContent: false - ), - chromeHex: chromeHex, - paneHex: paneHex, - borderHex: borderHex, - opacity: opacityValue - ), - variant( - id: "translucentNoFade", - title: String(localized: "debug.tabBarBackdropLab.variant.translucentNoFade", defaultValue: "No fade"), - detail: String(localized: "debug.tabBarBackdropLab.variant.translucentNoFade.detail", defaultValue: "Hard translucent edge for contrast."), - effect: .init( - style: .translucentChrome, - fadeWidth: 0, - solidWidth: 30, - leadingOpacity: 1.0, - trailingOpacity: 1.0, - masksTabContent: true - ), - chromeHex: chromeHex, - paneHex: paneHex, - borderHex: borderHex, - opacity: opacityValue - ), - variant( - id: "hidden", - title: String(localized: "debug.tabBarBackdropLab.variant.hidden", defaultValue: "7 No backdrop"), - detail: String(localized: "debug.tabBarBackdropLab.variant.hidden.detail", defaultValue: "Control sample. Tabs remain visible below the buttons."), - effect: .init(style: .hidden, fadeWidth: 24, leadingOpacity: 0, trailingOpacity: 0, masksTabContent: false), - chromeHex: chromeHex, - paneHex: paneHex, - borderHex: borderHex, - opacity: opacityValue - ), - variant( - id: "precompositedPane", - title: String(localized: "debug.tabBarBackdropLab.variant.precompositedPane", defaultValue: "0 Opaque pane composite"), - detail: String(localized: "debug.tabBarBackdropLab.variant.precompositedPane.detail", defaultValue: "Old candidate. Covers too hard."), - effect: .init(style: .precompositedPaneBackground, fadeWidth: 24, leadingOpacity: 0, trailingOpacity: 1.0, masksTabContent: false), - chromeHex: chromeHex, - paneHex: paneHex, - borderHex: borderHex, - opacity: opacityValue - ), - variant( - id: "opaquePane", - title: String(localized: "debug.tabBarBackdropLab.variant.opaquePane", defaultValue: "1 Raw pane opaque"), - detail: String(localized: "debug.tabBarBackdropLab.variant.opaquePane.detail", defaultValue: "Forces the pane fill to full opacity."), - effect: .init(style: .opaquePaneBackground, fadeWidth: 24, leadingOpacity: 0, trailingOpacity: 1.0, masksTabContent: false), - chromeHex: chromeHex, - paneHex: paneHex, - borderHex: borderHex, - opacity: opacityValue - ), - variant( - id: "opaqueBar", - title: String(localized: "debug.tabBarBackdropLab.variant.opaqueBar", defaultValue: "2 Raw bar opaque"), - detail: String(localized: "debug.tabBarBackdropLab.variant.opaqueBar.detail", defaultValue: "Uses the tab chrome color at full opacity."), - effect: .init(style: .opaqueBarBackground, fadeWidth: 24, leadingOpacity: 0, trailingOpacity: 1.0, masksTabContent: false), - chromeHex: chromeHex, - paneHex: paneHex, - borderHex: borderHex, - opacity: opacityValue - ), + GroupBox( + String( + localized: "debug.workspaceTabChrome.closeGlyph.sectionTitle", + defaultValue: "Close Glyph" + ) + ) { + VStack(alignment: .leading, spacing: 8) { + tuningSliderRow( + String( + localized: "debug.workspaceTabChrome.closeGlyph.xLabel", + defaultValue: "X" + ), + value: $closeGlyphDX + ) + tuningSliderRow( + String( + localized: "debug.workspaceTabChrome.closeGlyph.yLabel", + defaultValue: "Y" + ), + value: $closeGlyphDY + ) + + HStack(spacing: 10) { + Button( + String( + localized: "settings.sidebarAppearance.reset.button", + defaultValue: "Reset" + ) + ) { + closeGlyphDX = WorkspaceLayoutTabChromeDebugSettings.defaultCloseGlyphDX + closeGlyphDY = WorkspaceLayoutTabChromeDebugSettings.defaultCloseGlyphDY + applyLiveUpdate() + } + + Text( + String( + localized: "debug.workspaceTabChrome.closeGlyph.help", + defaultValue: "Updates live tabs and the debug previews." + ) + ) + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(.top, 2) + } + + GroupBox( + String( + localized: "debug.workspaceTabChrome.closeCircle.sectionTitle", + defaultValue: "Close Circle" + ) + ) { + VStack(alignment: .leading, spacing: 8) { + tuningSliderRow( + String( + localized: "debug.workspaceTabChrome.closeGlyph.xLabel", + defaultValue: "X" + ), + value: $closeCircleDX + ) + tuningSliderRow( + String( + localized: "debug.workspaceTabChrome.closeGlyph.yLabel", + defaultValue: "Y" + ), + value: $closeCircleDY + ) + tuningSliderRow( + String( + localized: "debug.workspaceTabChrome.closeCircle.sizeLabel", + defaultValue: "Size" + ), + value: $closeCircleSizeDelta, + range: WorkspaceLayoutTabChromeDebugSettings.closeCircleSizeDeltaRange, + clamp: WorkspaceLayoutTabChromeDebugSettings.clampedCircleSizeDelta + ) + + HStack(spacing: 10) { + Button( + String( + localized: "settings.sidebarAppearance.reset.button", + defaultValue: "Reset" + ) + ) { + closeCircleDX = WorkspaceLayoutTabChromeDebugSettings.defaultCloseCircleDX + closeCircleDY = WorkspaceLayoutTabChromeDebugSettings.defaultCloseCircleDY + closeCircleSizeDelta = WorkspaceLayoutTabChromeDebugSettings.defaultCloseCircleSizeDelta + applyLiveUpdate() + } + + Text( + String( + localized: "debug.workspaceTabChrome.closeGlyph.help", + defaultValue: "Updates live tabs and the debug previews." + ) + ) + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(.top, 2) + } + + GroupBox( + String( + localized: "debug.workspaceTabChrome.preview.sectionTitle", + defaultValue: "Interactive Preview" + ) + ) { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 14) { + Toggle( + String( + localized: "debug.workspaceTabChrome.preview.selected", + defaultValue: "Selected" + ), + isOn: $previewIsSelected + ) + .toggleStyle(.checkbox) + + Toggle( + String( + localized: "debug.workspaceTabChrome.preview.tabHover", + defaultValue: "Tab Hover" + ), + isOn: $previewIsHovered + ) + .toggleStyle(.checkbox) + + Toggle( + String( + localized: "debug.workspaceTabChrome.preview.closeHover", + defaultValue: "Close Hover" + ), + isOn: $previewCloseHovered + ) + .toggleStyle(.checkbox) + + Toggle( + String( + localized: "debug.workspaceTabChrome.preview.closePressed", + defaultValue: "Close Pressed" + ), + isOn: $previewClosePressed + ) + .toggleStyle(.checkbox) + } + + VStack(alignment: .leading, spacing: 6) { + Text("Live AppKit") + .font(.caption) + .foregroundColor(.secondary) + WorkspaceTabChromeLiveInteractivePreviewCanvas( + appearance: workspaceLayoutTabChromeDebugAppearance(), + isSelected: previewIsSelected, + isHovered: previewIsHovered, + isCloseHovered: previewCloseHovered, + isClosePressed: previewClosePressed + ) + } + + if let previewRenderedScenario { + WorkspaceTabChromeDebugScenarioSection( + rendered: previewRenderedScenario, + overlayOpacity: overlayOpacity + ) + } + + WorkspaceTabChromeCloseGlyphPreviewStrip( + appearance: workspaceLayoutTabChromeDebugAppearance(), + closeGlyphDX: closeGlyphDX, + closeGlyphDY: closeGlyphDY, + closeCircleDX: closeCircleDX, + closeCircleDY: closeCircleDY, + closeCircleSizeDelta: closeCircleSizeDelta + ) + } + .padding(.top, 2) + } + + ScrollView([.vertical, .horizontal]) { + VStack(alignment: .leading, spacing: 18) { + ForEach(renderedScenarios) { rendered in + WorkspaceTabChromeDebugScenarioSection( + rendered: rendered, + overlayOpacity: overlayOpacity + ) + } + } + .padding(.bottom, 8) + } + } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .onAppear { + if renderedScenarios.isEmpty { + renderAll() + } + applyLiveUpdate() + } + .onChange(of: closeGlyphDX) { _, _ in applyLiveUpdate() } + .onChange(of: closeGlyphDY) { _, _ in applyLiveUpdate() } + .onChange(of: closeCircleDX) { _, _ in applyLiveUpdate() } + .onChange(of: closeCircleDY) { _, _ in applyLiveUpdate() } + .onChange(of: closeCircleSizeDelta) { _, _ in applyLiveUpdate() } + .onChange(of: previewIsSelected) { _, _ in renderPreview() } + .onChange(of: previewIsHovered) { _, _ in renderPreview() } + .onChange(of: previewCloseHovered) { _, _ in renderPreview() } + .onChange(of: previewClosePressed) { _, _ in renderPreview() } + } + + private func renderAll() { + renderedScenarios = workspaceTabChromeDebugRenderedScenarios() + } + + private func renderPreview() { + previewRenderedScenario = workspaceTabChromeDebugRenderedScenario( + from: workspaceTabChromeDebugPreviewScenario( + isSelected: previewIsSelected, + isHovered: previewIsHovered, + isCloseHovered: previewCloseHovered, + isClosePressed: previewClosePressed + ) + ) + } + + private func tuningSliderRow( + _ label: String, + value: Binding, + range: ClosedRange = WorkspaceLayoutTabChromeDebugSettings.closeGlyphOffsetRange, + clamp: @escaping (Double) -> Double = WorkspaceLayoutTabChromeDebugSettings.clamped + ) -> some View { + let clampedValue = Binding( + get: { value.wrappedValue }, + set: { value.wrappedValue = clamp($0) } + ) + + return HStack(spacing: 8) { + Text(label) + Slider( + value: clampedValue, + in: range, + step: 0.01 + ) + TextField( + "", + value: clampedValue, + format: .number.precision(.fractionLength(2...3)) + ) + .font(.caption) + .monospacedDigit() + .multilineTextAlignment(.trailing) + .textFieldStyle(.roundedBorder) + .frame(width: 72) + } + } + + private func exportPNGs() { + let formatter = ISO8601DateFormatter() + let stamp = formatter.string(from: Date()).replacingOccurrences(of: ":", with: "-") + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-workspace-tab-chrome-\(stamp)", isDirectory: true) + do { + _ = try workspaceLayoutExportTabChromeDebugArtifacts(to: directory) + exportStatus = directory.path + NSWorkspace.shared.activateFileViewerSelecting([directory]) + } catch { + exportStatus = "Export failed: \(error.localizedDescription)" + } + } + + private func applyLiveUpdate() { + NotificationCenter.default.post(name: .workspaceTabChromeDebugSettingsDidChange, object: nil) + renderPreview() + renderAll() + } +} + +private struct WorkspaceTabChromeCloseGlyphPreviewStrip: View { + let appearance: WorkspaceLayoutConfiguration.Appearance + let closeGlyphDX: Double + let closeGlyphDY: Double + let closeCircleDX: Double + let closeCircleDY: Double + let closeCircleSizeDelta: Double + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text( + String( + localized: "debug.workspaceTabChrome.preview.closeState", + defaultValue: "Close Button" + ) + ) + .font(.caption) + .foregroundColor(.secondary) + + HStack(alignment: .top, spacing: 12) { + closeGlyphCell( + title: String( + localized: "debug.workspaceTabChrome.preview.closeState.idle", + defaultValue: "Idle" + ), + appearance: appearance, + isHovered: false, + isPressed: false + ) + closeGlyphCell( + title: String( + localized: "debug.workspaceTabChrome.preview.closeState.hover", + defaultValue: "Hover" + ), + appearance: appearance, + isHovered: true, + isPressed: false + ) + closeGlyphCell( + title: String( + localized: "debug.workspaceTabChrome.preview.closeState.pressed", + defaultValue: "Pressed" + ), + appearance: appearance, + isHovered: true, + isPressed: true + ) + } + } + } + + @ViewBuilder + private func closeGlyphCell( + title: String, + appearance: WorkspaceLayoutConfiguration.Appearance, + isHovered: Bool, + isPressed: Bool + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.caption2) + .foregroundColor(.secondary) + ZStack { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color(nsColor: TabBarColors.nsColorPaneBackground(for: appearance))) + VStack(spacing: 0) { + Rectangle() + .fill(Color.accentColor) + .frame(height: TabBarMetrics.activeIndicatorHeight) + Spacer(minLength: 0) + } + WorkspaceTabChromeCloseGlyphPreviewCanvas( + appearance: appearance, + isHovered: isHovered, + isPressed: isPressed, + closeGlyphDX: closeGlyphDX, + closeGlyphDY: closeGlyphDY, + closeCircleDX: closeCircleDX, + closeCircleDY: closeCircleDY, + closeCircleSizeDelta: closeCircleSizeDelta + ) + .frame(width: 72, height: 72) + } + .frame(width: 96, height: 96) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color.black.opacity(0.12), lineWidth: 1) + ) + } + } +} + +private struct WorkspaceTabChromeCloseGlyphPreviewCanvas: NSViewRepresentable { + let appearance: WorkspaceLayoutConfiguration.Appearance + let isHovered: Bool + let isPressed: Bool + let closeGlyphDX: Double + let closeGlyphDY: Double + let closeCircleDX: Double + let closeCircleDY: Double + let closeCircleSizeDelta: Double + + func makeNSView(context: Context) -> WorkspaceLayoutAccessoryDebugPreviewView { + WorkspaceLayoutAccessoryDebugPreviewView( + frame: CGRect(x: 0, y: 0, width: 72, height: 72), + symbolName: "xmark", + pointSize: TabBarMetrics.closeIconSize, + isHovered: isHovered, + isPressed: isPressed, + appearance: appearance + ) + } + + func updateNSView(_ nsView: WorkspaceLayoutAccessoryDebugPreviewView, context: Context) { + _ = closeGlyphDX + _ = closeGlyphDY + _ = closeCircleDX + _ = closeCircleDY + _ = closeCircleSizeDelta + nsView.update( + symbolName: "xmark", + pointSize: TabBarMetrics.closeIconSize, + isHovered: isHovered, + isPressed: isPressed, + appearance: appearance + ) + } +} + +private struct WorkspaceTabChromeLiveInteractivePreviewCanvas: NSViewRepresentable { + let appearance: WorkspaceLayoutConfiguration.Appearance + let isSelected: Bool + let isHovered: Bool + let isCloseHovered: Bool + let isClosePressed: Bool + + func makeNSView(context: Context) -> WorkspaceLayoutNativeTabButtonDebugPreviewHost { + let host = WorkspaceLayoutNativeTabButtonDebugPreviewHost(frame: .zero) + host.update( + title: "~/fun/cmuxterm-hq", + icon: "terminal.fill", + kind: .terminal, + appearance: appearance, + isSelected: isSelected, + isHovered: isHovered, + isCloseHovered: isCloseHovered, + isClosePressed: isClosePressed + ) + return host + } + + func updateNSView(_ nsView: WorkspaceLayoutNativeTabButtonDebugPreviewHost, context: Context) { + nsView.update( + title: "~/fun/cmuxterm-hq", + icon: "terminal.fill", + kind: .terminal, + appearance: appearance, + isSelected: isSelected, + isHovered: isHovered, + isCloseHovered: isCloseHovered, + isClosePressed: isClosePressed + ) + } +} + +private struct WorkspaceTabChromeDebugSnapshotView: View { + let renderedScenarios: [WorkspaceTabChromeDebugRenderedScenario] + let overlayOpacity: Double + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Workspace Tab Chrome") + .font(.headline) + Text("AppKit candidate vs. reference tab rendering across representative states.") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Text("Overlay \(String(format: "%.0f%%", overlayOpacity * 100))") + .font(.caption) + .foregroundColor(.secondary) + } + + if !renderedScenarios.isEmpty { + let perfectCount = renderedScenarios.filter { $0.metrics.matchingPixels }.count + Text("\(perfectCount)/\(renderedScenarios.count) states pixel-perfect") + .font(.caption) + .foregroundColor(perfectCount == renderedScenarios.count ? .green : .secondary) + } + + VStack(alignment: .leading, spacing: 18) { + ForEach(renderedScenarios) { rendered in + WorkspaceTabChromeDebugScenarioSection( + rendered: rendered, + overlayOpacity: overlayOpacity + ) + } + } + } + .padding(16) + .background(Color(nsColor: .windowBackgroundColor)) + } +} + +@MainActor +private func workspaceTabChromeDebugSnapshotImage(overlayOpacity: Double = 0.55) -> NSImage? { + let renderedScenarios = workspaceTabChromeDebugRenderedScenarios() + let hostingView = NSHostingView( + rootView: WorkspaceTabChromeDebugSnapshotView( + renderedScenarios: renderedScenarios, + overlayOpacity: overlayOpacity + ) + ) + let fittingSize = hostingView.fittingSize + guard fittingSize.width > 0, fittingSize.height > 0 else { + return nil + } + + hostingView.frame = NSRect(origin: .zero, size: fittingSize) + hostingView.layoutSubtreeIfNeeded() + + guard let bitmap = hostingView.bitmapImageRepForCachingDisplay(in: hostingView.bounds) else { + return nil + } + hostingView.cacheDisplay(in: hostingView.bounds, to: bitmap) + + let image = NSImage(size: fittingSize) + image.addRepresentation(bitmap) + return image +} + +@MainActor +func workspaceTabChromeDebugWriteSnapshot(to url: URL, overlayOpacity: Double = 0.55) throws { + guard let image = workspaceTabChromeDebugSnapshotImage(overlayOpacity: overlayOpacity), + let tiff = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff), + let pngData = bitmap.representation(using: .png, properties: [:]) else { + throw NSError( + domain: "cmux.workspaceTabChromeDebug", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to render workspace tab chrome debug snapshot."] + ) + } + + let directory = url.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try pngData.write(to: url) +} + +private struct WorkspaceTabChromeDebugScenarioSection: View { + let rendered: WorkspaceTabChromeDebugRenderedScenario + let overlayOpacity: Double + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Text(rendered.scenario.title) + .font(.headline) + Text(rendered.scenario.id) + .font(.caption) + .foregroundColor(.secondary) + Text(metricSummary) + .font(.caption) + .foregroundColor(rendered.metrics.matchingPixels ? .green : .secondary) + } + + HStack(alignment: .top, spacing: 14) { + chromeCell(title: "AppKit", image: rendered.appKitImage) + chromeCell(title: "Reference", image: rendered.referenceImage) + overlayCell + chromeCell(title: "Diff", image: rendered.diffImage) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + } + + private var metricSummary: String { + let metrics = rendered.metrics + return "diff \(metrics.differingPixelCount)/\(metrics.totalPixelCount), max \(metrics.maxChannelDelta), mean \(String(format: "%.2f", metrics.meanAbsoluteChannelDelta))" + } + + @ViewBuilder + private func chromeCell(title: String, image: NSImage) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.caption) + .foregroundColor(.secondary) + chromeCanvas { + Image(nsImage: image) + .interpolation(.none) + .antialiased(false) + } + } + } + + @ViewBuilder + private var overlayCell: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Overlay") + .font(.caption) + .foregroundColor(.secondary) + chromeCanvas { + ZStack { + Image(nsImage: rendered.referenceImage) + .interpolation(.none) + .antialiased(false) + Image(nsImage: rendered.appKitImage) + .interpolation(.none) + .antialiased(false) + .opacity(overlayOpacity) + } + } + } + } + + @ViewBuilder + private func chromeCanvas(@ViewBuilder content: () -> Content) -> some View { + let width = max(rendered.appKitImage.size.width, max(rendered.referenceImage.size.width, rendered.diffImage.size.width)) + let height = max(rendered.appKitImage.size.height, max(rendered.referenceImage.size.height, rendered.diffImage.size.height)) + ZStack { + Rectangle() + .fill(Color(nsColor: .windowBackgroundColor)) + content() + } + .frame(width: width, height: height) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color.black.opacity(0.12), lineWidth: 1) + ) + } +} + +// MARK: - Tab Bar Backdrop Lab Window + +private final class TabBarBackdropLabWindowController: NSWindowController, NSWindowDelegate { + static let shared = TabBarBackdropLabWindowController() + + private init() { + let window = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 1320, height: 880), + styleMask: [.titled, .closable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.title = String(localized: "debug.tabBarBackdropLab.title", defaultValue: "Tab Bar Backdrop Lab") + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.isMovableByWindowBackground = true + window.isReleasedWhenClosed = false + window.isOpaque = false + window.backgroundColor = .clear + window.collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary] + window.level = .floating + window.identifier = NSUserInterfaceItemIdentifier("cmux.tabBarBackdropLab") + window.center() + + let hostingView = NSHostingView(rootView: TabBarBackdropLabView()) + hostingView.wantsLayer = true + hostingView.layer?.backgroundColor = NSColor.clear.cgColor + window.contentView = hostingView + + super.init(window: window) + window.delegate = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + func show() { + window?.center() + window?.makeKeyAndOrderFront(nil) + window?.orderFrontRegardless() + } +} + +private struct TabBarBackdropLabVariant: Identifiable { + let id: String + let title: String + let detail: String + let effect: WorkspaceLayoutConfiguration.Appearance.SplitButtonBackdropEffect + let chromeHex: String + let tabBarHex: String + let splitButtonBackdropHex: String + let paneHex: String + let borderHex: String + let terminalColor: NSColor + let surfaceColor: NSColor + let separatorColor: NSColor + let opacity: CGFloat +} + +private struct TabBarBackdropLabView: View { + @State private var opacity: Double + @State private var sidebarWidth: Double = 74 + @State private var candidateSoftness: Double = Double(Workspace.splitButtonBackdropSoftness) + + init() { + let currentOpacity = Double(WindowAppearanceSnapshot.clampedOpacity(GhosttyApp.shared.defaultBackgroundOpacity)) + _opacity = State(initialValue: currentOpacity < 0.999 ? currentOpacity : 0.72) + } + + private var terminalColor: NSColor { + GhosttyApp.shared.defaultBackgroundColor.usingColorSpace(.sRGB) ?? NSColor(hex: "#646461") ?? .windowBackgroundColor + } + + private var surfaceColor: NSColor { + terminalColor.withAlphaComponent(CGFloat(opacity)) + } + + private var separatorColor: NSColor { + WindowChromeSeparatorColor.color(forChromeBackground: terminalColor) + } + + private var candidateBackdropEffect: WorkspaceLayoutConfiguration.Appearance.SplitButtonBackdropEffect { + let softness = CGFloat(min(max(0, candidateSoftness), 1)) + let productionSoftness = Workspace.splitButtonBackdropSoftness + let production = Workspace.splitButtonBackdropEffect() + func interpolate(strong: CGFloat, production: CGFloat, soft: CGFloat) -> CGFloat { + if softness <= productionSoftness { + let progress = productionSoftness <= 0 ? 1 : softness / productionSoftness + return strong + ((production - strong) * progress) + } + let progress = (softness - productionSoftness) / max(0.0001, 1 - productionSoftness) + return production + ((soft - production) * progress) + } + + return .init( + style: .translucentChrome, + fadeWidth: interpolate(strong: 20, production: production.fadeWidth, soft: 240), + contentFadeWidth: interpolate(strong: 0, production: production.contentFadeWidth, soft: 80), + solidWidth: interpolate(strong: 72, production: production.solidWidth, soft: 0), + fadeRampStartFraction: interpolate(strong: 0, production: production.fadeRampStartFraction, soft: 0.95), + leadingOpacity: production.leadingOpacity, + trailingOpacity: interpolate(strong: 1.0, production: production.trailingOpacity, soft: 0.25), + contentOcclusionFraction: interpolate(strong: 0, production: production.contentOcclusionFraction, soft: 1), + masksTabContent: true + ) + } + + private var variants: [TabBarBackdropLabVariant] { + let chromeHex = surfaceColor.hexString(includeAlpha: true) + let paneHex = "#00000000" + let borderHex = separatorColor.hexString(includeAlpha: true) + let opacityValue = CGFloat(opacity) + + return [ variant( - id: "precompositedBar", - title: String(localized: "debug.tabBarBackdropLab.variant.precompositedBar", defaultValue: "5 Opaque bar composite"), - detail: String(localized: "debug.tabBarBackdropLab.variant.precompositedBar.detail", defaultValue: "Composites tab chrome over the window background."), - effect: .init(style: .precompositedBarBackground, fadeWidth: 24, leadingOpacity: 0, trailingOpacity: 1.0, masksTabContent: false), + id: "candidate", + title: String(localized: "debug.tabBarBackdropLab.variant.candidate", defaultValue: "Candidate"), + detail: String(localized: "debug.tabBarBackdropLab.variant.candidate.detail", defaultValue: "Translucent chrome with tab occlusion."), + effect: candidateBackdropEffect, chromeHex: chromeHex, paneHex: paneHex, borderHex: borderHex, opacity: opacityValue ), variant( - id: "windowBackground", - title: String(localized: "debug.tabBarBackdropLab.variant.windowBackground", defaultValue: "3 Window background"), - detail: String(localized: "debug.tabBarBackdropLab.variant.windowBackground.detail", defaultValue: "Uses AppKit windowBackgroundColor."), - effect: .init(style: .windowBackground, fadeWidth: 24, leadingOpacity: 0, trailingOpacity: 1.0, masksTabContent: false), + id: "wideFade", + title: String(localized: "debug.tabBarBackdropLab.variant.candidateWideFade", defaultValue: "Wide fade"), + detail: String(localized: "debug.tabBarBackdropLab.variant.candidateWideFade.detail", defaultValue: "Same model with a softer edge."), + effect: .init( + style: .translucentChrome, + fadeWidth: 104, + contentFadeWidth: 46, + solidWidth: 10, + fadeRampStartFraction: 0.88, + leadingOpacity: 0, + trailingOpacity: 1.0, + masksTabContent: true + ), chromeHex: chromeHex, paneHex: paneHex, borderHex: borderHex, opacity: opacityValue ), variant( - id: "controlBackground", - title: String(localized: "debug.tabBarBackdropLab.variant.controlBackground", defaultValue: "4 Control background"), - detail: String(localized: "debug.tabBarBackdropLab.variant.controlBackground.detail", defaultValue: "Uses AppKit controlBackgroundColor."), - effect: .init(style: .controlBackground, fadeWidth: 24, leadingOpacity: 0, trailingOpacity: 1.0, masksTabContent: false), + id: "transparent", + title: String(localized: "debug.tabBarBackdropLab.variant.sharedBackdrop", defaultValue: "Shared backdrop"), + detail: String(localized: "debug.tabBarBackdropLab.variant.sharedBackdrop.detail", defaultValue: "Transparent local pane surfaces."), + effect: .init(style: .hidden, masksTabContent: false), chromeHex: chromeHex, + tabBarHex: "#00000000", + splitButtonBackdropHex: "#00000000", paneHex: paneHex, borderHex: borderHex, opacity: opacityValue @@ -3709,90 +4291,59 @@ private struct TabBarBackdropLabView: View { ] } - private var sampleWidthValue: CGFloat { - CGFloat(sampleWidth) - } - - private var gridColumns: [GridItem] { - [ - GridItem(.fixed(sampleWidthValue), spacing: 16, alignment: .top), - GridItem(.fixed(sampleWidthValue), spacing: 16, alignment: .top), - GridItem(.fixed(sampleWidthValue), spacing: 16, alignment: .top), - ] - } - - private var gridContentWidth: CGFloat { - sampleWidthValue * 3 + 32 - } - var body: some View { - VStack(alignment: .leading, spacing: 14) { - VStack(alignment: .leading, spacing: 12) { - VStack(alignment: .leading, spacing: 2) { - Text(String(localized: "debug.tabBarBackdropLab.title", defaultValue: "Tab Bar Backdrop Lab")) - .font(.headline) - Text(String(localized: "debug.tabBarBackdropLab.subtitle", defaultValue: "Live Bonsplit tab bars with overflow tabs under the split buttons. The window background is transparent.")) - .font(.caption) - .foregroundStyle(.secondary) - } + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text(String(localized: "debug.tabBarBackdropLab.title", defaultValue: "Tab Bar Backdrop Lab")) + .font(.title2.weight(.semibold)) + Text(String(localized: "debug.tabBarBackdropLab.subtitle", defaultValue: "Live WorkspaceSplit tab bars with overflow tabs under the split buttons. The window background is transparent.")) + .font(.caption) + .foregroundColor(.secondary) - HStack(alignment: .center, spacing: 18) { + HStack(spacing: 18) { labSlider( - title: String(localized: "debug.tabBarBackdropLab.opacity", defaultValue: "Surface opacity"), + title: String(localized: "debug.tabBarBackdropLab.opacity", defaultValue: "Opacity"), value: $opacity, - range: 0.2...1.0, - displayValue: "\(Int(opacity * 100))%", - width: 150 + range: 0.25...1, + displayValue: String(format: "%.2f", opacity), + width: 180 ) labSlider( - title: String(localized: "debug.tabBarBackdropLab.width", defaultValue: "Sample width"), - value: $sampleWidth, - range: 390...580, - displayValue: "\(Int(sampleWidth))", - width: 140 + title: String(localized: "debug.tabBarBackdropLab.sidebarWidth", defaultValue: "Sidebars"), + value: $sidebarWidth, + range: 48...140, + displayValue: "\(Int(sidebarWidth))", + width: 160 ) labSlider( - title: String(localized: "debug.tabBarBackdropLab.candidateSoftness", defaultValue: "Candidate softness"), + title: String(localized: "debug.tabBarBackdropLab.softness", defaultValue: "Softness"), value: $candidateSoftness, range: 0...1, - displayValue: "\(Int(candidateSoftness * 100))%", - width: 180 + displayValue: String(format: "%.2f", candidateSoftness), + width: 160 ) } - } - .padding(12) - .background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - ScrollView { - LazyVGrid( - columns: gridColumns, - alignment: .leading, - spacing: 16 - ) { + LazyVStack(alignment: .leading, spacing: 18) { ForEach(variants) { variant in TabBarBackdropLabSample( variant: variant, sidebarWidth: CGFloat(sidebarWidth) ) - .id(variant.renderIdentity) - .frame(width: sampleWidthValue, alignment: .topLeading) } } - .frame(minWidth: gridContentWidth, alignment: .leading) - .padding(.bottom, 24) } - .scrollIndicators(.visible) + .padding(18) } - .padding(18) .background(Color.clear) - .frame(minWidth: 1320, minHeight: 820) + .frame(minWidth: 1120, minHeight: 720) } private func variant( id: String, title: String, detail: String, - effect: BonsplitConfiguration.Appearance.SplitButtonBackdropEffect, + effect: WorkspaceLayoutConfiguration.Appearance.SplitButtonBackdropEffect, chromeHex: String, tabBarHex: String? = nil, splitButtonBackdropHex: String? = nil, @@ -3837,12 +4388,29 @@ private struct TabBarBackdropLabView: View { private struct TabBarBackdropLabSample: View { let variant: TabBarBackdropLabVariant let sidebarWidth: CGFloat - @State private var controller: BonsplitController - init(variant: TabBarBackdropLabVariant, sidebarWidth: CGFloat) { - self.variant = variant - self.sidebarWidth = sidebarWidth - _controller = State(initialValue: Self.makeController(for: variant)) + private var appearance: WorkspaceLayoutConfiguration.Appearance { + WorkspaceLayoutConfiguration.Appearance( + tabBarHeight: 33, + tabMinWidth: 138, + tabMaxWidth: 210, + tabTitleFontSize: 11, + minimumPaneWidth: 120, + minimumPaneHeight: 80, + showSplitButtons: true, + splitButtonsOnHover: false, + splitButtonBackdropEffect: variant.effect, + animationDuration: 0, + enableAnimations: false, + chromeColors: .init( + backgroundHex: variant.chromeHex, + tabBarBackgroundHex: variant.tabBarHex, + splitButtonBackdropHex: variant.splitButtonBackdropHex, + paneBackgroundHex: variant.paneHex, + borderHex: variant.borderHex + ), + usesSharedBackdrop: variant.tabBarHex == "#00000000" + ) } var body: some View { @@ -3850,10 +4418,9 @@ private struct TabBarBackdropLabSample: View { HStack(alignment: .firstTextBaseline, spacing: 8) { Text(variant.title) .font(.caption.weight(.semibold)) - .foregroundStyle(.primary) Text(variant.detail) .font(.caption2) - .foregroundStyle(.secondary) + .foregroundColor(.secondary) .lineLimit(1) } @@ -3873,14 +4440,14 @@ private struct TabBarBackdropLabSample: View { ) .frame(height: 24) - BonsplitView(controller: controller) { tab, _ in + VStack(spacing: 0) { + TabBarBackdropLabTabStrip(appearance: appearance) + .frame(height: appearance.tabBarHeight) TabBarBackdropLabTerminalPane( - title: tab.title, + title: String(localized: "debug.tabBarBackdropLab.tab.backdropCheck", defaultValue: "split button backdrop check"), color: variant.terminalColor, opacity: variant.opacity ) - } emptyPane: { _ in - Color.clear } } .frame(height: 132) @@ -3901,79 +4468,66 @@ private struct TabBarBackdropLabSample: View { } } .frame(maxWidth: .infinity, alignment: .topLeading) - .onAppear { - applyVariant() - } - .onChange(of: variant.renderIdentity) { _, _ in - applyVariant() - } - } - - private func applyVariant() { - controller.configuration = Self.makeConfiguration(for: variant) } +} - private static func makeAppearance(for variant: TabBarBackdropLabVariant) -> BonsplitConfiguration.Appearance { - BonsplitConfiguration.Appearance( - tabBarHeight: 33, - tabMinWidth: 138, - tabMaxWidth: 210, - tabTitleFontSize: 11, - tabSpacing: 0, - minimumPaneWidth: 120, - minimumPaneHeight: 80, - showSplitButtons: true, - splitButtons: BonsplitConfiguration.SplitActionButton.defaults, - splitButtonsOnHover: false, - splitButtonBackdropEffect: variant.effect, - animationDuration: 0.0, - enableAnimations: false, - chromeColors: .init( - backgroundHex: variant.chromeHex, - tabBarBackgroundHex: variant.tabBarHex, - splitButtonBackdropHex: variant.splitButtonBackdropHex, - paneBackgroundHex: variant.paneHex, - borderHex: variant.borderHex - ) - ) - } +private struct TabBarBackdropLabTabStrip: View { + let appearance: WorkspaceLayoutConfiguration.Appearance - private static func makeConfiguration(for variant: TabBarBackdropLabVariant) -> BonsplitConfiguration { - BonsplitConfiguration( - allowSplits: true, - allowCloseTabs: true, - allowTabReordering: false, - allowCrossPaneTabMove: false, - autoCloseEmptyPanes: false, - contentViewLifecycle: .recreateOnSwitch, - newTabPosition: .end, - appearance: makeAppearance(for: variant) - ) - } + private let titles = [ + String(localized: "debug.tabBarBackdropLab.tab.agentBrowserLogs", defaultValue: "agent-browser logs"), + String(localized: "debug.tabBarBackdropLab.tab.terminalTransparency", defaultValue: "cmux terminal transparency"), + String(localized: "debug.tabBarBackdropLab.tab.underlayText", defaultValue: "underlay tab text visible here"), + String(localized: "debug.tabBarBackdropLab.tab.rightEdgeOverflow", defaultValue: "right edge overflow sample"), + String(localized: "debug.tabBarBackdropLab.tab.hiddenBelowControls", defaultValue: "tabs hidden below controls") + ] - private static func makeController(for variant: TabBarBackdropLabVariant) -> BonsplitController { - let controller = BonsplitController(configuration: makeConfiguration(for: variant)) + var body: some View { + ZStack(alignment: .trailing) { + HStack(spacing: appearance.tabSpacing) { + ForEach(Array(titles.enumerated()), id: \.offset) { index, title in + Text(title) + .font(.system(size: appearance.tabTitleFontSize)) + .lineLimit(1) + .truncationMode(.tail) + .foregroundColor(TabBarColors.activeText(for: appearance)) + .padding(.horizontal, 10) + .frame(width: index == 0 ? 170 : 200, height: max(24, appearance.tabBarHeight - 7), alignment: .leading) + .background(index == 0 ? TabBarColors.activeTabBackground(for: appearance) : TabBarColors.hoveredTabBackground(for: appearance)) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + } + Spacer(minLength: 0) + } + .padding(.leading, 8) + .padding(.trailing, 88) - let titles = [ - String(localized: "debug.tabBarBackdropLab.tab.agentBrowserLogs", defaultValue: "agent-browser logs"), - String(localized: "debug.tabBarBackdropLab.tab.terminalTransparency", defaultValue: "cmux terminal transparency"), - String(localized: "debug.tabBarBackdropLab.tab.underlayText", defaultValue: "underlay tab text visible here"), - String(localized: "debug.tabBarBackdropLab.tab.backdropCheck", defaultValue: "split button backdrop check"), - String(localized: "debug.tabBarBackdropLab.tab.rightEdgeOverflow", defaultValue: "right edge overflow sample"), - String(localized: "debug.tabBarBackdropLab.tab.hiddenBelowControls", defaultValue: "tabs hidden below controls") - ] - let tabs = titles.enumerated().compactMap { index, title in - controller.createTab( - title: title, - icon: index == 0 ? "terminal" : "doc.text", - isDirty: index == 2, - showsNotificationBadge: index == 4 + HStack(spacing: 4) { + Image(systemName: "plus") + Image(systemName: "rectangle.split.2x1") + Image(systemName: "rectangle.split.1x2") + } + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(TabBarColors.splitActionIcon(for: appearance, isPressed: false)) + .padding(.horizontal, 9) + .frame(height: appearance.tabBarHeight) + .background( + LinearGradient( + stops: [ + .init(color: Color(nsColor: TabBarColors.nsColorSplitButtonBackdropSurface(for: appearance)).opacity(0), location: 0), + .init(color: Color(nsColor: TabBarColors.nsColorSplitButtonBackdropSurface(for: appearance)), location: 0.65), + .init(color: Color(nsColor: TabBarColors.nsColorSplitButtonBackdropSurface(for: appearance)), location: 1), + ], + startPoint: .leading, + endPoint: .trailing + ) ) } - if let selected = tabs.dropFirst(4).first ?? tabs.first { - controller.selectTab(selected) + .background(TabBarColors.barBackground(for: appearance)) + .overlay(alignment: .bottom) { + Rectangle() + .fill(TabBarColors.separator(for: appearance)) + .frame(height: 1) } - return controller } } @@ -4040,11 +4594,11 @@ private struct TabBarBackdropLabTerminalPane: View { Color(nsColor: color.withAlphaComponent(opacity)) VStack(alignment: .leading, spacing: 4) { Text("\(String(localized: "debug.tabBarBackdropLab.terminal.prompt", defaultValue: "lawrence in ~/cmux")) \(title)") - .foregroundStyle(Color.green) + .foregroundColor(.green) Text(String(localized: "debug.tabBarBackdropLab.terminal.overflow", defaultValue: "tab titles intentionally overflow under the split buttons")) - .foregroundStyle(Color.white.opacity(0.78)) + .foregroundColor(.white.opacity(0.78)) Text(String(localized: "debug.tabBarBackdropLab.terminal.compare", defaultValue: "drag / resize / compare the transparent edges")) - .foregroundStyle(Color.white.opacity(0.52)) + .foregroundColor(.white.opacity(0.52)) Spacer(minLength: 0) } .font(.system(size: 11, design: .monospaced)) @@ -4083,408 +4637,123 @@ private final class BackgroundDebugWindowController: NSWindowController, NSWindo fatalError("init(coder:) has not been implemented") } - func show() { - window?.center() - window?.makeKeyAndOrderFront(nil) - } -} - -private struct BackgroundDebugView: View { - @AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000" - @AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.03 - @AppStorage("bgGlassMaterial") private var bgGlassMaterial = "hudWindow" - @AppStorage("bgGlassEnabled") private var bgGlassEnabled = false - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 14) { - Text("Window Background Glass") - .font(.headline) - - GroupBox("Glass Effect") { - VStack(alignment: .leading, spacing: 8) { - Toggle("Enable Glass Effect", isOn: $bgGlassEnabled) - - Picker("Material", selection: $bgGlassMaterial) { - Text("HUD Window").tag("hudWindow") - Text("Under Window").tag("underWindowBackground") - Text("Sidebar").tag("sidebar") - Text("Menu").tag("menu") - Text("Popover").tag("popover") - } - .disabled(!bgGlassEnabled) - } - .padding(.top, 2) - } - - GroupBox("Tint") { - VStack(alignment: .leading, spacing: 8) { - ColorPicker("Tint Color", selection: tintColorBinding, supportsOpacity: false) - .disabled(!bgGlassEnabled) - - HStack(spacing: 8) { - Text("Opacity") - Slider(value: $bgGlassTintOpacity, in: 0...0.8) - .disabled(!bgGlassEnabled) - Text(String(format: "%.0f%%", bgGlassTintOpacity * 100)) - .font(.caption) - .frame(width: 44, alignment: .trailing) - } - } - .padding(.top, 2) - } - - HStack(spacing: 12) { - Button("Reset") { - bgGlassTintHex = "#000000" - bgGlassTintOpacity = 0.03 - bgGlassMaterial = "hudWindow" - bgGlassEnabled = false - updateWindowGlassTint() - } - - Button("Copy Config") { - copyBgConfig() - } - } - - Text("Tint changes apply live. Enable/disable requires reload.") - .font(.caption) - .foregroundColor(.secondary) - - Spacer(minLength: 0) - } - .padding(16) - .frame(maxWidth: .infinity, alignment: .topLeading) - } - .onChange(of: bgGlassTintHex) { _ in updateWindowGlassTint() } - .onChange(of: bgGlassTintOpacity) { _ in updateWindowGlassTint() } - } - - private func updateWindowGlassTint() { - let window: NSWindow? = { - if let key = NSApp.keyWindow, - let raw = key.identifier?.rawValue, - raw == "cmux.main" || raw.hasPrefix("cmux.main.") { - return key - } - return NSApp.windows.first(where: { - guard let raw = $0.identifier?.rawValue else { return false } - return raw == "cmux.main" || raw.hasPrefix("cmux.main.") - }) - }() - guard let window else { return } - let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity) - WindowGlassEffect.updateTint(to: window, color: tintColor) - } - - private var tintColorBinding: Binding { - Binding( - get: { - Color(nsColor: NSColor(hex: bgGlassTintHex) ?? .black) - }, - set: { newColor in - let nsColor = NSColor(newColor) - bgGlassTintHex = nsColor.hexString() - } - ) - } - - private func copyBgConfig() { - let payload = """ - bgGlassEnabled=\(bgGlassEnabled) - bgGlassMaterial=\(bgGlassMaterial) - bgGlassTintHex=\(bgGlassTintHex) - bgGlassTintOpacity=\(String(format: "%.2f", bgGlassTintOpacity)) - """ - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(payload, forType: .string) - } -} - -private final class StartupAppearanceDebugWindowController: NSWindowController, NSWindowDelegate { - static let shared = StartupAppearanceDebugWindowController() - - private init() { - let window = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 460, height: 500), - styleMask: [.titled, .closable, .utilityWindow], - backing: .buffered, - defer: false - ) - window.title = String( - localized: "debug.startupAppearance.window.title", - defaultValue: "Startup Appearance Debug" - ) - window.titleVisibility = .visible - window.titlebarAppearsTransparent = false - window.isMovableByWindowBackground = true - window.isReleasedWhenClosed = false - window.identifier = NSUserInterfaceItemIdentifier("cmux.startupAppearanceDebug") - window.center() - window.contentView = NSHostingView(rootView: StartupAppearanceDebugView()) - AppDelegate.shared?.applyWindowDecorations(to: window) - super.init(window: window) - window.delegate = self - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func show() { - window?.center() - window?.makeKeyAndOrderFront(nil) - } -} - -private enum StartupAppearancePreviewMode: String, CaseIterable, Identifiable { - case stored - case light - case dark - - var id: String { rawValue } - - var displayName: String { - switch self { - case .stored: - return String( - localized: "debug.startupAppearance.mode.stored", - defaultValue: "Stored App Setting" - ) - case .light: - return String( - localized: "debug.startupAppearance.mode.light", - defaultValue: "Force Light" - ) - case .dark: - return String( - localized: "debug.startupAppearance.mode.dark", - defaultValue: "Force Dark" - ) - } - } -} - -private struct StartupAppearanceDebugView: View { - @State private var selectedProfile = GhosttyStartupAppearancePreviewState.profile - @State private var selectedAppearance = StartupAppearancePreviewMode.stored - @State private var lastAppliedProfile = GhosttyStartupAppearancePreviewState.profile - @State private var lastAppliedAppearance = StartupAppearancePreviewMode.stored - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 14) { - Text( - String( - localized: "debug.startupAppearance.window.title", - defaultValue: "Startup Appearance Debug" - ) - ) - .font(.headline) - - GroupBox( - String( - localized: "debug.startupAppearance.preview.heading", - defaultValue: "Preview" - ) - ) { - VStack(alignment: .leading, spacing: 10) { - Picker( - String( - localized: "debug.startupAppearance.startupConfig.label", - defaultValue: "Startup config" - ), - selection: $selectedProfile - ) { - ForEach(GhosttyStartupAppearancePreviewProfile.allCases) { profile in - Text(profile.displayName).tag(profile) - } - } - .pickerStyle(.menu) - - Text(selectedProfile.detail) - .font(.caption) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) + func show() { + window?.center() + window?.makeKeyAndOrderFront(nil) + } +} - Picker( - String( - localized: "debug.startupAppearance.appearance.label", - defaultValue: "Appearance" - ), - selection: $selectedAppearance - ) { - ForEach(StartupAppearancePreviewMode.allCases) { mode in - Text(mode.displayName).tag(mode) - } - } - .pickerStyle(.segmented) +private struct BackgroundDebugView: View { + @AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000" + @AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.03 + @AppStorage("bgGlassMaterial") private var bgGlassMaterial = "hudWindow" + @AppStorage("bgGlassEnabled") private var bgGlassEnabled = false - HStack(spacing: 12) { - Button( - String( - localized: "debug.startupAppearance.applyPreview.button", - defaultValue: "Apply Preview" - ) - ) { - applyPreview() - } - .keyboardShortcut(.defaultAction) + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text("Window Background Glass") + .font(.headline) - Button( - String( - localized: "debug.startupAppearance.restoreRealStartup.button", - defaultValue: "Restore Real Startup" - ) - ) { - restoreRealStartup() - } + GroupBox("Glass Effect") { + VStack(alignment: .leading, spacing: 8) { + Toggle("Enable Glass Effect", isOn: $bgGlassEnabled) + + Picker("Material", selection: $bgGlassMaterial) { + Text("HUD Window").tag("hudWindow") + Text("Under Window").tag("underWindowBackground") + Text("Sidebar").tag("sidebar") + Text("Menu").tag("menu") + Text("Popover").tag("popover") } + .disabled(!bgGlassEnabled) } .padding(.top, 2) } - GroupBox( - String( - localized: "debug.startupAppearance.selectedConfig.heading", - defaultValue: "Selected Config" - ) - ) { + GroupBox("Tint") { VStack(alignment: .leading, spacing: 8) { - ScrollView { - Text(selectedConfigText) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .topLeading) - .padding(8) - } - .frame(minHeight: 92, maxHeight: 150) - .background(Color(nsColor: .textBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + ColorPicker("Tint Color", selection: tintColorBinding, supportsOpacity: false) + .disabled(!bgGlassEnabled) - Button( - String( - localized: "debug.startupAppearance.copySelectedConfig.button", - defaultValue: "Copy Selected Config" - ) - ) { - copySelectedConfig() + HStack(spacing: 8) { + Text("Opacity") + Slider(value: $bgGlassTintOpacity, in: 0...0.8) + .disabled(!bgGlassEnabled) + Text(String(format: "%.0f%%", bgGlassTintOpacity * 100)) + .font(.caption) + .frame(width: 44, alignment: .trailing) } - .disabled(selectedPreviewConfigText == nil) } .padding(.top, 2) } - GroupBox( - String( - localized: "debug.startupAppearance.applied.heading", - defaultValue: "Applied" - ) - ) { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 4) { - Text( - String( - localized: "debug.startupAppearance.applied.configLabel", - defaultValue: "Config:" - ) - ) - Text(lastAppliedProfile.displayName) - } - HStack(spacing: 4) { - Text( - String( - localized: "debug.startupAppearance.applied.appearanceLabel", - defaultValue: "Appearance:" - ) - ) - Text(lastAppliedAppearance.displayName) - } - Text( - String( - localized: "debug.startupAppearance.applied.help", - defaultValue: "Reloads the running app through Ghostty config update, matching startup theme resolution without editing config files." - ) - ) - .font(.caption) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 12) { + Button("Reset") { + bgGlassTintHex = "#000000" + bgGlassTintOpacity = 0.03 + bgGlassMaterial = "hudWindow" + bgGlassEnabled = false + updateWindowGlassTint() + } + + Button("Copy Config") { + copyBgConfig() } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 2) } + Text("Tint changes apply live. Enable/disable requires reload.") + .font(.caption) + .foregroundColor(.secondary) + Spacer(minLength: 0) } .padding(16) .frame(maxWidth: .infinity, alignment: .topLeading) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } - - private var selectedPreviewConfigText: String? { - selectedProfile.previewConfigContents() - } - - private var selectedConfigText: String { - selectedPreviewConfigText ?? String( - localized: "debug.startupAppearance.realConfigFallback", - defaultValue: "Loads real user config files." - ) + .onChange(of: bgGlassTintHex) { _ in updateWindowGlassTint() } + .onChange(of: bgGlassTintOpacity) { _ in updateWindowGlassTint() } } - private func applyPreview() { - applyAppearance(selectedAppearance) - GhosttyStartupAppearancePreviewState.profile = selectedProfile - GhosttyConfig.invalidateLoadCache() - GhosttyApp.shared.reloadConfiguration( - source: "debug.startupAppearancePreview", - reloadSettingsFromFile: false - ) - lastAppliedProfile = selectedProfile - lastAppliedAppearance = selectedAppearance - } - - private func restoreRealStartup() { - selectedProfile = .realUserConfig - selectedAppearance = .stored - applyAppearance(.stored) - GhosttyStartupAppearancePreviewState.profile = .realUserConfig - GhosttyConfig.invalidateLoadCache() - GhosttyApp.shared.reloadConfiguration( - source: "debug.startupAppearanceRestore", - reloadSettingsFromFile: false - ) - lastAppliedProfile = .realUserConfig - lastAppliedAppearance = .stored + private func updateWindowGlassTint() { + let window: NSWindow? = { + if let key = NSApp.keyWindow, + let raw = key.identifier?.rawValue, + raw == "cmux.main" || raw.hasPrefix("cmux.main.") { + return key + } + return NSApp.windows.first(where: { + guard let raw = $0.identifier?.rawValue else { return false } + return raw == "cmux.main" || raw.hasPrefix("cmux.main.") + }) + }() + guard let window else { return } + let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity) + WindowGlassEffect.updateTint(to: window, color: tintColor) } - private func applyAppearance(_ mode: StartupAppearancePreviewMode) { - switch mode { - case .stored: - switch AppearanceSettings.resolvedMode() { - case .system, .auto: - NSApplication.shared.appearance = nil - case .light: - NSApplication.shared.appearance = NSAppearance(named: .aqua) - case .dark: - NSApplication.shared.appearance = NSAppearance(named: .darkAqua) + private var tintColorBinding: Binding { + Binding( + get: { + Color(nsColor: NSColor(hex: bgGlassTintHex) ?? .black) + }, + set: { newColor in + let nsColor = NSColor(newColor) + bgGlassTintHex = nsColor.hexString() } - case .light: - NSApplication.shared.appearance = NSAppearance(named: .aqua) - case .dark: - NSApplication.shared.appearance = NSAppearance(named: .darkAqua) - } + ) } - private func copySelectedConfig() { - guard let config = selectedPreviewConfigText else { return } + private func copyBgConfig() { + let payload = """ + bgGlassEnabled=\(bgGlassEnabled) + bgGlassMaterial=\(bgGlassMaterial) + bgGlassTintHex=\(bgGlassTintHex) + bgGlassTintOpacity=\(String(format: "%.2f", bgGlassTintOpacity)) + """ let pasteboard = NSPasteboard.general pasteboard.clearContents() - pasteboard.setString(config, forType: .string) + pasteboard.setString(payload, forType: .string) } } @@ -5276,7 +5545,6 @@ struct SettingsView: View { private let pickerColumnWidth: CGFloat = 196 private let notificationSoundControlWidth: CGFloat = 280 private let shortcutChordsDocsURL = URL(string: "https://cmux.com/docs/keyboard-shortcuts#shortcut-chords")! - @Environment(\.openWindow) private var openWindow @AppStorage(LanguageSettings.languageKey) private var appLanguage = LanguageSettings.defaultLanguage.rawValue @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue @@ -5353,8 +5621,6 @@ struct SettingsView: View { private var openSidebarPortLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPortLinksInCmuxBrowser @AppStorage(ShortcutHintDebugSettings.showHintsOnCommandHoldKey) private var showShortcutHintsOnCommandHold = ShortcutHintDebugSettings.defaultShowHintsOnCommandHold - @AppStorage(ShortcutHintDebugSettings.showHintsOnControlHoldKey) - private var showShortcutHintsOnControlHold = ShortcutHintDebugSettings.defaultShowHintsOnControlHold @AppStorage("sidebarShowSSH") private var sidebarShowSSH = true @AppStorage("sidebarShowPorts") private var sidebarShowPorts = true @AppStorage("sidebarShowLog") private var sidebarShowLog = true @@ -5367,7 +5633,6 @@ struct SettingsView: View { @AppStorage("sidebarMatchTerminalBackground") private var sidebarMatchTerminalBackground = false @ObservedObject private var notificationStore = TerminalNotificationStore.shared - @ObservedObject private var authManager = AuthManager.shared @StateObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared @State private var shortcutResetToken = UUID() @State private var topBlurOpacity: Double = 0 @@ -5390,6 +5655,7 @@ struct SettingsView: View { @State private var showLanguageRestartAlert = false @State private var isResettingSettings = false @State private var workspaceTabPaletteEntries = WorkspaceTabColorSettings.palette() + @State private var trustedDirectoriesDraft: String = CmuxDirectoryTrust.shared.allTrustedPaths.joined(separator: "\n") private var selectedWorkspacePlacement: NewWorkspacePlacement { NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement @@ -5652,6 +5918,14 @@ struct SettingsView: View { browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist } + private func saveTrustedDirectories() { + let paths = trustedDirectoriesDraft + .split(separator: "\n", omittingEmptySubsequences: true) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + CmuxDirectoryTrust.shared.replaceAll(with: paths) + } + private var hasCustomNotificationSoundFilePath: Bool { !notificationSoundCustomFilePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @@ -5842,7 +6116,7 @@ struct SettingsView: View { private func handleNotificationPermissionAction() { let state = notificationStore.authorizationState.statusLabel #if DEBUG - cmuxDebugLog("notification.ui enableTapped state=\(state)") + dlog("notification.ui enableTapped state=\(state)") #endif NSLog("notification.ui enableTapped state=%@", state) switch notificationStore.authorizationState { @@ -5891,11 +6165,6 @@ struct SettingsView: View { ZStack(alignment: .top) { ScrollView { VStack(alignment: .leading, spacing: 14) { - SettingsSectionHeader(title: String(localized: "settings.section.account", defaultValue: "Account")) - SettingsCard { - AuthSettingsRow(authManager: authManager) - } - SettingsSectionHeader(title: String(localized: "settings.section.app", defaultValue: "App")) SettingsCard { SettingsCardRow( @@ -6023,23 +6292,6 @@ struct SettingsView: View { SettingsCardDivider() - SettingsCardRow( - configurationReview: .action, - String(localized: "settings.app.configWindow", defaultValue: "Terminal Config"), - subtitle: String( - localized: "settings.app.configWindow.subtitle", - defaultValue: "Open the cmux config, standalone Ghostty config, and merged preview in one utility window." - ) - ) { - Button(String(localized: "settings.app.configWindow.openButton", defaultValue: "Open Config Window")) { - openWindow(id: ConfigSettingsView.windowID) - } - .buttonStyle(.bordered) - .controlSize(.small) - } - - SettingsCardDivider() - SettingsCardRow( configurationReview: .json("app.openMarkdownInCmuxViewer"), String(localized: "settings.app.openMarkdownInCmuxViewer", defaultValue: "Open Markdown in cmux Viewer"), @@ -6889,6 +7141,39 @@ struct SettingsView: View { SettingsCardNote(String(localized: "settings.automation.port.note", defaultValue: "Each workspace gets CMUX_PORT and CMUX_PORT_END env vars with a dedicated port range. New terminals inherit these values.")) } + SettingsSectionHeader(title: String(localized: "settings.section.customCommands", defaultValue: "Custom Commands")) + SettingsCard { + VStack(alignment: .leading, spacing: 6) { + SettingsCardRow( + configurationReview: .json("customCommands.trustedDirectories"), + String(localized: "settings.customCommands.trustedDirectories", defaultValue: "Trusted Directories"), + subtitle: String(localized: "settings.customCommands.trustedDirectories.subtitle", defaultValue: "Commands from cmux.json in these directories run without confirmation. One path per line.") + ) { + EmptyView() + } + + TextEditor(text: $trustedDirectoriesDraft) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 60, maxHeight: 120) + .scrollContentBackground(.hidden) + .padding(6) + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + .padding(.horizontal, 16) + .padding(.bottom, 12) + .onChange(of: trustedDirectoriesDraft) { _ in + saveTrustedDirectories() + } + } + + SettingsCardDivider() + SettingsCardNote(String(localized: "settings.customCommands.trustedDirectories.note", defaultValue: "Place a cmux.json in your project root to define custom commands. Trust a directory from the confirmation dialog, or add paths here. For git repos, trusting the root covers all subdirectories.")) + } + SettingsSectionHeader(title: String(localized: "settings.section.browser", defaultValue: "Browser")) .id(SettingsNavigationTarget.browser) .accessibilityIdentifier("SettingsBrowserSection") @@ -7196,20 +7481,11 @@ struct SettingsView: View { SettingsCardRow( configurationReview: .json("shortcuts.showModifierHoldHints"), String(localized: "settings.shortcuts.showHints", defaultValue: "Show Cmd/Ctrl-Hold Shortcut Hints"), - subtitle: (showShortcutHintsOnCommandHold || showShortcutHintsOnControlHold) + subtitle: showShortcutHintsOnCommandHold ? String(localized: "settings.shortcuts.showHints.subtitleOn", defaultValue: "Holding Cmd (sidebar/titlebar) or Ctrl/Cmd (pane tabs) shows shortcut hint pills.") : String(localized: "settings.shortcuts.showHints.subtitleOff", defaultValue: "Holding Cmd or Ctrl keeps shortcut hint pills hidden.") ) { - Toggle( - "", - isOn: Binding( - get: { showShortcutHintsOnCommandHold || showShortcutHintsOnControlHold }, - set: { newValue in - showShortcutHintsOnCommandHold = newValue - showShortcutHintsOnControlHold = newValue - } - ) - ) + Toggle("", isOn: $showShortcutHintsOnCommandHold) .labelsHidden() .controlSize(.small) } @@ -7515,7 +7791,6 @@ struct SettingsView: View { openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser openSidebarPortLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPortLinksInCmuxBrowser showShortcutHintsOnCommandHold = ShortcutHintDebugSettings.defaultShowHintsOnCommandHold - showShortcutHintsOnControlHold = ShortcutHintDebugSettings.defaultShowHintsOnControlHold sidebarShowSSH = true sidebarShowPorts = true sidebarShowLog = true @@ -7626,84 +7901,6 @@ private struct SettingsSectionHeader: View { } } -private struct AuthSettingsRow: View { - @ObservedObject var authManager: AuthManager - - var body: some View { - HStack(alignment: .center, spacing: 12) { - VStack(alignment: .leading, spacing: 2) { - Text(titleText) - .font(.system(size: 13, weight: .medium)) - if let subtitle = subtitleText { - Text(subtitle) - .font(.system(size: 11)) - .foregroundColor(.secondary) - } - } - Spacer(minLength: 12) - if authManager.isLoading || authManager.isRestoringSession { - ProgressView().controlSize(.small) - } - Button(action: buttonAction) { - Text(buttonTitle) - } - .controlSize(.small) - .disabled(authManager.isLoading || authManager.isRestoringSession) - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - } - - private var titleText: String { - if authManager.isAuthenticated { - if let email = authManager.currentUser?.primaryEmail, !email.isEmpty { - return email - } - return String( - localized: "settings.account.signedIn.title", - defaultValue: "Signed in" - ) - } - return String( - localized: "settings.account.signedOut.title", - defaultValue: "Not signed in" - ) - } - - private var subtitleText: String? { - if authManager.isAuthenticated { - return authManager.currentUser?.displayName - } - return String( - localized: "settings.account.signedOut.subtitle", - defaultValue: "Sign in with your cmux account to enable sync across devices." - ) - } - - private var buttonTitle: String { - if authManager.isAuthenticated { - return String( - localized: "settings.account.signOut", - defaultValue: "Sign Out" - ) - } - return String( - localized: "settings.account.signIn", - defaultValue: "Sign In…" - ) - } - - private func buttonAction() { - if authManager.isAuthenticated { - Task { @MainActor in - await authManager.signOut() - } - } else { - authManager.beginSignIn() - } - } -} - private struct SettingsCard: View { @ViewBuilder let content: Content @@ -8230,11 +8427,12 @@ private struct ShortcutSettingRow: View { } var body: some View { - ShortcutRecorderSettingsControl( - action: action, - shortcut: $shortcut, + KeyboardShortcutRecorder( + label: action.label, subtitle: KeyboardShortcutSettings.settingsFileManagedSubtitle(for: action), + shortcut: $shortcut, displayString: { action.displayedShortcutString(for: $0) }, + transformRecordedShortcut: { action.normalizedRecordedShortcut($0) }, isDisabled: KeyboardShortcutSettings.isManagedBySettingsFile(action) ) .onChange(of: shortcut) { newValue in @@ -8249,71 +8447,6 @@ private struct ShortcutSettingRow: View { } } -private struct ShortcutRecorderSettingsControl: View { - let action: KeyboardShortcutSettings.Action - @Binding var shortcut: StoredShortcut - var subtitle: String? = nil - var displayString: (StoredShortcut) -> String = { $0.displayString } - var isDisabled: Bool = false - - @State private var rejectedAttempt: ShortcutRecorderRejectedAttempt? - - var body: some View { - KeyboardShortcutRecorder( - label: action.label, - subtitle: subtitle, - shortcut: $shortcut, - displayString: displayString, - transformRecordedShortcut: { action.normalizedRecordedShortcutResult($0) }, - validationMessage: validationPresentation?.message, - validationButtonTitle: validationPresentation?.swapButtonTitle, - onValidationButtonPressed: validationPresentation?.canSwap == true - ? { swapConflictingShortcut() } - : nil, - undoButtonTitle: validationPresentation?.undoButtonTitle, - onUndoButtonPressed: rejectedAttempt != nil ? { rejectedAttempt = nil } : nil, - hasPendingRejection: rejectedAttempt != nil, - isDisabled: isDisabled, - onRecorderFeedbackChanged: { rejectedAttempt = $0 } - ) - .onChange(of: shortcut) { _ in - rejectedAttempt = nil - } - .onReceive(NotificationCenter.default.publisher(for: KeyboardShortcutRecorderActivity.didChangeNotification)) { _ in - if KeyboardShortcutRecorderActivity.isAnyRecorderActive { - rejectedAttempt = nil - } - } - } - - private var validationPresentation: ShortcutRecorderValidationPresentation? { - ShortcutRecorderValidationPresentation( - attempt: rejectedAttempt, - action: action, - currentShortcut: shortcut - ) - } - - private func swapConflictingShortcut() { - guard case let .conflictsWithAction(conflictingAction)? = rejectedAttempt?.reason, - let proposedShortcut = rejectedAttempt?.proposedShortcut else { - return - } - - KeyboardShortcutRecorderActivity.stopAllRecording() - - let previousShortcut = shortcut - KeyboardShortcutSettings.swapShortcutConflict( - proposedShortcut: proposedShortcut, - currentAction: action, - conflictingAction: conflictingAction, - previousShortcut: previousShortcut - ) - shortcut = proposedShortcut - rejectedAttempt = nil - } -} - private struct GlobalHotkeySection: View { @AppStorage(SystemWideHotkeySettings.enabledKey) private var isEnabled = SystemWideHotkeySettings.defaultEnabled @State private var shortcut = KeyboardShortcutSettings.shortcut(for: SystemWideHotkeySettings.action) @@ -8358,10 +8491,11 @@ private struct GlobalHotkeySection: View { SettingsCardDivider() - ShortcutRecorderSettingsControl( - action: SystemWideHotkeySettings.action, - shortcut: $shortcut, + KeyboardShortcutRecorder( + label: String(localized: "settings.globalHotkey.shortcut", defaultValue: "Show/Hide All Windows"), subtitle: KeyboardShortcutSettings.settingsFileManagedSubtitle(for: SystemWideHotkeySettings.action), + shortcut: $shortcut, + transformRecordedShortcut: { SystemWideHotkeySettings.action.normalizedRecordedShortcut($0) }, isDisabled: KeyboardShortcutSettings.isManagedBySettingsFile(SystemWideHotkeySettings.action) ) .padding(.horizontal, 14) @@ -8404,11 +8538,13 @@ private struct SettingsRootView: View { window.identifier = NSUserInterfaceItemIdentifier("cmux.settings") applyCurrentSettingsWindowStyle(to: window) - let accessories = window.titlebarAccessoryViewControllers - for index in accessories.indices.reversed() { - guard let identifier = accessories[index].view.identifier?.rawValue else { continue } - guard identifier.hasPrefix("cmux.") else { continue } - window.removeTitlebarAccessoryViewController(at: index) + if window.cmuxSupportsTitlebarAccessoryControllers { + let accessories = window.titlebarAccessoryViewControllers + for index in accessories.indices.reversed() { + guard let identifier = accessories[index].view.identifier?.rawValue else { continue } + guard identifier.hasPrefix("cmux.") else { continue } + window.removeTitlebarAccessoryViewController(at: index) + } } AppDelegate.shared?.applyWindowDecorations(to: window) } diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index fd1d9e2f70d1..c05e8c84d490 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -54,34 +54,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ---- - -## SwiftTerm - -- **License:** MIT License -- **Copyright:** Copyright (c) 2019-2022 Miguel de Icaza, The xterm.js authors, SourceLair Private Company, Christopher Jeffrey -- **Source:** https://github.com/migueldeicaza/SwiftTerm - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - ---- - ## Sparkle - **License:** MIT License diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 9114aaf9d02b..2089c45fa52f 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -11,25 +11,6 @@ private final class FakeWKInspectorContainerView: NSView {} private final class FocusableTestView: NSView { override var acceptsFirstResponder: Bool { true } } -private final class GhosttyCommandEquivalentProbeView: GhosttyNSView { - var afterMenuMissCallCount = 0 - var pasteCallCount = 0 - var pasteAsPlainTextCallCount = 0 - var performAfterMenuMissResult = true - - override func performKeyEquivalentAfterMenuMiss(with event: NSEvent) -> Bool { - afterMenuMissCallCount += 1 - return performAfterMenuMissResult - } - - override func paste(_ sender: Any?) { - pasteCallCount += 1 - } - - override func pasteAsPlainText(_ sender: Any?) { - pasteAsPlainTextCallCount += 1 - } -} @MainActor final class AppDelegateShortcutRoutingTests: XCTestCase { @@ -1010,7 +991,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { let orphanManager = TabManager() let orphanSidebarState = SidebarState() let orphanSidebarSelectionState = SidebarSelectionState() - let orphanFileExplorerState = FileExplorerState() autoreleasepool { var orphanWindow: NSWindow? = NSWindow( @@ -1025,8 +1005,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { windowId: orphanWindowId, tabManager: orphanManager, sidebarState: orphanSidebarState, - sidebarSelectionState: orphanSidebarSelectionState, - fileExplorerState: orphanFileExplorerState + sidebarSelectionState: orphanSidebarSelectionState ) orphanWindow = nil } @@ -1055,7 +1034,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { let orphanManager = TabManager() let orphanSidebarState = SidebarState() let orphanSidebarSelectionState = SidebarSelectionState() - let orphanFileExplorerState = FileExplorerState() autoreleasepool { var orphanWindow: NSWindow? = NSWindow( @@ -1070,8 +1048,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { windowId: orphanWindowId, tabManager: orphanManager, sidebarState: orphanSidebarState, - sidebarSelectionState: orphanSidebarSelectionState, - fileExplorerState: orphanFileExplorerState + sidebarSelectionState: orphanSidebarSelectionState ) orphanWindow = nil } @@ -1296,7 +1273,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { let originalPanelIds = Set(workspace.panels.keys) - guard let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + guard let rightPanel = workspace.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .horizontal) else { XCTFail("Expected split terminal panels") return } @@ -1307,7 +1284,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTFail("Expected split pane IDs") return } - let layoutBefore = workspace.bonsplitController.layoutSnapshot() + let layoutBefore = workspace.layoutSnapshot() guard let leftPaneBeforeFrame = layoutBefore.panes.first(where: { $0.paneId == leftPaneBefore.id.uuidString })?.frame, let rightPaneBeforeFrame = layoutBefore.panes.first(where: { $0.paneId == rightPaneBefore.id.uuidString })?.frame else { XCTFail("Expected pane frames before shortcut split") @@ -1323,7 +1300,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { window.makeKeyAndOrderFront(nil) RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) workspace.focusPanel(rightPanel.id) - XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected Bonsplit selection to stay on the right pane") + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected WorkspaceSplit selection to stay on the right pane") leftPanel.hostedView.suppressReparentFocus() XCTAssertTrue(window.makeFirstResponder(leftSurfaceView)) leftPanel.hostedView.clearSuppressReparentFocus() @@ -1350,7 +1327,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTFail("Expected pane IDs after shortcut split") return } - let layoutAfter = workspace.bonsplitController.layoutSnapshot() + let layoutAfter = workspace.layoutSnapshot() guard let newPaneFrame = layoutAfter.panes.first(where: { $0.paneId == newPaneId.id.uuidString })?.frame, let rightPaneAfterFrame = layoutAfter.panes.first(where: { $0.paneId == rightPaneAfter.id.uuidString })?.frame else { XCTFail("Expected pane frames after shortcut split") @@ -1723,7 +1700,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { ), 0, accuracy: 0.5, - "Manually hosted minimal windows already have zero safe area, so the Bonsplit strip must not be pulled offscreen" + "Manually hosted minimal windows already have zero safe area, so the WorkspaceSplit strip must not be pulled offscreen" ) XCTAssertEqual( @@ -1782,8 +1759,8 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { } // Recreate the regression shape: the window chrome state says minimal + - // collapsed sidebar, but the selected workspace's live Bonsplit inset is stale. - sourceWorkspace.bonsplitController.configuration.appearance.tabBarLeadingInset = 0 + // collapsed sidebar, but the selected workspace's live WorkspaceSplit inset is stale. + sourceWorkspace.tabBarLeadingInset = 0 guard let newWorkspaceId = appDelegate.addWorkspaceInPreferredMainWindow(debugSource: "test.issue2737") else { XCTFail("Expected workspace creation to route to the test window") @@ -1798,7 +1775,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { } XCTAssertEqual( - newWorkspace.bonsplitController.configuration.appearance.tabBarLeadingInset, + newWorkspace.tabBarLeadingInset, 80, accuracy: 0.5, "New minimal-mode workspaces should reserve traffic-light space immediately even when the source workspace inset is stale" @@ -1820,7 +1797,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { // Simulate the new-window flow: createMainWindow with a snapshot that forces // sidebar collapsed. The initial workspace is created inside TabManager.init, - // before ContentView.onAppear can run syncTrafficLightInset — so the seed in + // before ContentView.onAppear can run syncTrafficLightInset, so the seed in // createMainWindow is what protects the first render. let snapshot = SessionWindowSnapshot( frame: nil, @@ -1846,7 +1823,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { // No RunLoop spin before reading the inset — the seed must be applied by the // time createMainWindow returns, not lazily after onAppear runs. XCTAssertEqual( - initialWorkspace.bonsplitController.configuration.appearance.tabBarLeadingInset, + initialWorkspace.tabBarLeadingInset, 80, accuracy: 0.5, "New minimal-mode windows with collapsed sidebar should reserve traffic-light space on the initial workspace before first render" @@ -2533,7 +2510,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(observedPaletteWindow?.windowNumber, window.windowNumber) } - func testCmdFFocusedBrowserOpensBrowserFind() { + func testCmdFFocusedBrowserKeepsWebContentFirstRouting() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") return @@ -2552,7 +2529,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertNotNil(manager.focusedBrowserPanel) XCTAssertNil(manager.focusedBrowserPanel?.searchState) - let initialMode = appDelegate.fileExplorerState?.mode guard let event = makeKeyDownEvent( key: "f", @@ -2565,16 +2541,139 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { } #if DEBUG - XCTAssertTrue( + XCTAssertFalse( appDelegate.debugHandleCustomShortcut(event: event), - "Cmd+F should open browser find when browser web content is focused" + "Cmd+F should fall through so browser web content gets first chance" ) #else XCTFail("debugHandleCustomShortcut is only available in DEBUG") #endif - XCTAssertNotNil(manager.focusedBrowserPanel?.searchState) - XCTAssertEqual(appDelegate.fileExplorerState?.mode, initialMode) + XCTAssertNil(manager.focusedBrowserPanel?.searchState) + } + + func testCmdEFocusedBrowserKeepsWebContentFirstRouting() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId, + let terminalPanel = workspace.terminalPanel(for: terminalPanelId), + let browserPanel = workspace.splitBrowserPanel( + fromPanelId: terminalPanel.id, + orientation: .horizontal, + focus: true + ) else { + XCTFail("Expected visible terminal and browser panels") + return + } + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + XCTAssertEqual(workspace.focusedPanelId, browserPanel.id, "Expected split browser pane to stay focused for Cmd+E") + + guard let event = makeKeyDownEvent( + key: "e", + modifiers: [.command], + keyCode: 14, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Cmd+E event") + return + } + +#if DEBUG + XCTAssertFalse( + appDelegate.debugHandleCustomShortcut(event: event), + "Cmd+E should fall through so browser web content gets first chance when a browser pane is focused" + ) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + func testCmdLFocusesFocusedBrowserAddressBar() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId, + let terminalPanel = workspace.terminalPanel(for: terminalPanelId), + let browserPanel = workspace.splitBrowserPanel( + fromPanelId: terminalPanel.id, + orientation: .horizontal, + focus: true + ) else { + XCTFail("Expected visible terminal and browser panels") + return + } + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + XCTAssertEqual(workspace.focusedPanelId, browserPanel.id, "Expected split browser pane to stay focused for Cmd+L") + + let addressBarExpectation = expectation(description: "browserFocusAddressBar notification") + var notifiedPanelId: UUID? + let notificationToken = NotificationCenter.default.addObserver( + forName: .browserFocusAddressBar, + object: nil, + queue: nil + ) { notification in + notifiedPanelId = notification.object as? UUID + addressBarExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(notificationToken) } + + guard let event = makeKeyDownEvent( + key: "l", + modifiers: [.command], + keyCode: 37, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Cmd+L event") + return + } + +#if DEBUG + let handled = appDelegate.debugHandleCustomShortcut(event: event) + XCTAssertTrue( + handled, + "Cmd+L should be handled while the focused pane is a browser" + ) + guard handled else { return } +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [addressBarExpectation], timeout: 0.2) + XCTAssertEqual( + notifiedPanelId, + browserPanel.id, + "Cmd+L should notify the currently focused browser pane" + ) + XCTAssertEqual( + appDelegate.focusedBrowserAddressBarPanelId(), + browserPanel.id, + "Cmd+L should focus the currently focused browser pane" + ) } func testCmdPhysicalWWithDvorakCharactersDoesNotTriggerClosePanelShortcut() { @@ -4243,8 +4342,9 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { // MARK: - Non-Latin keyboard layout shortcut tests - func testBrowserFirstFindShortcutRoutingRecognizesBrowserLocalFindCommandFamily() { + func testBrowserFirstFindShortcutRoutingRecognizesFindCommandFamily() { let cases: [(name: String, modifiers: NSEvent.ModifierFlags, chars: String, keyCode: UInt16)] = [ + ("cmd-f", [.command], "f", 3), ("cmd-g", [.command], "g", 5), ("cmd-shift-g", [.command, .shift], "g", 5), ("cmd-shift-f", [.command, .shift], "f", 3), @@ -4265,31 +4365,17 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { } } - func testBrowserFirstFindShortcutRoutingExcludesGlobalFindCommand() { - let event = makeKeyEvent( - modifierFlags: [.command], - characters: "f", - charactersIgnoringModifiers: "f", - keyCode: 3 - ) - - XCTAssertFalse( - shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst(event), - "Cmd+F belongs to cmux focus-aware find routing, not browser-first routing" - ) - } - func testBrowserFirstFindShortcutRoutingFallsBackToKeyCodeForNonLatinInput() { let event = makeKeyEvent( modifierFlags: [.command], characters: "", - charactersIgnoringModifiers: "п", // Cyrillic p from a non-Latin input source - keyCode: 5 // kVK_ANSI_G + charactersIgnoringModifiers: "а", // Cyrillic a from a non-Latin input source + keyCode: 3 // kVK_ANSI_F ) XCTAssertTrue( shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst(event), - "Expected browser-first routing to keep Cmd+G eligible under non-Latin input" + "Expected browser-first routing to keep Cmd+F eligible under non-Latin input" ) } @@ -4324,9 +4410,9 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { let event = makeKeyEvent( modifierFlags: [.command], - characters: "g", - charactersIgnoringModifiers: "g", - keyCode: 5 + characters: "f", + charactersIgnoringModifiers: "f", + keyCode: 3 ) XCTAssertFalse( @@ -4543,55 +4629,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { #endif } - func testWindowPerformKeyEquivalentDefersTerminalPasteMenuMissToGhosttyBindingResolution() { - let previousMainMenu = NSApp.mainMenu - let probeWindow = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - let contentView = NSView(frame: probeWindow.contentRect(forFrameRect: probeWindow.frame)) - let probeView = GhosttyCommandEquivalentProbeView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) - - defer { - NSApp.mainMenu = previousMainMenu - probeWindow.orderOut(nil) - } - - let emptyMenu = NSMenu(title: "Test") - emptyMenu.addItem(withTitle: "Placeholder", action: nil, keyEquivalent: "") - NSApp.mainMenu = emptyMenu - - probeWindow.contentView = contentView - contentView.addSubview(probeView) - probeWindow.makeKeyAndOrderFront(nil) - probeWindow.displayIfNeeded() - XCTAssertTrue(probeWindow.makeFirstResponder(probeView), "Expected probe Ghostty view to own first responder") - - guard let event = makeKeyDownEvent( - key: "v", - modifiers: [.command], - keyCode: 9, - windowNumber: probeWindow.windowNumber - ) else { - XCTFail("Failed to construct Cmd+V event") - return - } - - XCTAssertTrue( - probeWindow.performKeyEquivalent(with: event), - "Cmd+V menu miss should still route through Ghostty binding resolution" - ) - XCTAssertEqual(probeView.afterMenuMissCallCount, 1, "Ghostty binding resolution should run after the menu miss") - XCTAssertEqual(probeView.pasteCallCount, 0, "Window routing must not force paste before Ghostty inspects bindings") - XCTAssertEqual( - probeView.pasteAsPlainTextCallCount, - 0, - "Window routing must not force plain-text paste before Ghostty inspects bindings" - ) - } - func testWindowSendEventRepairsVisibleSameWindowResponderDriftForFocusedTerminalTyping() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") @@ -4673,213 +4710,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { #endif } - func testTerminalFirstResponderGuardBlocksMoveFocusWhenRightSidebarOwnsKeyboardFocus() { - guard let appDelegate = AppDelegate.shared else { - XCTFail("Expected AppDelegate.shared") - return - } - - let windowId = appDelegate.createMainWindow() - defer { closeWindow(withId: windowId) } - - guard let window = window(withId: windowId), - let contentView = window.contentView, - let manager = appDelegate.tabManagerFor(windowId: windowId), - let workspace = manager.selectedWorkspace, - let panelId = workspace.focusedPanelId, - let terminalPanel = workspace.terminalPanel(for: panelId), - let terminalView = surfaceView(in: terminalPanel.hostedView) else { - XCTFail("Expected focused terminal surface") - return - } - - let strayView = FocusableTestView(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) - contentView.addSubview(strayView) - defer { strayView.removeFromSuperview() } - - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - terminalPanel.hostedView.setVisibleInUI(true) - terminalPanel.hostedView.setActive(true) - - XCTAssertTrue(window.makeFirstResponder(strayView), "Expected a foreign responder before blocking terminal focus") - appDelegate.noteRightSidebarKeyboardFocusIntent(mode: .feed, in: window) - - XCTAssertFalse( - window.makeFirstResponder(terminalView), - "Coordinator-owned sidebar focus should block direct terminal first-responder requests" - ) - - terminalPanel.hostedView.moveFocus() - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) - - XCTAssertTrue(window.firstResponder === strayView, "Blocked terminal moveFocus should keep the existing responder intact") - XCTAssertFalse( - terminalPanel.hostedView.isSurfaceViewFirstResponder(), - "Blocked terminal moveFocus must not leave the Ghostty surface as first responder" - ) - } - - func testFindShortcutFromFileTreeOpensRightSidebarFind() { - guard let appDelegate = AppDelegate.shared else { - XCTFail("Expected AppDelegate.shared") - return - } - - let windowId = appDelegate.createMainWindow() - defer { closeWindow(withId: windowId) } - - guard let window = window(withId: windowId), - let contentView = window.contentView, - let manager = appDelegate.tabManagerFor(windowId: windowId), - let workspace = manager.selectedWorkspace, - let panelId = workspace.focusedPanelId, - let terminalPanel = workspace.terminalPanel(for: panelId) else { - XCTFail("Expected focused terminal surface") - return - } - - let sidebarResponder = FocusableTestView(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) - contentView.addSubview(sidebarResponder) - defer { sidebarResponder.removeFromSuperview() } - - XCTAssertTrue(window.makeFirstResponder(sidebarResponder), "Expected right sidebar responder to take focus") - appDelegate.fileExplorerState?.mode = .files - appDelegate.noteRightSidebarKeyboardFocusIntent(mode: .files, in: window) - - guard let event = makeKeyDownEvent( - key: "f", - modifiers: [.command], - keyCode: 3, - windowNumber: window.windowNumber - ) else { - XCTFail("Failed to construct Cmd+F event") - return - } - -#if DEBUG - XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) -#else - XCTFail("debugHandleCustomShortcut is only available in DEBUG") -#endif - - XCTAssertNil(terminalPanel.searchState, "Cmd+F from the file tree should not create terminal search state") - XCTAssertEqual(appDelegate.fileExplorerState?.mode, .find) - } - - func testFindShortcutFromTerminalOpensTerminalFind() { - guard let appDelegate = AppDelegate.shared else { - XCTFail("Expected AppDelegate.shared") - return - } - - let windowId = appDelegate.createMainWindow() - defer { closeWindow(withId: windowId) } - - guard let window = window(withId: windowId), - let manager = appDelegate.tabManagerFor(windowId: windowId), - let workspace = manager.selectedWorkspace, - let panelId = workspace.focusedPanelId, - let terminalPanel = workspace.terminalPanel(for: panelId) else { - XCTFail("Expected focused terminal surface") - return - } - - appDelegate.noteTerminalKeyboardFocusIntent(workspaceId: workspace.id, panelId: terminalPanel.id, in: window) - - guard let event = makeKeyDownEvent( - key: "f", - modifiers: [.command], - keyCode: 3, - windowNumber: window.windowNumber - ) else { - XCTFail("Failed to construct Cmd+F event") - return - } - -#if DEBUG - XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) -#else - XCTFail("debugHandleCustomShortcut is only available in DEBUG") -#endif - - XCTAssertNotNil(terminalPanel.searchState, "Cmd+F from terminal focus should create terminal search state") - } - - func testFindShortcutFromOtherRightSidebarModeDoesNotStealFocus() { - guard let appDelegate = AppDelegate.shared else { - XCTFail("Expected AppDelegate.shared") - return - } - - let windowId = appDelegate.createMainWindow() - defer { closeWindow(withId: windowId) } - - guard let window = window(withId: windowId), - let contentView = window.contentView, - let manager = appDelegate.tabManagerFor(windowId: windowId), - let workspace = manager.selectedWorkspace, - let panelId = workspace.focusedPanelId, - let terminalPanel = workspace.terminalPanel(for: panelId) else { - XCTFail("Expected focused terminal surface") - return - } - - let sidebarResponder = FocusableTestView(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) - contentView.addSubview(sidebarResponder) - defer { sidebarResponder.removeFromSuperview() } - - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - terminalPanel.hostedView.setVisibleInUI(true) - terminalPanel.hostedView.setActive(true) - terminalPanel.hostedView.moveFocus() - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) - - XCTAssertTrue(window.makeFirstResponder(sidebarResponder), "Expected right sidebar responder to take focus") - appDelegate.fileExplorerState?.mode = .feed - appDelegate.noteRightSidebarKeyboardFocusIntent(mode: .feed, in: window) - XCTAssertFalse( - appDelegate.allowsTerminalKeyboardFocus( - workspaceId: workspace.id, - panelId: terminalPanel.id, - in: window - ), - "Right sidebar ownership should block direct terminal focus before Cmd+F" - ) - - guard let event = makeKeyDownEvent( - key: "f", - modifiers: [.command], - keyCode: 3, - windowNumber: window.windowNumber - ) else { - XCTFail("Failed to construct Cmd+F event") - return - } - -#if DEBUG - XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) -#else - XCTFail("debugHandleCustomShortcut is only available in DEBUG") -#endif - - XCTAssertFalse( - appDelegate.allowsTerminalKeyboardFocus( - workspaceId: workspace.id, - panelId: terminalPanel.id, - in: window - ), - "Cmd+F should keep keyboard ownership in the existing right sidebar section" - ) - XCTAssertNil(terminalPanel.searchState, "Cmd+F should not create terminal search state") - XCTAssertEqual(appDelegate.fileExplorerState?.mode, .feed) - XCTAssertFalse( - terminalPanel.hostedView.isSurfaceViewFirstResponder(), - "Cmd+F from a non-file right sidebar mode should not refocus the terminal responder" - ) - } - func testWindowSendEventRepairsFocusedTerminalSearchTypingAfterResponderDrift() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift index 61625cc83195..5fbe4bb1e7bb 100644 --- a/cmuxTests/BrowserConfigTests.swift +++ b/cmuxTests/BrowserConfigTests.swift @@ -1,11 +1,11 @@ import XCTest import Combine import AppKit +import AuthenticationServices import SwiftUI import UniformTypeIdentifiers import WebKit import ObjectiveC.runtime -import Bonsplit import UserNotifications #if canImport(cmux_DEV) @@ -228,6 +228,42 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { XCTAssertFalse(spy.invoked) } + func testPlainTextKeyEquivalentStaysOnNativeWebKitPathWhenWebViewIsFirstResponder() throws { + installCmuxUnitTestWKWebViewPerformKeyEquivalentOverride() + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let event = try XCTUnwrap(makeKeyDownEvent(key: "z", modifiers: [], keyCode: 6)) + + var forwardedToWebKit = false + cmuxUnitTestWKWebViewPerformKeyEquivalentHook = { currentWebView, currentEvent in + guard currentWebView === webView, currentEvent === event else { return nil } + forwardedToWebKit = true + return false + } + defer { cmuxUnitTestWKWebViewPerformKeyEquivalentHook = nil } + + XCTAssertFalse(webView.performKeyEquivalent(with: event)) + XCTAssertTrue(forwardedToWebKit) + } + + func testSpaceKeyEquivalentBypassesNativeWebKitPathWhenWebViewIsFirstResponder() throws { + installCmuxUnitTestWKWebViewPerformKeyEquivalentOverride() + defer { cmuxUnitTestWKWebViewPerformKeyEquivalentHook = nil } + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + + for modifiers in [NSEvent.ModifierFlags(), NSEvent.ModifierFlags.shift] { + let event = try XCTUnwrap(makeKeyDownEvent(key: " ", modifiers: modifiers, keyCode: 49)) + var forwardedToWebKit = false + cmuxUnitTestWKWebViewPerformKeyEquivalentHook = { currentWebView, currentEvent in + guard currentWebView === webView, currentEvent === event else { return nil } + forwardedToWebKit = true + return true + } + + XCTAssertFalse(webView.performKeyEquivalent(with: event)) + XCTAssertFalse(forwardedToWebKit, "Space should proceed to keyDown, not WebKit key-equivalent handling") + } + } + func testCmdReturnDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() { let spy = ActionSpy() installMenu(spy: spy, key: "\r", modifiers: [.command]) @@ -267,6 +303,7 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) webView.autoresizingMask = [.width, .height] + webView.allowsFirstResponderAcquisition = true container.addSubview(webView) window.makeKeyAndOrderFront(nil) @@ -300,6 +337,7 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) webView.autoresizingMask = [.width, .height] + webView.allowsFirstResponderAcquisition = true container.addSubview(webView) window.makeKeyAndOrderFront(nil) @@ -333,6 +371,7 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) webView.autoresizingMask = [.width, .height] + webView.allowsFirstResponderAcquisition = true container.addSubview(webView) let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) @@ -369,6 +408,7 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) webView.autoresizingMask = [.width, .height] + webView.allowsFirstResponderAcquisition = true container.addSubview(webView) let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) @@ -406,6 +446,7 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) webView.autoresizingMask = [.width, .height] + webView.allowsFirstResponderAcquisition = true container.addSubview(webView) let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) @@ -637,6 +678,7 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) webView.autoresizingMask = [.width, .height] + webView.allowsFirstResponderAcquisition = true container.addSubview(webView) let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) @@ -807,6 +849,7 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { window.orderOut(nil) } + webView.allowsFirstResponderAcquisition = true XCTAssertTrue(window.makeFirstResponder(webView)) guard let event = makeKeyDownEvent( key: "`", @@ -863,6 +906,7 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { window.orderOut(nil) } + webView.allowsFirstResponderAcquisition = true XCTAssertTrue(window.makeFirstResponder(inspectorView)) guard let event = makeKeyDownEvent( key: "f", @@ -1142,11 +1186,11 @@ final class BrowserDevToolsButtonDebugSettingsTests: XCTestCase { func testCopyPayloadUsesPersistedValues() { let defaults = makeIsolatedDefaults() defaults.set(BrowserDevToolsIconOption.scope.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconNameKey) - defaults.set(BrowserDevToolsIconColorOption.bonsplitActive.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconColorKey) + defaults.set(BrowserDevToolsIconColorOption.workspaceSplitActive.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconColorKey) let payload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: defaults) XCTAssertTrue(payload.contains("browserDevToolsIconName=scope")) - XCTAssertTrue(payload.contains("browserDevToolsIconColor=bonsplitActive")) + XCTAssertTrue(payload.contains("browserDevToolsIconColor=workspaceSplitActive")) } } @@ -1501,19 +1545,7 @@ final class BrowserPopupDecisionTests: XCTestCase { ) } - func testOtherNavigationKeyDownGestureStillCreatesPopup() { - XCTAssertTrue( - browserNavigationShouldCreatePopup( - navigationType: .other, - modifierFlags: [], - buttonNumber: 0, - currentEventType: .keyDown, - currentEventButtonNumber: 0 - ) - ) - } - - func testOtherNavigationWithoutExplicitNewTabIntentCreatesPopup() { + func testOtherNavigationPlainLeftClickCreatesPopup() { XCTAssertTrue( browserNavigationShouldCreatePopup( navigationType: .other, @@ -1564,213 +1596,36 @@ final class BrowserNilTargetFallbackDecisionTests: XCTestCase { } -final class BrowserSimpleUserGesturePopupRetargetingTests: XCTestCase { - func testKeyboardKeyDownSameSiteGETWithoutPopupFeaturesPrefersCurrentTabRetarget() { - XCTAssertTrue( - browserNavigationShouldOpenSimpleUserGesturePopupInCurrentTab( - navigationType: .other, - requestMethod: "GET", - requestURL: URL(string: "https://search.bilibili.com/all?keyword=test"), - openerURL: URL(string: "https://www.bilibili.com/video/BV1"), - currentEventType: .keyDown, - popupFeaturesWereSpecified: false - ) - ) - } - - func testKeyboardSameSiteGETWithoutPopupFeaturesPrefersCurrentTabRetarget() { - XCTAssertTrue( - browserNavigationShouldOpenSimpleUserGesturePopupInCurrentTab( - navigationType: .other, - requestMethod: "GET", - requestURL: URL(string: "https://search.bilibili.com/all?keyword=test"), - openerURL: URL(string: "https://www.bilibili.com/video/BV1"), - currentEventType: .keyUp, - popupFeaturesWereSpecified: false - ) - ) - } - - func testLeftClickSameSiteGETWithoutPopupFeaturesPrefersCurrentTabRetarget() { - XCTAssertTrue( - browserNavigationShouldOpenSimpleUserGesturePopupInCurrentTab( - navigationType: .other, - requestMethod: "GET", - requestURL: URL(string: "https://search.bilibili.com/all?keyword=test"), - openerURL: URL(string: "https://www.bilibili.com/video/BV1"), - currentEventType: .leftMouseUp, - popupFeaturesWereSpecified: false - ) - ) - } - - func testCrossSiteKeyboardPopupStaysPopup() { - XCTAssertFalse( - browserNavigationShouldOpenSimpleUserGesturePopupInCurrentTab( - navigationType: .other, - requestMethod: "GET", - requestURL: URL(string: "https://accounts.google.com/o/oauth2/v2/auth"), - openerURL: URL(string: "https://app.example.com/login"), - currentEventType: .keyUp, - popupFeaturesWereSpecified: false - ) - ) - } - - func testExplicitCommandNewTabGestureDoesNotRetargetIntoCurrentTab() { - XCTAssertFalse( - browserNavigationShouldOpenSimpleUserGesturePopupInCurrentTab( - navigationType: .other, - requestMethod: "GET", - requestURL: URL(string: "https://search.bilibili.com/all?keyword=test"), - openerURL: URL(string: "https://www.bilibili.com/video/BV1"), - modifierFlags: [.command], - currentEventType: .keyUp, - popupFeaturesWereSpecified: false - ) - ) - } - - func testMixedSchemePopupStaysPopupEvenWhenRegistrableDomainMatches() { - XCTAssertFalse( - browserNavigationShouldOpenSimpleUserGesturePopupInCurrentTab( - navigationType: .other, - requestMethod: "GET", - requestURL: URL(string: "https://login.example.com/oauth"), - openerURL: URL(string: "http://example.com/login"), - currentEventType: .keyUp, - popupFeaturesWereSpecified: false - ) - ) - } - - func testGitHubPagesTenantsStayPopup() { - XCTAssertFalse( - browserNavigationShouldOpenSimpleUserGesturePopupInCurrentTab( - navigationType: .other, - requestMethod: "GET", - requestURL: URL(string: "https://foo.github.io/search"), - openerURL: URL(string: "https://bar.github.io/login"), - currentEventType: .keyUp, - popupFeaturesWereSpecified: false - ) - ) - } - - func testAppspotTenantsStayPopup() { - XCTAssertFalse( - browserNavigationShouldOpenSimpleUserGesturePopupInCurrentTab( - navigationType: .other, - requestMethod: "GET", - requestURL: URL(string: "https://a.appspot.com/search"), - openerURL: URL(string: "https://b.appspot.com/login"), - currentEventType: .keyUp, - popupFeaturesWereSpecified: false - ) - ) - } - - func testCloudFrontTenantsStayPopup() { - XCTAssertFalse( - browserNavigationShouldOpenSimpleUserGesturePopupInCurrentTab( - navigationType: .other, - requestMethod: "GET", - requestURL: URL(string: "https://foo.cloudfront.net/search"), - openerURL: URL(string: "https://bar.cloudfront.net/login"), - currentEventType: .keyUp, - popupFeaturesWereSpecified: false - ) - ) - } - - func testS3TenantsStayPopup() { - XCTAssertFalse( - browserNavigationShouldOpenSimpleUserGesturePopupInCurrentTab( - navigationType: .other, - requestMethod: "GET", - requestURL: URL(string: "https://a.s3.amazonaws.com/search"), - openerURL: URL(string: "https://b.s3.amazonaws.com/login"), - currentEventType: .keyUp, - popupFeaturesWereSpecified: false - ) - ) - } - - func testSameHostKeyboardPopupStaysPopup() { - XCTAssertFalse( - browserNavigationShouldOpenSimpleUserGesturePopupInCurrentTab( - navigationType: .other, - requestMethod: "GET", - requestURL: URL(string: "https://www.example.com/chooser"), - openerURL: URL(string: "https://www.example.com/settings"), - currentEventType: .keyUp, - popupFeaturesWereSpecified: false - ) - ) - } - - func testCrossPortSameHostPopupStaysPopup() { - XCTAssertFalse( - browserNavigationShouldOpenSimpleUserGesturePopupInCurrentTab( - navigationType: .other, - requestMethod: "GET", - requestURL: URL(string: "https://localhost:3000/search"), - openerURL: URL(string: "https://localhost:5000/login"), - currentEventType: .keyUp, - popupFeaturesWereSpecified: false - ) - ) - } - - func testDistinctBareCountryCodeSecondLevelHostsStayPopup() { - XCTAssertFalse( - browserNavigationShouldOpenSimpleUserGesturePopupInCurrentTab( - navigationType: .other, - requestMethod: "GET", - requestURL: URL(string: "https://foo.co.uk/search"), - openerURL: URL(string: "https://bar.co.uk/login"), - currentEventType: .keyUp, - popupFeaturesWereSpecified: false - ) - ) - } - - func testCrossRegistrableDomainsUnderCommonMultiPartSuffixStayPopup() { - XCTAssertFalse( - browserNavigationShouldOpenSimpleUserGesturePopupInCurrentTab( - navigationType: .other, - requestMethod: "GET", - requestURL: URL(string: "https://foo.example.co.uk/search"), - openerURL: URL(string: "https://bar.attacker.co.uk/login"), - currentEventType: .keyUp, - popupFeaturesWereSpecified: false - ) +final class BrowserWebAuthnCapabilityDecisionTests: XCTestCase { + func testCrossOriginFrameWithoutAuthorizationDoesNotAdvertisePlatformPasskeys() { + XCTAssertEqual( + browserWebAuthnAdvertisedPlatformPasskeyAvailability( + authorizationState: .notDetermined, + deviceConfiguredForPasskeys: nil, + callerMayPromptForPlatformAuthorization: false + ), + false ) } - func testPopupFeaturesKeepKeyboardRequestOnPopupPath() { - XCTAssertFalse( - browserNavigationShouldOpenSimpleUserGesturePopupInCurrentTab( - navigationType: .other, - requestMethod: "GET", - requestURL: URL(string: "https://www.bilibili.com/search"), - openerURL: URL(string: "https://www.bilibili.com/video/BV1"), - currentEventType: .keyUp, - popupFeaturesWereSpecified: true + func testPromptableFrameFallsBackWhenDeviceConfigurationIsUnknown() { + XCTAssertNil( + browserWebAuthnAdvertisedPlatformPasskeyAvailability( + authorizationState: .notDetermined, + deviceConfiguredForPasskeys: nil, + callerMayPromptForPlatformAuthorization: true ) ) } - func testPOSTKeyboardRequestStaysPopup() { - XCTAssertFalse( - browserNavigationShouldOpenSimpleUserGesturePopupInCurrentTab( - navigationType: .other, - requestMethod: "POST", - requestURL: URL(string: "https://www.bilibili.com/search"), - openerURL: URL(string: "https://www.bilibili.com/video/BV1"), - currentEventType: .keyUp, - popupFeaturesWereSpecified: false - ) + func testAuthorizedBrowserUsesKnownDeviceConfiguration() { + XCTAssertEqual( + browserWebAuthnAdvertisedPlatformPasskeyAvailability( + authorizationState: .authorized, + deviceConfiguredForPasskeys: true, + callerMayPromptForPlatformAuthorization: false + ), + true ) } } @@ -2043,10 +1898,10 @@ final class BrowserSessionHistoryRestoreTests: XCTestCase { func testResetSidebarContextClearsBrowserPanelsIntoNewTabState() throws { let workspace = Workspace() - let paneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) + let paneId = try XCTUnwrap(workspace.paneIds.first) let contextPanelId = try XCTUnwrap(workspace.focusedPanelId) let browser = try XCTUnwrap( - workspace.newBrowserSurface( + workspace.createBrowserPanel( inPane: paneId, url: URL(string: "https://example.com"), focus: false @@ -2084,7 +1939,7 @@ final class BrowserSessionHistoryRestoreTests: XCTestCase { timestamp: Date() ) ) - workspace.surfaceListeningPorts[contextPanelId] = [3000] + workspace.setSurfaceListeningPorts(panelId: contextPanelId, ports: [3000]) workspace.recomputeListeningPorts() XCTAssertTrue(browser.shouldRenderWebView) @@ -2109,10 +1964,10 @@ final class BrowserSessionHistoryRestoreTests: XCTestCase { XCTAssertTrue(workspace.metadataBlocks.isEmpty) XCTAssertNil(workspace.progress) XCTAssertNil(workspace.gitBranch) - XCTAssertTrue(workspace.panelGitBranches.isEmpty) + XCTAssertTrue(workspace.surfaceStatesSnapshot().compactMapValues(\.gitBranch).isEmpty) XCTAssertNil(workspace.pullRequest) - XCTAssertTrue(workspace.panelPullRequests.isEmpty) - XCTAssertTrue(workspace.surfaceListeningPorts.isEmpty) + XCTAssertTrue(workspace.surfaceStatesSnapshot().compactMapValues(\.pullRequest).isEmpty) + XCTAssertTrue(workspace.surfaceStatesSnapshot().filter { !$0.value.listeningPorts.isEmpty }.isEmpty) XCTAssertTrue(workspace.listeningPorts.isEmpty) XCTAssertFalse(browser.shouldRenderWebView) XCTAssertNil(browser.preferredURLStringForOmnibar()) @@ -2423,6 +2278,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { paneId: paneId, shouldAttachWebView: true, useLocalInlineHosting: false, + retainedHostView: nil, shouldFocusWebView: false, isPanelFocused: true, portalZPriority: 0, @@ -2465,6 +2321,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { paneId: paneId, shouldAttachWebView: true, useLocalInlineHosting: false, + retainedHostView: nil, shouldFocusWebView: false, isPanelFocused: true, portalZPriority: 0, @@ -2526,6 +2383,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { paneId: paneId, shouldAttachWebView: false, useLocalInlineHosting: true, + retainedHostView: nil, shouldFocusWebView: false, isPanelFocused: true, portalZPriority: 0, @@ -2609,6 +2467,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { paneId: paneId, shouldAttachWebView: false, useLocalInlineHosting: true, + retainedHostView: nil, shouldFocusWebView: false, isPanelFocused: true, portalZPriority: 0, @@ -2838,6 +2697,149 @@ final class BrowserOmnibarCommandNavigationTests: XCTestCase { final class BrowserIMEKeyDownRoutingTests: XCTestCase { + @MainActor + func testWindowPerformKeyEquivalentForwardsSpaceToBrowserKeyDown() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + webView.allowsFirstResponderAcquisition = true + container.addSubview(webView) + + let responder = BrowserMarkedTextProbeTextView(frame: NSRect(x: 0, y: 0, width: 32, height: 20)) + webView.addSubview(responder) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + XCTAssertTrue(window.makeFirstResponder(responder)) + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: " ", + charactersIgnoringModifiers: " ", + isARepeat: false, + keyCode: 49 + ) else { + XCTFail("Failed to construct Space event") + return + } + + let consumed = window.performKeyEquivalent(with: event) + + XCTAssertTrue(consumed, "Plain browser Space should be handled by direct keyDown forwarding") + XCTAssertEqual(responder.keyDownEvents.count, 1, "Space should reach the browser responder exactly once") + } + + @MainActor + func testWebViewKeyDownForwardsPlainSpaceToContentCandidateWhenWrapperIsFirstResponder() { + _ = NSApplication.shared + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + webView.allowsFirstResponderAcquisition = true + container.addSubview(webView) + + let responder = BrowserMarkedTextProbeTextView(frame: NSRect(x: 0, y: 0, width: 32, height: 20)) + webView.addSubview(responder) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + XCTAssertTrue(window.makeFirstResponder(webView)) + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: " ", + charactersIgnoringModifiers: " ", + isARepeat: false, + keyCode: 49 + ) else { + XCTFail("Failed to construct Space event") + return + } + + webView.keyDown(with: event) + + XCTAssertTrue(window.firstResponder === webView) + XCTAssertEqual(responder.keyDownEvents.count, 1) + XCTAssertEqual(responder.keyDownEvents.first?.characters, " ") + } + + @MainActor + func testWebViewKeyDownDoesNotForwardPlainLettersToContentCandidate() { + _ = NSApplication.shared + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + webView.allowsFirstResponderAcquisition = true + container.addSubview(webView) + + let responder = BrowserMarkedTextProbeTextView(frame: NSRect(x: 0, y: 0, width: 32, height: 20)) + webView.addSubview(responder) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + XCTAssertTrue(window.makeFirstResponder(webView)) + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "a", + charactersIgnoringModifiers: "a", + isARepeat: false, + keyCode: 0 + ) else { + XCTFail("Failed to construct letter event") + return + } + + webView.keyDown(with: event) + + XCTAssertTrue(window.firstResponder === webView) + XCTAssertEqual(responder.keyDownEvents.count, 0) + } + @MainActor func testWindowPerformKeyEquivalentDoesNotForwardReturnDuringMarkedTextComposition() { _ = NSApplication.shared @@ -2854,6 +2856,7 @@ final class BrowserIMEKeyDownRoutingTests: XCTestCase { let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) webView.autoresizingMask = [.width, .height] + webView.allowsFirstResponderAcquisition = true container.addSubview(webView) let responder = BrowserMarkedTextProbeTextView(frame: NSRect(x: 0, y: 0, width: 32, height: 20)) @@ -2903,6 +2906,7 @@ final class BrowserIMEKeyDownRoutingTests: XCTestCase { let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) webView.autoresizingMask = [.width, .height] + webView.allowsFirstResponderAcquisition = true container.addSubview(webView) let responder = BrowserMarkedTextProbeTextView(frame: NSRect(x: 0, y: 0, width: 32, height: 20)) @@ -3050,8 +3054,173 @@ final class BrowserReturnKeyDownRoutingTests: XCTestCase { ) ) } + + func testRoutesForSpaceWhenBrowserFirstResponder() { + XCTAssertTrue( + shouldDispatchBrowserSpaceViaFirstResponderKeyDown( + keyCode: 49, + firstResponderIsBrowser: true, + flags: [] + ) + ) + } + + func testRoutesForShiftSpaceWhenBrowserFirstResponder() { + XCTAssertTrue( + shouldDispatchBrowserSpaceViaFirstResponderKeyDown( + keyCode: 49, + firstResponderIsBrowser: true, + flags: [.shift] + ) + ) + } + + func testDoesNotRouteSpaceWhenBrowserFirstResponderHasMarkedText() { + XCTAssertFalse( + shouldDispatchBrowserSpaceViaFirstResponderKeyDown( + keyCode: 49, + firstResponderIsBrowser: true, + firstResponderHasMarkedText: true, + flags: [] + ) + ) + } + + func testDoesNotRouteModifiedSpaceWhenBrowserFirstResponder() { + XCTAssertFalse( + shouldDispatchBrowserSpaceViaFirstResponderKeyDown( + keyCode: 49, + firstResponderIsBrowser: true, + flags: [.command] + ) + ) + XCTAssertFalse( + shouldDispatchBrowserSpaceViaFirstResponderKeyDown( + keyCode: 49, + firstResponderIsBrowser: true, + flags: [.option] + ) + ) + XCTAssertFalse( + shouldDispatchBrowserSpaceViaFirstResponderKeyDown( + keyCode: 49, + firstResponderIsBrowser: true, + flags: [.control] + ) + ) + } } +final class BrowserPlainTextKeyEquivalentRoutingTests: XCTestCase { + func testBypassesPlainLetterKeyEquivalentWhenBrowserFirstResponder() { + XCTAssertTrue( + shouldBypassBrowserPlainTextKeyEquivalent( + keyCode: 0, + firstResponderIsBrowser: true, + flags: [], + characters: "a", + charactersIgnoringModifiers: "a" + ) + ) + } + + func testBypassesShiftLetterKeyEquivalentWhenBrowserFirstResponder() { + XCTAssertTrue( + shouldBypassBrowserPlainTextKeyEquivalent( + keyCode: 0, + firstResponderIsBrowser: true, + flags: [.shift], + characters: "A", + charactersIgnoringModifiers: "a" + ) + ) + } + + func testDoesNotBypassPlainLetterWhenFirstResponderIsNotBrowser() { + XCTAssertFalse( + shouldBypassBrowserPlainTextKeyEquivalent( + keyCode: 0, + firstResponderIsBrowser: false, + flags: [], + characters: "a", + charactersIgnoringModifiers: "a" + ) + ) + } + + func testDoesNotBypassPlainLetterWhenBrowserFirstResponderHasMarkedText() { + XCTAssertFalse( + shouldBypassBrowserPlainTextKeyEquivalent( + keyCode: 0, + firstResponderIsBrowser: true, + firstResponderHasMarkedText: true, + flags: [], + characters: "a", + charactersIgnoringModifiers: "a" + ) + ) + } + + func testDoesNotBypassModifiedLetterShortcuts() { + XCTAssertFalse( + shouldBypassBrowserPlainTextKeyEquivalent( + keyCode: 0, + firstResponderIsBrowser: true, + flags: [.command], + characters: "a", + charactersIgnoringModifiers: "a" + ) + ) + XCTAssertFalse( + shouldBypassBrowserPlainTextKeyEquivalent( + keyCode: 0, + firstResponderIsBrowser: true, + flags: [.option], + characters: "å", + charactersIgnoringModifiers: "a" + ) + ) + XCTAssertFalse( + shouldBypassBrowserPlainTextKeyEquivalent( + keyCode: 0, + firstResponderIsBrowser: true, + flags: [.control], + characters: "\u{1}", + charactersIgnoringModifiers: "a" + ) + ) + } + + func testDoesNotBypassDedicatedBrowserKeyRoutes() { + XCTAssertFalse( + shouldBypassBrowserPlainTextKeyEquivalent( + keyCode: 49, + firstResponderIsBrowser: true, + flags: [], + characters: " ", + charactersIgnoringModifiers: " " + ) + ) + XCTAssertFalse( + shouldBypassBrowserPlainTextKeyEquivalent( + keyCode: 36, + firstResponderIsBrowser: true, + flags: [], + characters: "\r", + charactersIgnoringModifiers: "\r" + ) + ) + XCTAssertFalse( + shouldBypassBrowserPlainTextKeyEquivalent( + keyCode: 125, + firstResponderIsBrowser: true, + flags: [], + characters: "\u{F701}", + charactersIgnoringModifiers: "\u{F701}" + ) + ) + } +} final class BrowserZoomShortcutActionTests: XCTestCase { func testZoomInSupportsEqualsAndPlusVariants() { @@ -3315,7 +3484,7 @@ final class CmuxWebViewDragRoutingTests: XCTestCase { func testRejectsInternalPaneDragEvenWhenFilePromiseTypesArePresent() { XCTAssertTrue( CmuxWebView.shouldRejectInternalPaneDrag([ - DragOverlayRoutingPolicy.bonsplitTabTransferType, + DragOverlayRoutingPolicy.splitTabTransferType, NSPasteboard.PasteboardType("com.apple.pasteboard.promised-file-url"), ]) ) @@ -3658,3 +3827,99 @@ final class BrowserOmnibarFocusPolicyTests: XCTestCase { ) } } + +final class BrowserFindOverlayPresentationTests: XCTestCase { + func testLocalInlineBrowserUsesSwiftUIFindOverlay() { + XCTAssertEqual( + resolveBrowserFindOverlayPresentationMode( + hasSearchState: true, + shouldRenderWebView: true, + usesLocalInlineHosting: true + ), + .swiftUI + ) + } + + func testPortalBrowserUsesPortalFindOverlay() { + XCTAssertEqual( + resolveBrowserFindOverlayPresentationMode( + hasSearchState: true, + shouldRenderWebView: true, + usesLocalInlineHosting: false + ), + .appKitPortal + ) + } + + func testPlaceholderBrowserUsesSwiftUIFindOverlay() { + XCTAssertEqual( + resolveBrowserFindOverlayPresentationMode( + hasSearchState: true, + shouldRenderWebView: false, + usesLocalInlineHosting: false + ), + .swiftUI + ) + } + + func testNoSearchStateUsesNoOverlay() { + XCTAssertEqual( + resolveBrowserFindOverlayPresentationMode( + hasSearchState: false, + shouldRenderWebView: true, + usesLocalInlineHosting: true + ), + .none + ) + } + + func testBrowserSearchOverlayPanelIdResolvesFieldEditorDelegate() throws { + _ = NSApplication.shared + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 120), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let field = NSTextField(frame: NSRect(x: 20, y: 40, width: 200, height: 24)) + let panelId = UUID() + setBrowserSearchOverlayPanelId(panelId, on: field) + window.contentView?.addSubview(field) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + + XCTAssertTrue(window.makeFirstResponder(field)) + + let firstResponder = try XCTUnwrap(window.firstResponder) + XCTAssertEqual(browserSearchOverlayPanelId(for: firstResponder), panelId) + XCTAssertEqual(browserSearchOverlayPanelId(for: field), panelId) + } + + func testBrowserSearchOverlayPanelIdResolvesFieldEditorItself() throws { + _ = NSApplication.shared + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 120), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let field = NSTextField(frame: NSRect(x: 20, y: 40, width: 200, height: 24)) + window.contentView?.addSubview(field) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + + XCTAssertTrue(window.makeFirstResponder(field)) + + let firstResponder = try XCTUnwrap(window.firstResponder as? NSTextView) + let panelId = UUID() + setBrowserSearchOverlayPanelId(panelId, on: firstResponder) + + XCTAssertEqual(browserSearchOverlayPanelId(for: firstResponder), panelId) + } +} diff --git a/cmuxTests/BrowserPanelTests.swift b/cmuxTests/BrowserPanelTests.swift index abe4cb26bd67..e291b5ab3946 100644 --- a/cmuxTests/BrowserPanelTests.swift +++ b/cmuxTests/BrowserPanelTests.swift @@ -4,7 +4,6 @@ import SwiftUI import UniformTypeIdentifiers import WebKit import ObjectiveC.runtime -import Bonsplit import UserNotifications #if canImport(cmux_DEV) @@ -21,6 +20,32 @@ private func drainBrowserPanelMainQueue() { XCTWaiter().wait(for: [expectation], timeout: 1.0) } +private final class BrowserPanelTestNavigationDelegate: NSObject, WKNavigationDelegate { + let onFinish: () -> Void + + init(onFinish: @escaping () -> Void) { + self.onFinish = onFinish + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + onFinish() + } +} + +@MainActor +private func waitForBrowserPanelCondition( + timeout: TimeInterval = 1.0, + file: StaticString = #filePath, + line: UInt = #line, + _ condition: () -> Bool +) { + let deadline = Date().addingTimeInterval(timeout) + while !condition(), Date() < deadline { + RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) + } + XCTAssertTrue(condition(), file: file, line: line) +} + @MainActor private func makeTemporaryBrowserPanelProfile(named prefix: String) throws -> BrowserProfileDefinition { try XCTUnwrap( @@ -57,143 +82,1531 @@ final class BrowserPanelChromeBackgroundColorTests: XCTestCase { return } - XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.001, file: file, line: line) - XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.001, file: file, line: line) - XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.001, file: file, line: line) - XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.001, file: file, line: line) - } -} + XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.001, file: file, line: line) + } +} + + +final class BrowserPanelOmnibarPillBackgroundColorTests: XCTestCase { + func testLightModeSlightlyDarkensThemeBackground() { + assertResolvedColorMatchesExpectedBlend(for: .light, darkenMix: 0.04) + } + + func testDarkModeSlightlyDarkensThemeBackground() { + assertResolvedColorMatchesExpectedBlend(for: .dark, darkenMix: 0.05) + } + + private func assertResolvedColorMatchesExpectedBlend( + for colorScheme: ColorScheme, + darkenMix: CGFloat, + file: StaticString = #filePath, + line: UInt = #line + ) { + let themeBackground = NSColor(srgbRed: 0.94, green: 0.93, blue: 0.91, alpha: 1.0) + let expected = themeBackground.blended(withFraction: darkenMix, of: .black) ?? themeBackground + + guard + let actual = resolvedBrowserOmnibarPillBackgroundColor( + for: colorScheme, + themeBackgroundColor: themeBackground + ).usingColorSpace(.sRGB), + let expectedSRGB = expected.usingColorSpace(.sRGB), + let themeSRGB = themeBackground.usingColorSpace(.sRGB) + else { + XCTFail("Expected sRGB-convertible colors", file: file, line: line) + return + } + + XCTAssertEqual(actual.redComponent, expectedSRGB.redComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.greenComponent, expectedSRGB.greenComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.blueComponent, expectedSRGB.blueComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.alphaComponent, expectedSRGB.alphaComponent, accuracy: 0.001, file: file, line: line) + XCTAssertNotEqual(actual.redComponent, themeSRGB.redComponent, file: file, line: line) + } +} + + +@MainActor +final class BrowserPanelProfileIsolationTests: XCTestCase { + func testStaleDidFinishDoesNotRecordVisitIntoSwitchedProfileHistory() throws { + let alternateProfile = try makeTemporaryBrowserPanelProfile(named: "Switched") + let defaultStore = BrowserHistoryStore.shared + let alternateStore = BrowserProfileStore.shared.historyStore(for: alternateProfile.id) + defaultStore.clearHistory() + alternateStore.clearHistory() + defer { + defaultStore.clearHistory() + alternateStore.clearHistory() + } + + let panel = BrowserPanel( + workspaceId: UUID(), + profileID: BrowserProfileStore.shared.builtInDefaultProfileID + ) + let staleWebView = panel.webView + let staleDelegate = try XCTUnwrap(staleWebView.navigationDelegate) + let staleURL = try XCTUnwrap(URL(string: "https://example.com/stale-finish")) + staleWebView.loadHTMLString( + "Stalestale", + baseURL: staleURL + ) + + XCTAssertTrue( + panel.switchToProfile(alternateProfile.id), + "Expected profile switch to succeed, current=\(panel.profileID) requested=\(alternateProfile.id) exists=\(BrowserProfileStore.shared.profileDefinition(id: alternateProfile.id) != nil)" + ) + defaultStore.clearHistory() + alternateStore.clearHistory() + + staleDelegate.webView?(staleWebView, didFinish: nil) + drainBrowserPanelMainQueue() + + XCTAssertTrue( + defaultStore.entries.isEmpty, + "Expected stale completion callbacks to avoid writing into the old profile history store, found \(defaultStore.entries.map { $0.url })" + ) + XCTAssertTrue( + alternateStore.entries.isEmpty, + "Expected stale completion callbacks to avoid writing into the newly selected profile history store, found \(alternateStore.entries.map { $0.url })" + ) + } +} + + +@MainActor +final class BrowserPanelAddressBarFocusRequestTests: XCTestCase { + func testRequestPersistsUntilAcknowledged() { + let panel = BrowserPanel(workspaceId: UUID()) + XCTAssertNil(panel.pendingAddressBarFocusRequestId) + + let requestId = panel.requestAddressBarFocus() + XCTAssertEqual(panel.pendingAddressBarFocusRequestId, requestId) + XCTAssertTrue(panel.shouldSuppressWebViewFocus()) + + panel.acknowledgeAddressBarFocusRequest(requestId) + XCTAssertNil(panel.pendingAddressBarFocusRequestId) + XCTAssertTrue(panel.isAddressBarFocused) + + // Acknowledgement only clears the durable request; focus suppression follows + // explicit blur state transitions. + XCTAssertTrue(panel.shouldSuppressWebViewFocus()) + panel.setAddressBarFocused(false, reason: "test") + XCTAssertFalse(panel.shouldSuppressWebViewFocus()) + } + + func testRequestCoalescesWhilePending() { + let panel = BrowserPanel(workspaceId: UUID()) + let firstRequest = panel.requestAddressBarFocus() + let secondRequest = panel.requestAddressBarFocus() + + XCTAssertEqual(firstRequest, secondRequest) + XCTAssertEqual(panel.pendingAddressBarFocusRequestId, firstRequest) + } + + func testStaleAcknowledgementDoesNotClearNewestRequest() { + let panel = BrowserPanel(workspaceId: UUID()) + let firstRequest = panel.requestAddressBarFocus() + panel.acknowledgeAddressBarFocusRequest(firstRequest) + let secondRequest = panel.requestAddressBarFocus() + + XCTAssertNotEqual(firstRequest, secondRequest) + XCTAssertEqual(panel.pendingAddressBarFocusRequestId, secondRequest) + + panel.acknowledgeAddressBarFocusRequest(firstRequest) + XCTAssertEqual(panel.pendingAddressBarFocusRequestId, secondRequest) + + panel.acknowledgeAddressBarFocusRequest(secondRequest) + XCTAssertNil(panel.pendingAddressBarFocusRequestId) + } + + func testAddressBarBlurClearsPreferredFocusIntentAndSuppression() { + let panel = BrowserPanel(workspaceId: UUID()) + let requestId = panel.requestAddressBarFocus() + panel.acknowledgeAddressBarFocusRequest(requestId) + + XCTAssertEqual(panel.preferredFocusIntentForActivation(), .browser(.addressBar)) + XCTAssertTrue(panel.isAddressBarFocused) + XCTAssertTrue(panel.shouldSuppressWebViewFocus()) + + panel.setAddressBarFocused(false, reason: "test") + + XCTAssertFalse(panel.isAddressBarFocused) + XCTAssertNil(panel.pendingAddressBarFocusRequestId) + XCTAssertEqual(panel.preferredFocusIntentForActivation(), .browser(.webView)) + XCTAssertFalse(panel.shouldSuppressWebViewFocus()) + } + + func testWebViewFocusClearsStaleAddressBarIntent() { + let panel = BrowserPanel(workspaceId: UUID()) + let requestId = panel.requestAddressBarFocus() + panel.acknowledgeAddressBarFocusRequest(requestId) + + XCTAssertEqual(panel.preferredFocusIntentForActivation(), .browser(.addressBar)) + XCTAssertTrue(panel.isAddressBarFocused) + + panel.noteWebViewFocused() + + XCTAssertFalse(panel.isAddressBarFocused) + XCTAssertEqual(panel.preferredFocusIntentForActivation(), .browser(.webView)) + XCTAssertFalse(panel.shouldSuppressWebViewFocus()) + } +} + +@MainActor +private final class BrowserPanelFakeWebContentResponderView: NSView { + override var acceptsFirstResponder: Bool { true } + + override func becomeFirstResponder() -> Bool { + true + } + + @objc(insertText:replacementRange:) + func insertText(_ string: Any, replacementRange: NSRange) { + _ = string + _ = replacementRange + } +} + +private final class BrowserPanelKeyStateTestWindow: NSWindow { + var testIsKeyWindow = true + var nilFirstResponderRequestCount = 0 + + override var isKeyWindow: Bool { + testIsKeyWindow + } + + override func makeFirstResponder(_ responder: NSResponder?) -> Bool { + if responder == nil { + nilFirstResponderRequestCount += 1 + } + return super.makeFirstResponder(responder) + } +} + +@MainActor +final class BrowserPanelFindFocusRequestTests: XCTestCase { + @MainActor + func testActualFocusDistinguishesWebViewWrapperFromWebContent() throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + + let contentResponder = BrowserPanelFakeWebContentResponderView(frame: panel.webView.bounds) + contentResponder.autoresizingMask = [.width, .height] + panel.webView.addSubview(contentResponder) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + + let cmuxWebView = try XCTUnwrap(panel.webView as? CmuxWebView) + cmuxWebView.allowsFirstResponderAcquisition = true + XCTAssertTrue(window.makeFirstResponder(panel.webView)) + XCTAssertEqual(panel.actualFocus(in: window), .browserWebViewWrapper(panel.id)) + + XCTAssertTrue(window.makeFirstResponder(contentResponder)) + XCTAssertEqual(panel.actualFocus(in: window), .browserWebContent(panel.id)) + } + + @MainActor + func testTransitionToWebContentFocusRestoresWebContentResponder() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + + let navigation = expectation(description: "test page loaded") + let delegate = BrowserPanelTestNavigationDelegate { + navigation.fulfill() + } + panel.webView.navigationDelegate = delegate + + panel.webView.loadHTMLString( + """ + + + + + + """, + baseURL: nil + ) + await fulfillment(of: [navigation], timeout: 2.0) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + let chromeField = NSTextField(frame: NSRect(x: 20, y: 20, width: 180, height: 24)) + contentView.addSubview(chromeField) + XCTAssertTrue(window.makeFirstResponder(chromeField)) + + let restored = expectation(description: "web content focus restored") + _ = panel.transitionToWebContentFocus( + reason: "test", + yieldFindFieldResponder: false, + completion: { focused in + XCTAssertTrue(focused) + restored.fulfill() + } + ) + await fulfillment(of: [restored], timeout: 2.0) + + XCTAssertTrue(panel.hasWebViewFirstResponder(in: window)) + _ = delegate + } + + @MainActor + func testSpaceKeyDeliveryAfterRestoredWebContentFocusMutatesFocusedPageInput() async throws { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + + let navigation = expectation(description: "space probe page loaded") + let delegate = BrowserPanelTestNavigationDelegate { + navigation.fulfill() + } + panel.webView.navigationDelegate = delegate + panel.webView.loadHTMLString( + """ + + + + + + """, + baseURL: nil + ) + await fulfillment(of: [navigation], timeout: 2.0) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + panel.notePanelFocusChanged(true) + panel.prepareFocusIntentForActivation(.browser(.webView)) + XCTAssertTrue(panel.requestMountedContentWebViewFocus(in: window, reason: "spaceProbe")) + XCTAssertEqual(panel.actualFocus(in: window), .browserWebViewWrapper(panel.id)) + + let activeElementId = try await panel.evaluateJavaScript("document.activeElement && document.activeElement.id") as? String + XCTAssertEqual(activeElementId, "target") + + let spaceEvent = try XCTUnwrap( + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: " ", + charactersIgnoringModifiers: " ", + isARepeat: false, + keyCode: 49 + ) + ) + + XCTAssertTrue( + window.performKeyEquivalent(with: spaceEvent), + "Space should be claimed by cmux only after it is forwarded to WebKit keyDown." + ) + + func probeSnapshot() async throws -> [String: Any] { + let evaluated = try await panel.evaluateJavaScript( + """ + JSON.stringify({ + value: target.value, + start: target.selectionStart, + end: target.selectionEnd, + active: document.activeElement === target ? "target" : document.activeElement && (document.activeElement.id || document.activeElement.tagName) || "nil", + events: window.__cmuxSpaceProbe + }) + """ + ) + let json = try XCTUnwrap(evaluated as? String) + let object = try JSONSerialization.jsonObject(with: Data(json.utf8), options: []) + return try XCTUnwrap(object as? [String: Any]) + } + + var snapshot: [String: Any] = [:] + for _ in 0..<50 { + snapshot = try await probeSnapshot() + if snapshot["value"] as? String == "hello " { + break + } + try await Task.sleep(nanoseconds: 10_000_000) + } + + XCTAssertEqual(snapshot["value"] as? String, "hello ", "Probe snapshot: \(snapshot)") + XCTAssertEqual(snapshot["start"] as? Int, 6, "Probe snapshot: \(snapshot)") + XCTAssertEqual(snapshot["end"] as? Int, 6, "Probe snapshot: \(snapshot)") + XCTAssertEqual(snapshot["active"] as? String, "target", "Probe snapshot: \(snapshot)") + + let events = try XCTUnwrap(snapshot["events"] as? [[String: Any]]) + XCTAssertTrue(events.contains { $0["kind"] as? String == "keydown" && $0["key"] as? String == " " }) + XCTAssertTrue(events.contains { $0["kind"] as? String == "beforeinput" && $0["data"] as? String == " " }) + XCTAssertTrue(events.contains { $0["kind"] as? String == "input" && $0["data"] as? String == " " }) + _ = delegate + } + + @MainActor + func testPaneBlurPreservesWebViewResponderUntilNextPanelClaimsFocus() throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + + XCTAssertTrue(panel.requestExplicitWebViewFocus()) + XCTAssertTrue(panel.hasWebViewFirstResponder(in: window)) + + panel.unfocus() + XCTAssertTrue(panel.hasWebViewFirstResponder(in: window)) + + let terminalSurrogate = BrowserPanelFakeWebContentResponderView( + frame: NSRect(x: 20, y: 20, width: 180, height: 24) + ) + contentView.addSubview(terminalSurrogate) + XCTAssertTrue(window.makeFirstResponder(terminalSurrogate)) + + let intent = PanelFocusIntent.browser(.webView) + panel.prepareFocusIntentForActivation(intent) + XCTAssertTrue(panel.requestMountedContentWebViewFocus(in: window, reason: "testPaneFocus")) + XCTAssertTrue(panel.hasWebViewFirstResponder(in: window)) + } + + @MainActor + func testPaneRestoreKeepsWrapperFocusWithoutNilResponderBounce() throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + + let cmuxWebView = try XCTUnwrap(panel.webView as? CmuxWebView) + cmuxWebView.allowsFirstResponderAcquisition = true + XCTAssertTrue(window.makeFirstResponder(panel.webView)) + XCTAssertTrue(window.firstResponder === panel.webView) + + let nilRequestsBeforeRestore = window.nilFirstResponderRequestCount + XCTAssertTrue(panel.requestMountedContentWebViewFocus(in: window, reason: "rapidPaneRestore")) + XCTAssertEqual(panel.actualFocus(in: window), .browserWebViewWrapper(panel.id)) + XCTAssertEqual(window.nilFirstResponderRequestCount, nilRequestsBeforeRestore) + XCTAssertEqual(panel.captureFocusIntent(in: window), .browser(.webView)) + } + + @MainActor + func testMountedContentFocusUsesWebViewWrapperAsNativeFocusOwner() throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + + let contentResponder = BrowserPanelFakeWebContentResponderView(frame: panel.webView.bounds) + contentResponder.autoresizingMask = [.width, .height] + panel.webView.addSubview(contentResponder) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + + let chromeField = NSTextField(frame: NSRect(x: 20, y: 20, width: 180, height: 24)) + contentView.addSubview(chromeField) + XCTAssertTrue(window.makeFirstResponder(chromeField)) + + let intent = PanelFocusIntent.browser(.webView) + panel.prepareFocusIntentForActivation(intent) + XCTAssertTrue(panel.requestMountedContentWebViewFocus(in: window, reason: "testContentFocus")) + XCTAssertTrue(window.firstResponder === panel.webView) + XCTAssertEqual(panel.actualFocus(in: window), .browserWebViewWrapper(panel.id)) + XCTAssertEqual(panel.captureFocusIntent(in: window), .browser(.webView)) + } + + @MainActor + func testMountedContentFocusDoesNotPromoteDeepInternalResponder() throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + + let containerResponder = BrowserPanelFakeWebContentResponderView(frame: panel.webView.bounds) + containerResponder.autoresizingMask = [.width, .height] + panel.webView.addSubview(containerResponder) + + let contentResponder = BrowserPanelFakeWebContentResponderView(frame: containerResponder.bounds) + contentResponder.autoresizingMask = [.width, .height] + containerResponder.addSubview(contentResponder) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + + let chromeField = NSTextField(frame: NSRect(x: 20, y: 20, width: 180, height: 24)) + contentView.addSubview(chromeField) + XCTAssertTrue(window.makeFirstResponder(chromeField)) + + panel.prepareFocusIntentForActivation(.browser(.webView)) + XCTAssertTrue(panel.requestMountedContentWebViewFocus(in: window, reason: "deepContentFocus")) + XCTAssertTrue(window.firstResponder === panel.webView) + XCTAssertEqual(panel.actualFocus(in: window), .browserWebViewWrapper(panel.id)) + } + + @MainActor + func testYieldWebContentFocusDoesNotBounceThroughNilResponder() throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + + let contentResponder = BrowserPanelFakeWebContentResponderView(frame: panel.webView.bounds) + contentResponder.autoresizingMask = [.width, .height] + panel.webView.addSubview(contentResponder) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + panel.prepareFocusIntentForActivation(.browser(.webView)) + XCTAssertTrue(panel.requestMountedContentWebViewFocus(in: window, reason: "yieldPreserve")) + XCTAssertTrue(window.firstResponder === panel.webView) + + XCTAssertTrue(window.makeFirstResponder(contentResponder)) + XCTAssertTrue(window.firstResponder === contentResponder) + + let nilRequestsBeforeYield = window.nilFirstResponderRequestCount + XCTAssertTrue(panel.yieldFocusIntent(.browser(.webView), in: window)) + + XCTAssertTrue(window.firstResponder === contentResponder) + XCTAssertEqual(window.nilFirstResponderRequestCount, nilRequestsBeforeYield) + } + + func testStartFindIssuesDurableRequestUntilAcknowledged() throws { + let panel = BrowserPanel(workspaceId: UUID()) + + XCTAssertNil(panel.pendingFindFieldFocusRequestId) + + panel.startFind() + + let requestId = try XCTUnwrap(panel.pendingFindFieldFocusRequestId) + XCTAssertNotNil(panel.searchState) + XCTAssertEqual(panel.preferredFocusIntentForActivation(), .browser(.findField)) + XCTAssertTrue(panel.canApplyFindFieldFocusRequest(requestId)) + + panel.noteFindFieldFocused(requestId: requestId) + + XCTAssertNil(panel.pendingFindFieldFocusRequestId) + XCTAssertEqual(panel.preferredFocusIntentForActivation(), .browser(.findField)) + } + + func testRestoreFindFocusReissuesRequestWithoutRecreatingSearchState() throws { + let panel = BrowserPanel(workspaceId: UUID()) + + panel.startFind() + let initialSearchState = try XCTUnwrap(panel.searchState) + let firstRequest = try XCTUnwrap(panel.pendingFindFieldFocusRequestId) + panel.noteFindFieldFocused(requestId: firstRequest) + + XCTAssertTrue(panel.restoreFocusIntent(.browser(.findField))) + XCTAssertTrue(panel.searchState === initialSearchState) + + let secondRequest = try XCTUnwrap(panel.pendingFindFieldFocusRequestId) + XCTAssertNotEqual(firstRequest, secondRequest) + XCTAssertTrue(panel.canApplyFindFieldFocusRequest(secondRequest)) + } + + func testPassiveWebViewObservationDoesNotOverridePendingFindIntent() throws { + let panel = BrowserPanel(workspaceId: UUID()) + + panel.startFind() + let requestId = try XCTUnwrap(panel.pendingFindFieldFocusRequestId) + + panel.noteWebViewFocused(source: .passiveObservation) + + XCTAssertEqual(panel.pendingFindFieldFocusRequestId, requestId) + XCTAssertEqual(panel.preferredFocusIntentForActivation(), .browser(.findField)) + } + + func testPassiveWebViewObservationDoesNotOverrideActiveFindIntent() throws { + let panel = BrowserPanel(workspaceId: UUID()) + + panel.startFind() + let requestId = try XCTUnwrap(panel.pendingFindFieldFocusRequestId) + panel.noteFindFieldFocused(requestId: requestId) + + panel.noteWebViewFocused(source: .passiveObservation) + + XCTAssertNil(panel.pendingFindFieldFocusRequestId) + XCTAssertEqual(panel.preferredFocusIntentForActivation(), .browser(.findField)) + } + + func testWebViewFocusCanOverrideFindIntentWhileFindBarStaysVisible() throws { + let panel = BrowserPanel(workspaceId: UUID()) + + panel.startFind() + let requestId = try XCTUnwrap(panel.pendingFindFieldFocusRequestId) + panel.noteFindFieldFocused(requestId: requestId) + XCTAssertEqual(panel.preferredFocusIntentForActivation(), .browser(.findField)) + + panel.noteWebViewFocused(source: .explicit) + + XCTAssertNotNil(panel.searchState) + XCTAssertEqual(panel.preferredFocusIntentForActivation(), .browser(.webView)) + } + + func testHideFindReturnsPreferredIntentToWebView() throws { + let panel = BrowserPanel(workspaceId: UUID()) + + panel.startFind() + let requestId = try XCTUnwrap(panel.pendingFindFieldFocusRequestId) + panel.noteFindFieldFocused(requestId: requestId) + + panel.hideFind(reason: "test") + + XCTAssertNil(panel.searchState) + XCTAssertEqual(panel.preferredFocusIntentForActivation(), .browser(.webView)) + XCTAssertNil(panel.pendingFindFieldFocusRequestId) + } + + func testHideFindStartsPendingWebContentRestoreImmediately() throws { + let panel = BrowserPanel(workspaceId: UUID()) + + panel.startFind() + let requestId = try XCTUnwrap(panel.pendingFindFieldFocusRequestId) + panel.noteFindFieldFocused(requestId: requestId) + + panel.hideFind(reason: "test") + + XCTAssertNil(panel.searchState) + XCTAssertEqual(panel.preferredFocusIntentForActivation(), .browser(.webView)) + + let pendingRestoreRequest = try XCTUnwrap(panel.pendingWebContentRestoreRequestId) + XCTAssertEqual(panel.preferredFocusIntentForActivation(), .browser(.webView)) + + panel.startFind() + + XCTAssertNotEqual(panel.pendingFindFieldFocusRequestId, pendingRestoreRequest) + XCTAssertNil(panel.pendingWebContentRestoreRequestId) + } + + func testFindOverlayDisappearRequestsFindFocusWhenFindStaysVisible() throws { + let panel = BrowserPanel(workspaceId: UUID()) + + panel.startFind() + let requestId = try XCTUnwrap(panel.pendingFindFieldFocusRequestId) + panel.noteFindFieldFocused(requestId: requestId) + + panel.noteFindOverlayDisappeared(source: "test") + + XCTAssertNotNil(panel.searchState) + XCTAssertNotEqual(panel.pendingFindFieldFocusRequestId, requestId) + XCTAssertEqual(panel.preferredFocusIntentForActivation(), .browser(.findField)) + } + + func testPendingFindDismissRestoreWaitsForPaneFocusAndOverlayTeardownBeforeTakingWebViewFocus() throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + + let findField = NSTextField(frame: NSRect(x: 20, y: 20, width: 180, height: 24)) + setBrowserSearchOverlayPanelId(panel.id, on: findField) + contentView.addSubview(findField) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + XCTAssertTrue(window.makeFirstResponder(findField)) + + panel.startFind() + let requestId = try XCTUnwrap(panel.pendingFindFieldFocusRequestId) + panel.noteFindFieldFocused(requestId: requestId) + panel.hideFind(reason: "test") + + XCTAssertNotNil(panel.pendingWebContentRestoreRequestId) + XCTAssertNil(panel.searchState) + XCTAssertEqual(browserSearchOverlayPanelId(for: window.firstResponder), panel.id) + + panel.notePanelFocusChanged(true) + XCTAssertNotNil(panel.pendingWebContentRestoreRequestId) + XCTAssertEqual(browserSearchOverlayPanelId(for: window.firstResponder), panel.id) + + XCTAssertTrue(window.makeFirstResponder(nil)) + panel.noteFindFieldEndedEditing(requestId: requestId, source: "test") + findField.removeFromSuperview() + panel.noteFindOverlayDisappeared(source: "test") + waitForBrowserPanelCondition { + panel.pendingWebContentRestoreRequestId == nil + } + XCTAssertNil(panel.pendingWebContentRestoreRequestId) + XCTAssertEqual(panel.captureFocusIntent(in: window), .browser(.webView)) + } + + @MainActor + func testFindDismissRestoreWaitsForFocusedFindFieldEditingToEndAfterOverlayTeardown() throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + + let findField = NSTextField(frame: NSRect(x: 20, y: 20, width: 180, height: 24)) + setBrowserSearchOverlayPanelId(panel.id, on: findField) + contentView.addSubview(findField) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + XCTAssertTrue(window.makeFirstResponder(findField)) + + panel.startFind() + let requestId = try XCTUnwrap(panel.pendingFindFieldFocusRequestId) + panel.noteFindFieldMounted(requestId: requestId) + panel.noteFindFieldFocused(requestId: requestId) + XCTAssertEqual(browserSearchOverlayPanelId(for: window.firstResponder), panel.id) + + panel.hideFind(reason: "test") + XCTAssertNotNil(panel.pendingWebContentRestoreRequestId) + XCTAssertNil(panel.searchState) + + panel.notePanelFocusChanged(true) + panel.noteFindOverlayDisappeared(source: "test") + + XCTAssertNotNil( + panel.pendingWebContentRestoreRequestId, + "Focused find field editing has not ended yet, so restoring WKWebView focus here can be overwritten by AppKit field-editor teardown." + ) + XCTAssertEqual(browserSearchOverlayPanelId(for: window.firstResponder), panel.id) + + panel.noteFindFieldEndedEditing(requestId: requestId, source: "test") + waitForBrowserPanelCondition { + panel.pendingWebContentRestoreRequestId == nil + } + XCTAssertNil(panel.pendingWebContentRestoreRequestId) + XCTAssertEqual(panel.captureFocusIntent(in: window), .browser(.webView)) + } + + @MainActor + func testExplicitWebViewFocusReactivatesWrapperFromExistingInternalResponder() throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + + let contentResponder = BrowserPanelFakeWebContentResponderView(frame: panel.webView.bounds) + contentResponder.autoresizingMask = [.width, .height] + panel.webView.addSubview(contentResponder) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + + let cmuxWebView = try XCTUnwrap(panel.webView as? CmuxWebView) + cmuxWebView.allowsFirstResponderAcquisition = true + XCTAssertTrue(window.makeFirstResponder(contentResponder)) + XCTAssertTrue(window.firstResponder === contentResponder) + cmuxWebView.allowsFirstResponderAcquisition = false + + XCTAssertTrue(panel.requestExplicitWebViewFocus()) + XCTAssertTrue(window.firstResponder === panel.webView) + XCTAssertEqual(panel.actualFocus(in: window), .browserWebViewWrapper(panel.id)) + XCTAssertEqual(panel.captureFocusIntent(in: window), .browser(.webView)) + } + + @MainActor + func testExplicitWebViewFocusKeepsWrapperAsNativeFocusOwner() throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + + let contentResponder = BrowserPanelFakeWebContentResponderView(frame: panel.webView.bounds) + contentResponder.autoresizingMask = [.width, .height] + panel.webView.addSubview(contentResponder) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + + let cmuxWebView = try XCTUnwrap(panel.webView as? CmuxWebView) + cmuxWebView.allowsFirstResponderAcquisition = true + XCTAssertTrue(window.makeFirstResponder(panel.webView)) + XCTAssertTrue(window.firstResponder === panel.webView) + cmuxWebView.allowsFirstResponderAcquisition = false + + XCTAssertTrue(panel.requestExplicitWebViewFocus()) + XCTAssertTrue(window.firstResponder === panel.webView) + XCTAssertEqual(panel.actualFocus(in: window), .browserWebViewWrapper(panel.id)) + XCTAssertEqual(panel.captureFocusIntent(in: window), .browser(.webView)) + } + + @MainActor + func testExplicitWebViewFocusUsesPublicWebViewWhenLeavingBrowserChrome() throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + + let chromeField = NSTextField(frame: NSRect(x: 20, y: 20, width: 180, height: 24)) + contentView.addSubview(chromeField) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + + XCTAssertTrue(window.makeFirstResponder(chromeField)) + XCTAssertTrue(panel.requestExplicitWebViewFocus()) + XCTAssertTrue(panel.hasWebViewFirstResponder(in: window)) + XCTAssertEqual(panel.captureFocusIntent(in: window), .browser(.webView)) + } + + @MainActor + func testExplicitWebViewFocusYieldsOwnedBrowserFindFieldResponder() throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + + let findField = NSTextField(frame: NSRect(x: 20, y: 20, width: 180, height: 24)) + setBrowserSearchOverlayPanelId(panel.id, on: findField) + contentView.addSubview(findField) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + + XCTAssertTrue(window.makeFirstResponder(findField)) + XCTAssertEqual(browserSearchOverlayPanelId(for: window.firstResponder), panel.id) + + XCTAssertTrue(panel.requestExplicitWebViewFocus()) + XCTAssertEqual(panel.captureFocusIntent(in: window), .browser(.webView)) + } + + @MainActor + func testExplicitWebViewFocusBypassesStaleFalseResponderPolicy() throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + + let cmuxWebView = try XCTUnwrap(panel.webView as? CmuxWebView) + cmuxWebView.allowsFirstResponderAcquisition = false + + XCTAssertFalse(window.makeFirstResponder(panel.webView)) + XCTAssertTrue(panel.requestExplicitWebViewFocus()) + XCTAssertEqual(panel.captureFocusIntent(in: window), .browser(.webView)) + } + + @MainActor + func testWindowActivationReassertsFocusedWebContentIntent() throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + + let cmuxWebView = try XCTUnwrap(panel.webView as? CmuxWebView) + cmuxWebView.allowsFirstResponderAcquisition = true + XCTAssertTrue(window.makeFirstResponder(panel.webView)) + XCTAssertTrue(window.firstResponder === panel.webView) + cmuxWebView.allowsFirstResponderAcquisition = false + + panel.notePanelFocusChanged(true) + cmuxWebView.allowsFirstResponderAcquisition = false + + let nilRequestsBeforeReassert = window.nilFirstResponderRequestCount + XCTAssertTrue(panel.reassertWebContentFocusAfterWindowActivation(in: window, reason: "test")) + XCTAssertEqual(panel.actualFocus(in: window), .browserWebViewWrapper(panel.id)) + XCTAssertEqual(window.nilFirstResponderRequestCount, nilRequestsBeforeReassert) + XCTAssertTrue(cmuxWebView.allowsFirstResponderAcquisition) + XCTAssertEqual(panel.captureFocusIntent(in: window), .browser(.webView)) + } + + @MainActor + func testKeyboardInputRepairRestoresFocusedWebContentIntent() throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + + let staleResponder = BrowserPanelFakeWebContentResponderView( + frame: NSRect(x: 20, y: 20, width: 180, height: 24) + ) + contentView.addSubview(staleResponder) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + + panel.notePanelFocusChanged(true) + panel.prepareFocusIntentForActivation(.browser(.webView)) + + let cmuxWebView = try XCTUnwrap(panel.webView as? CmuxWebView) + cmuxWebView.allowsFirstResponderAcquisition = false + XCTAssertTrue(window.makeFirstResponder(staleResponder)) + XCTAssertFalse(panel.hasWebViewFirstResponder(in: window)) + + XCTAssertTrue(panel.repairWebContentFocusForKeyboardInput(in: window, reason: "test")) + + XCTAssertTrue(cmuxWebView.allowsFirstResponderAcquisition) + XCTAssertTrue(panel.hasWebViewFirstResponder(in: window)) + XCTAssertEqual(panel.captureFocusIntent(in: window), .browser(.webView)) + } + + @MainActor + func testKeyboardInputRepairCanRestoreWebContentDuringKeyDispatchWindowMismatch() throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + + let staleResponder = BrowserPanelFakeWebContentResponderView( + frame: NSRect(x: 20, y: 20, width: 180, height: 24) + ) + contentView.addSubview(staleResponder) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + + panel.notePanelFocusChanged(true) + panel.prepareFocusIntentForActivation(.browser(.webView)) + XCTAssertTrue(window.makeFirstResponder(staleResponder)) + + window.testIsKeyWindow = false + let cmuxWebView = try XCTUnwrap(panel.webView as? CmuxWebView) + cmuxWebView.allowsFirstResponderAcquisition = false + XCTAssertTrue(panel.repairWebContentFocusForKeyboardInput(in: window, reason: "test")) + + XCTAssertFalse(cmuxWebView.allowsFirstResponderAcquisition) + XCTAssertTrue(panel.hasWebViewFirstResponder(in: window)) + XCTAssertEqual(panel.captureFocusIntent(in: window), .browser(.webView)) + } + + @MainActor + func testKeyboardInputRepairLeavesFocusedWrapperAlone() throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + + panel.notePanelFocusChanged(true) + panel.prepareFocusIntentForActivation(.browser(.webView)) + + let cmuxWebView = try XCTUnwrap(panel.webView as? CmuxWebView) + cmuxWebView.allowsFirstResponderAcquisition = true + XCTAssertTrue(window.makeFirstResponder(panel.webView)) + XCTAssertEqual(panel.actualFocus(in: window), .browserWebViewWrapper(panel.id)) + + cmuxWebView.allowsFirstResponderAcquisition = false + let nilRequestsBeforeDefaultRepair = window.nilFirstResponderRequestCount + XCTAssertFalse( + panel.repairWebContentFocusForKeyboardInput( + in: window, + reason: "test", + repairFocusedWrapper: true + ) + ) + XCTAssertEqual(window.nilFirstResponderRequestCount, nilRequestsBeforeDefaultRepair) + XCTAssertEqual(panel.actualFocus(in: window), .browserWebViewWrapper(panel.id)) + + XCTAssertFalse(panel.repairWebContentFocusForKeyboardInput(in: window, reason: "test")) + + XCTAssertFalse(cmuxWebView.allowsFirstResponderAcquisition) + XCTAssertEqual(window.nilFirstResponderRequestCount, nilRequestsBeforeDefaultRepair) + XCTAssertTrue(panel.hasWebViewFirstResponder(in: window)) + XCTAssertEqual(panel.captureFocusIntent(in: window), .browser(.webView)) + } + + @MainActor + func testKeyboardInputRepairRehomesInternalResponderToWrapperWithoutNilBounce() throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + + let contentResponder = BrowserPanelFakeWebContentResponderView(frame: panel.webView.bounds) + contentResponder.autoresizingMask = [.width, .height] + panel.webView.addSubview(contentResponder) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + + panel.notePanelFocusChanged(true) + panel.prepareFocusIntentForActivation(.browser(.webView)) + let cmuxWebView = try XCTUnwrap(panel.webView as? CmuxWebView) + cmuxWebView.allowsFirstResponderAcquisition = true + XCTAssertTrue(window.makeFirstResponder(contentResponder)) + XCTAssertEqual(panel.actualFocus(in: window), .browserWebContent(panel.id)) -final class BrowserPanelOmnibarPillBackgroundColorTests: XCTestCase { - func testLightModeSlightlyDarkensThemeBackground() { - assertResolvedColorMatchesExpectedBlend(for: .light, darkenMix: 0.04) + cmuxWebView.allowsFirstResponderAcquisition = false + let nilRequestsBeforeRepair = window.nilFirstResponderRequestCount + XCTAssertTrue(panel.repairWebContentFocusForKeyboardInput(in: window, reason: "test")) + + XCTAssertEqual(window.nilFirstResponderRequestCount, nilRequestsBeforeRepair) + XCTAssertTrue(window.firstResponder === panel.webView) + XCTAssertEqual(panel.actualFocus(in: window), .browserWebViewWrapper(panel.id)) + XCTAssertEqual(panel.captureFocusIntent(in: window), .browser(.webView)) } - func testDarkModeSlightlyDarkensThemeBackground() { - assertResolvedColorMatchesExpectedBlend(for: .dark, darkenMix: 0.05) + @MainActor + func testWindowResignSuspendsWebContentPolicyButKeepsWebIntent() throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + contentView.layoutSubtreeIfNeeded() + + let cmuxWebView = try XCTUnwrap(panel.webView as? CmuxWebView) + cmuxWebView.allowsFirstResponderAcquisition = true + XCTAssertTrue(window.makeFirstResponder(panel.webView)) + + panel.notePanelFocusChanged(true) + XCTAssertTrue(cmuxWebView.allowsFirstResponderAcquisition) + + panel.noteOwningWindowDidResignKey(window, reason: "test") + + XCTAssertFalse(cmuxWebView.allowsFirstResponderAcquisition) + XCTAssertEqual(panel.preferredFocusIntentForActivation(), .browser(.webView)) + XCTAssertEqual(panel.captureFocusIntent(in: window), .browser(.webView)) } - private func assertResolvedColorMatchesExpectedBlend( - for colorScheme: ColorScheme, - darkenMix: CGFloat, - file: StaticString = #filePath, - line: UInt = #line - ) { - let themeBackground = NSColor(srgbRed: 0.94, green: 0.93, blue: 0.91, alpha: 1.0) - let expected = themeBackground.blended(withFraction: darkenMix, of: .black) ?? themeBackground + @MainActor + func testExplicitWebViewFocusDoesNotClaimInactiveWindow() throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } - guard - let actual = resolvedBrowserOmnibarPillBackgroundColor( - for: colorScheme, - themeBackgroundColor: themeBackground - ).usingColorSpace(.sRGB), - let expectedSRGB = expected.usingColorSpace(.sRGB), - let themeSRGB = themeBackground.usingColorSpace(.sRGB) - else { - XCTFail("Expected sRGB-convertible colors", file: file, line: line) - return - } + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + contentView.layoutSubtreeIfNeeded() - XCTAssertEqual(actual.redComponent, expectedSRGB.redComponent, accuracy: 0.001, file: file, line: line) - XCTAssertEqual(actual.greenComponent, expectedSRGB.greenComponent, accuracy: 0.001, file: file, line: line) - XCTAssertEqual(actual.blueComponent, expectedSRGB.blueComponent, accuracy: 0.001, file: file, line: line) - XCTAssertEqual(actual.alphaComponent, expectedSRGB.alphaComponent, accuracy: 0.001, file: file, line: line) - XCTAssertNotEqual(actual.redComponent, themeSRGB.redComponent, file: file, line: line) + window.testIsKeyWindow = false + let cmuxWebView = try XCTUnwrap(panel.webView as? CmuxWebView) + cmuxWebView.allowsFirstResponderAcquisition = false + + XCTAssertFalse(panel.requestExplicitWebViewFocus()) + + XCTAssertFalse(cmuxWebView.allowsFirstResponderAcquisition) + XCTAssertFalse(window.firstResponder === panel.webView) + XCTAssertEqual(panel.preferredFocusIntentForActivation(), .browser(.webView)) } -} + @MainActor + func testMountedHostWindowAttachReplaysWebFocusAfterOffWindowAddressBarCommit() throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + panel.notePanelFocusChanged(true) + let requestId = panel.requestAddressBarFocus() + panel.acknowledgeAddressBarFocusRequest(requestId) + panel.setAddressBarFocused(false, reason: "testNavigationCommit") -@MainActor -final class BrowserPanelProfileIsolationTests: XCTestCase { - func testStaleDidFinishDoesNotRecordVisitIntoSwitchedProfileHistory() throws { - let alternateProfile = try makeTemporaryBrowserPanelProfile(named: "Switched") - let defaultStore = BrowserHistoryStore.shared - let alternateStore = BrowserProfileStore.shared.historyStore(for: alternateProfile.id) - defaultStore.clearHistory() - alternateStore.clearHistory() - defer { - defaultStore.clearHistory() - alternateStore.clearHistory() - } + let cmuxWebView = try XCTUnwrap(panel.webView as? CmuxWebView) + XCTAssertFalse(cmuxWebView.allowsFirstResponderAcquisition) + XCTAssertEqual(panel.preferredFocusIntentForActivation(), .browser(.webView)) - let panel = BrowserPanel( - workspaceId: UUID(), - profileID: BrowserProfileStore.shared.builtInDefaultProfileID + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + contentView.layoutSubtreeIfNeeded() + + XCTAssertTrue( + panel.noteMountedWebViewHostDidMoveToWindow( + window, + shouldFocusWebView: true, + isPanelFocused: true, + reason: "test" + ) ) - let staleWebView = panel.webView - let staleDelegate = try XCTUnwrap(staleWebView.navigationDelegate) - let staleURL = try XCTUnwrap(URL(string: "https://example.com/stale-finish")) - staleWebView.loadHTMLString( - "Stalestale", - baseURL: staleURL + XCTAssertTrue(cmuxWebView.allowsFirstResponderAcquisition) + XCTAssertTrue(panel.hasWebViewFirstResponder(in: window)) + XCTAssertEqual(panel.captureFocusIntent(in: window), .browser(.webView)) + } + + @MainActor + func testNavigationFinishReassertsFocusedWebContentIntent() throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false ) + defer { window.orderOut(nil) } + + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + contentView.layoutSubtreeIfNeeded() + + let cmuxWebView = try XCTUnwrap(panel.webView as? CmuxWebView) + cmuxWebView.allowsFirstResponderAcquisition = false + panel.notePanelFocusChanged(true) XCTAssertTrue( - panel.switchToProfile(alternateProfile.id), - "Expected profile switch to succeed, current=\(panel.profileID) requested=\(alternateProfile.id) exists=\(BrowserProfileStore.shared.profileDefinition(id: alternateProfile.id) != nil)" + panel.reassertFocusedWebContentAfterNavigationFinish( + panel.webView, + reason: "test" + ) ) - defaultStore.clearHistory() - alternateStore.clearHistory() - staleDelegate.webView?(staleWebView, didFinish: nil) - drainBrowserPanelMainQueue() + XCTAssertTrue(cmuxWebView.allowsFirstResponderAcquisition) + XCTAssertTrue(panel.hasWebViewFirstResponder(in: window)) + XCTAssertEqual(panel.captureFocusIntent(in: window), .browser(.webView)) + } - XCTAssertTrue( - defaultStore.entries.isEmpty, - "Expected stale completion callbacks to avoid writing into the old profile history store, found \(defaultStore.entries.map { $0.url })" + @MainActor + func testNavigationFinishDoesNotStealFocusWhenPaneBlurred() throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false ) - XCTAssertTrue( - alternateStore.entries.isEmpty, - "Expected stale completion callbacks to avoid writing into the newly selected profile history store, found \(alternateStore.entries.map { $0.url })" + defer { window.orderOut(nil) } + + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + let terminalSurrogate = BrowserPanelFakeWebContentResponderView( + frame: NSRect(x: 20, y: 20, width: 180, height: 24) ) + contentView.addSubview(terminalSurrogate) + contentView.layoutSubtreeIfNeeded() + + XCTAssertTrue(window.makeFirstResponder(terminalSurrogate)) + panel.notePanelFocusChanged(false) + + XCTAssertFalse( + panel.reassertFocusedWebContentAfterNavigationFinish( + panel.webView, + reason: "test" + ) + ) + + XCTAssertTrue(window.firstResponder === terminalSurrogate) } -} + @MainActor + func testWindowBecomeKeyResumesSuspendedWebContentFocus() throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } -@MainActor -final class BrowserPanelAddressBarFocusRequestTests: XCTestCase { - func testRequestPersistsUntilAcknowledged() { + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + contentView.layoutSubtreeIfNeeded() + + let cmuxWebView = try XCTUnwrap(panel.webView as? CmuxWebView) + cmuxWebView.allowsFirstResponderAcquisition = true + XCTAssertTrue(window.makeFirstResponder(panel.webView)) + + panel.notePanelFocusChanged(true) + panel.noteOwningWindowDidResignKey(window, reason: "test") + XCTAssertFalse(cmuxWebView.allowsFirstResponderAcquisition) + + window.testIsKeyWindow = true + XCTAssertTrue(panel.noteOwningWindowDidBecomeKey(window, reason: "test")) + + XCTAssertTrue(cmuxWebView.allowsFirstResponderAcquisition) + XCTAssertTrue(panel.hasWebViewFirstResponder(in: window)) + XCTAssertEqual(panel.captureFocusIntent(in: window), .browser(.webView)) + } + + @MainActor + func testWindowActivationDoesNotStealAddressBarIntent() throws { let panel = BrowserPanel(workspaceId: UUID()) - XCTAssertNil(panel.pendingAddressBarFocusRequestId) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + + let addressResponder = BrowserPanelFakeWebContentResponderView( + frame: NSRect(x: 20, y: 20, width: 180, height: 24) + ) + contentView.addSubview(addressResponder) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + + panel.notePanelFocusChanged(true) let requestId = panel.requestAddressBarFocus() - XCTAssertEqual(panel.pendingAddressBarFocusRequestId, requestId) + panel.acknowledgeAddressBarFocusRequest(requestId) + XCTAssertTrue(window.makeFirstResponder(addressResponder)) + + XCTAssertFalse(panel.reassertWebContentFocusAfterWindowActivation(in: window, reason: "test")) + XCTAssertTrue(window.firstResponder === addressResponder) + XCTAssertEqual(panel.preferredFocusIntentForActivation(), .browser(.addressBar)) XCTAssertTrue(panel.shouldSuppressWebViewFocus()) + } + + @MainActor + func testKeyboardInputRepairDoesNotStealAddressBarIntent() throws { + let panel = BrowserPanel(workspaceId: UUID()) + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + + let addressResponder = BrowserPanelFakeWebContentResponderView( + frame: NSRect(x: 20, y: 20, width: 180, height: 24) + ) + contentView.addSubview(addressResponder) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + panel.notePanelFocusChanged(true) + let requestId = panel.requestAddressBarFocus() panel.acknowledgeAddressBarFocusRequest(requestId) - XCTAssertNil(panel.pendingAddressBarFocusRequestId) + XCTAssertTrue(window.makeFirstResponder(addressResponder)) - // Acknowledgement only clears the durable request; focus suppression follows - // explicit blur state transitions. + XCTAssertFalse(panel.repairWebContentFocusForKeyboardInput(in: window, reason: "test")) + XCTAssertTrue(window.firstResponder === addressResponder) + XCTAssertEqual(panel.preferredFocusIntentForActivation(), .browser(.addressBar)) XCTAssertTrue(panel.shouldSuppressWebViewFocus()) - panel.endSuppressWebViewFocusForAddressBar() - XCTAssertFalse(panel.shouldSuppressWebViewFocus()) } - func testRequestCoalescesWhilePending() { + @MainActor + func testKeyboardInputRepairDoesNotStealFindFieldIntent() throws { let panel = BrowserPanel(workspaceId: UUID()) - let firstRequest = panel.requestAddressBarFocus() - let secondRequest = panel.requestAddressBarFocus() + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } - XCTAssertEqual(firstRequest, secondRequest) - XCTAssertEqual(panel.pendingAddressBarFocusRequestId, firstRequest) + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) + + let findField = NSTextField(frame: NSRect(x: 20, y: 20, width: 180, height: 24)) + setBrowserSearchOverlayPanelId(panel.id, on: findField) + contentView.addSubview(findField) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + + panel.notePanelFocusChanged(true) + panel.startFind() + let requestId = try XCTUnwrap(panel.pendingFindFieldFocusRequestId) + panel.noteFindFieldFocused(requestId: requestId) + XCTAssertTrue(window.makeFirstResponder(findField)) + let findResponder = try XCTUnwrap(window.firstResponder) + + XCTAssertFalse(panel.repairWebContentFocusForKeyboardInput(in: window, reason: "test")) + XCTAssertTrue(window.firstResponder === findResponder) + XCTAssertEqual(panel.preferredFocusIntentForActivation(), .browser(.findField)) + XCTAssertTrue(panel.shouldSuppressWebViewFocus()) } - func testStaleAcknowledgementDoesNotClearNewestRequest() { + @MainActor + func testFindDismissRestoreBypassesStaleFalseResponderPolicy() throws { let panel = BrowserPanel(workspaceId: UUID()) - let firstRequest = panel.requestAddressBarFocus() - panel.acknowledgeAddressBarFocusRequest(firstRequest) - let secondRequest = panel.requestAddressBarFocus() + let window = BrowserPanelKeyStateTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } - XCTAssertNotEqual(firstRequest, secondRequest) - XCTAssertEqual(panel.pendingAddressBarFocusRequestId, secondRequest) + let contentView = try XCTUnwrap(window.contentView) + panel.webView.frame = contentView.bounds + panel.webView.autoresizingMask = [.width, .height] + contentView.addSubview(panel.webView) - panel.acknowledgeAddressBarFocusRequest(firstRequest) - XCTAssertEqual(panel.pendingAddressBarFocusRequestId, secondRequest) + let findField = NSTextField(frame: NSRect(x: 20, y: 20, width: 180, height: 24)) + setBrowserSearchOverlayPanelId(panel.id, on: findField) + contentView.addSubview(findField) - panel.acknowledgeAddressBarFocusRequest(secondRequest) - XCTAssertNil(panel.pendingAddressBarFocusRequestId) + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + XCTAssertTrue(window.makeFirstResponder(findField)) + + let cmuxWebView = try XCTUnwrap(panel.webView as? CmuxWebView) + cmuxWebView.allowsFirstResponderAcquisition = false + + panel.startFind() + let requestId = try XCTUnwrap(panel.pendingFindFieldFocusRequestId) + panel.noteFindFieldFocused(requestId: requestId) + panel.notePanelFocusChanged(true) + panel.hideFind(reason: "test") + + XCTAssertNotNil(panel.pendingWebContentRestoreRequestId) + XCTAssertNil(panel.searchState) + + XCTAssertTrue(window.makeFirstResponder(nil)) + panel.noteFindFieldEndedEditing(requestId: requestId, source: "test") + findField.removeFromSuperview() + panel.noteFindOverlayDisappeared(source: "test") + waitForBrowserPanelCondition { + panel.pendingWebContentRestoreRequestId == nil && panel.searchState == nil + } + XCTAssertNil(panel.pendingWebContentRestoreRequestId) + XCTAssertNil(panel.searchState) + XCTAssertEqual(panel.captureFocusIntent(in: window), .browser(.webView)) } } @@ -427,7 +1840,7 @@ final class WindowBrowserHostViewTests: XCTestCase { } } - private final class BonsplitMockSplitDelegate: NSObject, NSSplitViewDelegate {} + private final class WorkspaceSplitMockSplitDelegate: NSObject, NSSplitViewDelegate {} private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent { guard let event = NSEvent.mouseEvent( @@ -474,7 +1887,7 @@ final class WindowBrowserHostViewTests: XCTestCase { splitView.autoresizingMask = [.width, .height] splitView.isVertical = true splitView.dividerStyle = .thin - let splitDelegate = BonsplitMockSplitDelegate() + let splitDelegate = WorkspaceSplitMockSplitDelegate() splitView.delegate = splitDelegate let first = NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height)) let second = NSView(frame: NSRect(x: 121, y: 0, width: 179, height: contentView.bounds.height)) @@ -573,13 +1986,13 @@ final class WindowBrowserHostViewTests: XCTestCase { func testDragHoverEventsPassThroughForTabTransferOnBrowserHoverEvents() { XCTAssertTrue( WindowBrowserHostView.shouldPassThroughToDragTargets( - pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], + pasteboardTypes: [DragOverlayRoutingPolicy.splitTabTransferType], eventType: .cursorUpdate ) ) XCTAssertTrue( WindowBrowserHostView.shouldPassThroughToDragTargets( - pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], + pasteboardTypes: [DragOverlayRoutingPolicy.splitTabTransferType], eventType: .mouseEntered ) ) @@ -625,7 +2038,7 @@ final class WindowBrowserHostViewTests: XCTestCase { appSplit.autoresizingMask = [.width, .height] appSplit.isVertical = true appSplit.dividerStyle = .thin - let appSplitDelegate = BonsplitMockSplitDelegate() + let appSplitDelegate = WorkspaceSplitMockSplitDelegate() appSplit.delegate = appSplitDelegate let leading = NSView(frame: NSRect(x: 0, y: 0, width: 210, height: contentView.bounds.height)) let trailing = NSView(frame: NSRect(x: 211, y: 0, width: 209, height: contentView.bounds.height)) @@ -645,7 +2058,7 @@ final class WindowBrowserHostViewTests: XCTestCase { inspectorSplit.autoresizingMask = [.width, .height] inspectorSplit.isVertical = false inspectorSplit.dividerStyle = .thin - let inspectorDelegate = BonsplitMockSplitDelegate() + let inspectorDelegate = WorkspaceSplitMockSplitDelegate() inspectorSplit.delegate = inspectorDelegate let pageView = CapturingView(frame: NSRect(x: 0, y: 0, width: host.bounds.width, height: 160)) let consoleView = CapturingView(frame: NSRect(x: 0, y: 161, width: host.bounds.width, height: 99)) @@ -718,7 +2131,7 @@ final class WindowBrowserHostViewTests: XCTestCase { inspectorSplit.autoresizingMask = [.width, .height] inspectorSplit.isVertical = true inspectorSplit.dividerStyle = .thin - let inspectorDelegate = BonsplitMockSplitDelegate() + let inspectorDelegate = WorkspaceSplitMockSplitDelegate() inspectorSplit.delegate = inspectorDelegate let pageView = CapturingView(frame: NSRect(x: 0, y: 0, width: 1, height: slot.bounds.height)) let inspectorView = CapturingView( @@ -1896,13 +3309,13 @@ final class BrowserPaneDropRoutingTests: XCTestCase { func testHitTestingCapturesOnlyForRelevantDragEvents() { XCTAssertTrue( BrowserPaneDropTargetView.shouldCaptureHitTesting( - pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], + pasteboardTypes: [DragOverlayRoutingPolicy.splitTabTransferType], eventType: .cursorUpdate ) ) XCTAssertFalse( BrowserPaneDropTargetView.shouldCaptureHitTesting( - pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], + pasteboardTypes: [DragOverlayRoutingPolicy.splitTabTransferType], eventType: .leftMouseDown ) ) @@ -1977,6 +3390,81 @@ final class BrowserPaneDropRoutingTests: XCTestCase { } } +@MainActor +final class WorkspacePaneDropRoutingTests: XCTestCase { + func testTerminalDragKeepsLeftEdgeAsLeft() { + let targetPaneId = PaneID(id: UUID()) + let sourcePaneId = PaneID(id: UUID()) + let size = CGSize(width: 240, height: 180) + + let decision = WorkspacePaneDropRouting.decision( + for: CGPoint(x: 8, y: size.height * 0.5), + in: size, + targetPaneId: targetPaneId, + sourcePaneId: sourcePaneId + ) + + XCTAssertEqual(decision.defaultZone, .left) + XCTAssertEqual(decision.finalZone, .left) + XCTAssertEqual(decision.targetPaneId, targetPaneId) + XCTAssertEqual(decision.sourcePaneId, sourcePaneId) + XCTAssertNil(decision.remapReason) + } + + func testTerminalDragKeepsRightEdgeAsRight() { + let targetPaneId = PaneID(id: UUID()) + let sourcePaneId = PaneID(id: UUID()) + let size = CGSize(width: 240, height: 180) + + let decision = WorkspacePaneDropRouting.decision( + for: CGPoint(x: size.width - 8, y: size.height * 0.5), + in: size, + targetPaneId: targetPaneId, + sourcePaneId: sourcePaneId + ) + + XCTAssertEqual(decision.defaultZone, .right) + XCTAssertEqual(decision.finalZone, .right) + XCTAssertEqual(decision.targetPaneId, targetPaneId) + XCTAssertEqual(decision.sourcePaneId, sourcePaneId) + XCTAssertNil(decision.remapReason) + } + + func testNoSourceContextStillReturnsGeometryZone() { + let targetPaneId = PaneID(id: UUID()) + let size = CGSize(width: 240, height: 180) + + let decision = WorkspacePaneDropRouting.decision( + for: CGPoint(x: size.width * 0.5, y: size.height - 8), + in: size, + targetPaneId: targetPaneId, + sourcePaneId: nil + ) + + XCTAssertEqual(decision.defaultZone, .top) + XCTAssertEqual(decision.finalZone, .top) + XCTAssertEqual(decision.targetPaneId, targetPaneId) + XCTAssertNil(decision.sourcePaneId) + XCTAssertNil(decision.remapReason) + } + + func testTopOverlayFrameOccupiesUpperHalfInLocalCoordinates() { + let size = CGSize(width: 240, height: 180) + + let frame = WorkspacePaneDropRouting.overlayFrame(for: .top, in: size) + + XCTAssertEqual(frame, CGRect(x: 4, y: 90, width: 232, height: 86)) + } + + func testBottomOverlayFrameOccupiesLowerHalfInLocalCoordinates() { + let size = CGSize(width: 240, height: 180) + + let frame = WorkspacePaneDropRouting.overlayFrame(for: .bottom, in: size) + + XCTAssertEqual(frame, CGRect(x: 4, y: 4, width: 232, height: 86)) + } +} + @MainActor final class WindowBrowserSlotViewTests: XCTestCase { diff --git a/cmuxTests/CJKIMEInputTests.swift b/cmuxTests/CJKIMEInputTests.swift index ee970dc8d429..b5adf57da377 100644 --- a/cmuxTests/CJKIMEInputTests.swift +++ b/cmuxTests/CJKIMEInputTests.swift @@ -10,10 +10,6 @@ import ObjectiveC.runtime private var cjkIMEInterpretKeyEventsSwizzled = false private var cjkIMEInterpretKeyEventsHook: ((GhosttyNSView, [NSEvent]) -> Bool)? -private var ghosttyPasteActionSwizzled = false -private var ghosttyPasteActionHook: ((GhosttyNSView, Any?) -> Void)? -private var ghosttyPasteAsPlainTextActionSwizzled = false -private var ghosttyPasteAsPlainTextActionHook: ((GhosttyNSView, Any?) -> Void)? private extension GhosttyNSView { @objc func cmuxUnitTest_interpretKeyEvents(_ eventArray: [NSEvent]) { @@ -22,16 +18,6 @@ private extension GhosttyNSView { } cmuxUnitTest_interpretKeyEvents(eventArray) } - - @objc func cmuxUnitTest_paste(_ sender: Any?) { - ghosttyPasteActionHook?(self, sender) - cmuxUnitTest_paste(sender) - } - - @objc func cmuxUnitTest_pasteAsPlainText(_ sender: Any?) { - ghosttyPasteAsPlainTextActionHook?(self, sender) - cmuxUnitTest_pasteAsPlainText(sender) - } } private func installCJKIMEInterpretKeyEventsSwizzle() { @@ -66,68 +52,6 @@ private func installCJKIMEInterpretKeyEventsSwizzle() { cjkIMEInterpretKeyEventsSwizzled = true } -private func installGhosttyPasteActionSwizzle() { - guard !ghosttyPasteActionSwizzled else { return } - - let originalSelector = #selector(GhosttyNSView.paste(_:)) - let swizzledSelector = #selector(GhosttyNSView.cmuxUnitTest_paste(_:)) - - guard let originalMethod = class_getInstanceMethod(GhosttyNSView.self, originalSelector), - let swizzledMethod = class_getInstanceMethod(GhosttyNSView.self, swizzledSelector) else { - fatalError("Unable to locate GhosttyNSView paste methods for swizzling") - } - - let didAddMethod = class_addMethod( - GhosttyNSView.self, - originalSelector, - method_getImplementation(swizzledMethod), - method_getTypeEncoding(swizzledMethod) - ) - - if didAddMethod { - class_replaceMethod( - GhosttyNSView.self, - swizzledSelector, - method_getImplementation(originalMethod), - method_getTypeEncoding(originalMethod) - ) - } else { - method_exchangeImplementations(originalMethod, swizzledMethod) - } - - ghosttyPasteActionSwizzled = true - - guard !ghosttyPasteAsPlainTextActionSwizzled else { return } - - let plainTextOriginalSelector = #selector(GhosttyNSView.pasteAsPlainText(_:)) - let plainTextSwizzledSelector = #selector(GhosttyNSView.cmuxUnitTest_pasteAsPlainText(_:)) - - guard let plainTextOriginalMethod = class_getInstanceMethod(GhosttyNSView.self, plainTextOriginalSelector), - let plainTextSwizzledMethod = class_getInstanceMethod(GhosttyNSView.self, plainTextSwizzledSelector) else { - fatalError("Unable to locate GhosttyNSView pasteAsPlainText methods for swizzling") - } - - let didAddPlainTextMethod = class_addMethod( - GhosttyNSView.self, - plainTextOriginalSelector, - method_getImplementation(plainTextSwizzledMethod), - method_getTypeEncoding(plainTextSwizzledMethod) - ) - - if didAddPlainTextMethod { - class_replaceMethod( - GhosttyNSView.self, - plainTextSwizzledSelector, - method_getImplementation(plainTextOriginalMethod), - method_getTypeEncoding(plainTextOriginalMethod) - ) - } else { - method_exchangeImplementations(plainTextOriginalMethod, plainTextSwizzledMethod) - } - - ghosttyPasteAsPlainTextActionSwizzled = true -} - private func findGhosttyNSView(in view: NSView) -> GhosttyNSView? { if let view = view as? GhosttyNSView { return view @@ -1545,11 +1469,7 @@ final class GhosttyBackquoteRegressionTests: XCTestCase { } @MainActor -final class GhosttyKeyEquivalentRegressionTests: XCTestCase { - private struct PasteboardItemSnapshot { - let representations: [(type: NSPasteboard.PasteboardType, data: Data)] - } - +final class GhosttyPrintableShiftKeyEquivalentRegressionTests: XCTestCase { private struct HostedTerminalWindow { let surface: TerminalSurface let window: NSWindow @@ -1596,48 +1516,6 @@ final class GhosttyKeyEquivalentRegressionTests: XCTestCase { ) } - private func snapshotPasteboardItems(_ pasteboard: NSPasteboard) -> [PasteboardItemSnapshot] { - guard let items = pasteboard.pasteboardItems else { return [] } - return items.map { item in - let representations = item.types.compactMap { type -> (NSPasteboard.PasteboardType, Data)? in - guard let data = item.data(forType: type) else { return nil } - return (type, data) - } - return PasteboardItemSnapshot(representations: representations) - } - } - - private func restorePasteboardItems( - _ snapshots: [PasteboardItemSnapshot], - to pasteboard: NSPasteboard - ) { - pasteboard.clearContents() - guard !snapshots.isEmpty else { return } - let items = snapshots.compactMap { snapshot -> NSPasteboardItem? in - let item = NSPasteboardItem() - guard !snapshot.representations.isEmpty else { return nil } - for representation in snapshot.representations { - item.setData(representation.data, forType: representation.type) - } - return item - } - if !items.isEmpty { - _ = pasteboard.writeObjects(items) - } - } - - private func installUnrelatedMainMenu() -> NSMenu { - let mainMenu = NSMenu() - let fileItem = NSMenuItem(title: "File", action: nil, keyEquivalent: "") - let fileMenu = NSMenu(title: "File") - let item = NSMenuItem(title: "New", action: nil, keyEquivalent: "n") - item.keyEquivalentModifierMask = [.command] - fileMenu.addItem(item) - mainMenu.addItem(fileItem) - mainMenu.setSubmenu(fileMenu, for: fileItem) - return mainMenu - } - func testShiftSlashPrintableKeyEquivalentBypassesShortcutPath() throws { let hostedTerminal = try makeHostedTerminalWindow() let window = hostedTerminal.window @@ -1703,252 +1581,6 @@ final class GhosttyKeyEquivalentRegressionTests: XCTestCase { ) } } - - // MARK: - Terminal Paste Fallback - - func testCommandVPasteStillInvokesTerminalPasteWhenMainMenuMisses() throws { - installGhosttyPasteActionSwizzle() - - let hostedTerminal = try makeHostedTerminalWindow() - let terminalSurface = hostedTerminal.surface - let window = hostedTerminal.window - let surfaceView = hostedTerminal.surfaceView - defer { window.orderOut(nil) } - - window.makeFirstResponder(surfaceView) - XCTAssertNotNil(surfaceView.terminalSurface) - - let previousMainMenu = NSApp.mainMenu - NSApp.mainMenu = installUnrelatedMainMenu() - defer { NSApp.mainMenu = previousMainMenu } - - let pasteboard = NSPasteboard.general - let pasteboardSnapshot = snapshotPasteboardItems(pasteboard) - defer { restorePasteboardItems(pasteboardSnapshot, to: pasteboard) } - pasteboard.clearContents() - pasteboard.setString("opencode paste", forType: .string) - - var pasteInvocationCount = 0 - let previousPasteHook = ghosttyPasteActionHook - ghosttyPasteActionHook = { candidateView, sender in - previousPasteHook?(candidateView, sender) - guard candidateView === surfaceView else { return } - pasteInvocationCount += 1 - } - defer { ghosttyPasteActionHook = previousPasteHook } - - var forwardedCommandVCount = 0 - let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in - previousKeyEventObserver?(keyEvent) - guard keyEvent.action == GHOSTTY_ACTION_PRESS, keyEvent.keycode == 9 else { return } - forwardedCommandVCount += 1 - } - defer { - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver - } - - guard let event = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.command], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: window.windowNumber, - context: nil, - characters: "v", - charactersIgnoringModifiers: "v", - isARepeat: false, - keyCode: 9 - ) else { - XCTFail("Failed to construct Cmd+V event") - return - } - - withExtendedLifetime(terminalSurface) { - XCTAssertTrue(window.performKeyEquivalent(with: event)) - XCTAssertEqual( - pasteInvocationCount, - 1, - "Cmd+V should still invoke the terminal paste action even if the window main-menu fast path misses" - ) - XCTAssertEqual( - forwardedCommandVCount, - 0, - "Cmd+V should not fall back to Ghostty keyDown when the terminal paste action is available" - ) - } - } - - func testCommandShiftVPasteAsPlainTextStillInvokesTerminalFallbackWhenMainMenuMisses() throws { - installGhosttyPasteActionSwizzle() - - let hostedTerminal = try makeHostedTerminalWindow() - let terminalSurface = hostedTerminal.surface - let window = hostedTerminal.window - let surfaceView = hostedTerminal.surfaceView - defer { window.orderOut(nil) } - - window.makeFirstResponder(surfaceView) - XCTAssertNotNil(surfaceView.terminalSurface) - - let previousMainMenu = NSApp.mainMenu - NSApp.mainMenu = installUnrelatedMainMenu() - defer { NSApp.mainMenu = previousMainMenu } - - let pasteboard = NSPasteboard.general - let pasteboardSnapshot = snapshotPasteboardItems(pasteboard) - defer { restorePasteboardItems(pasteboardSnapshot, to: pasteboard) } - pasteboard.clearContents() - pasteboard.setString("opencode paste plain text", forType: .string) - - var pasteInvocationCount = 0 - let previousPasteHook = ghosttyPasteActionHook - ghosttyPasteActionHook = { candidateView, sender in - previousPasteHook?(candidateView, sender) - guard candidateView === surfaceView else { return } - pasteInvocationCount += 1 - } - defer { ghosttyPasteActionHook = previousPasteHook } - - var pasteAsPlainTextInvocationCount = 0 - let previousPasteAsPlainTextHook = ghosttyPasteAsPlainTextActionHook - ghosttyPasteAsPlainTextActionHook = { candidateView, sender in - previousPasteAsPlainTextHook?(candidateView, sender) - guard candidateView === surfaceView else { return } - pasteAsPlainTextInvocationCount += 1 - } - defer { ghosttyPasteAsPlainTextActionHook = previousPasteAsPlainTextHook } - - var forwardedCommandVCount = 0 - let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in - previousKeyEventObserver?(keyEvent) - guard keyEvent.action == GHOSTTY_ACTION_PRESS, keyEvent.keycode == 9 else { return } - forwardedCommandVCount += 1 - } - defer { - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver - } - - guard let event = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.command, .shift], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: window.windowNumber, - context: nil, - characters: "V", - charactersIgnoringModifiers: "v", - isARepeat: false, - keyCode: 9 - ) else { - XCTFail("Failed to construct Cmd+Shift+V event") - return - } - - withExtendedLifetime(terminalSurface) { - XCTAssertTrue(window.performKeyEquivalent(with: event)) - XCTAssertEqual( - pasteInvocationCount, - 0, - "Cmd+Shift+V should route through pasteAsPlainText instead of the regular terminal paste action" - ) - XCTAssertEqual( - pasteAsPlainTextInvocationCount, - 1, - "Cmd+Shift+V should still invoke the terminal pasteAsPlainText action even if the window main-menu fast path misses" - ) - XCTAssertEqual( - forwardedCommandVCount, - 0, - "Cmd+Shift+V should not fall back to Ghostty keyDown when the terminal plain-text paste action is available" - ) - } - } - - func testCommandVPasteRecreatesReleasedSurfaceBeforeConsumption() throws { - installGhosttyPasteActionSwizzle() - - let hostedTerminal = try makeHostedTerminalWindow() - let terminalSurface = hostedTerminal.surface - let window = hostedTerminal.window - let surfaceView = hostedTerminal.surfaceView - defer { window.orderOut(nil) } - - window.makeFirstResponder(surfaceView) - XCTAssertNotNil(surfaceView.terminalSurface) - XCTAssertNotNil(terminalSurface.surface) - - let previousMainMenu = NSApp.mainMenu - NSApp.mainMenu = installUnrelatedMainMenu() - defer { NSApp.mainMenu = previousMainMenu } - - let pasteboard = NSPasteboard.general - let pasteboardSnapshot = snapshotPasteboardItems(pasteboard) - defer { restorePasteboardItems(pasteboardSnapshot, to: pasteboard) } - pasteboard.clearContents() - pasteboard.setString("surface recovery paste", forType: .string) - - var pasteInvocationCount = 0 - let previousPasteHook = ghosttyPasteActionHook - ghosttyPasteActionHook = { candidateView, sender in - previousPasteHook?(candidateView, sender) - guard candidateView === surfaceView else { return } - pasteInvocationCount += 1 - } - defer { ghosttyPasteActionHook = previousPasteHook } - - var forwardedCommandVCount = 0 - let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in - previousKeyEventObserver?(keyEvent) - guard keyEvent.action == GHOSTTY_ACTION_PRESS, keyEvent.keycode == 9 else { return } - forwardedCommandVCount += 1 - } - defer { - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver - } - - terminalSurface.releaseSurfaceForTesting() - XCTAssertNil( - terminalSurface.surface, - "Expected the runtime Ghostty surface to be released before simulating Cmd+V" - ) - - guard let event = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.command], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: window.windowNumber, - context: nil, - characters: "v", - charactersIgnoringModifiers: "v", - isARepeat: false, - keyCode: 9 - ) else { - XCTFail("Failed to construct Cmd+V event") - return - } - - withExtendedLifetime(terminalSurface) { - XCTAssertTrue(window.performKeyEquivalent(with: event)) - XCTAssertEqual( - pasteInvocationCount, - 1, - "Cmd+V should still invoke the terminal paste action after a transient surface release" - ) - XCTAssertEqual( - forwardedCommandVCount, - 0, - "Cmd+V should recover the Ghostty surface without falling back to keyDown" - ) - XCTAssertNotNil( - terminalSurface.surface, - "Cmd+V should recreate the Ghostty surface before the direct terminal paste fallback consumes the shortcut" - ) - } - } } @MainActor diff --git a/cmuxTests/CmuxConfigTests.swift b/cmuxTests/CmuxConfigTests.swift index 736e24e89e03..834030d1034e 100644 --- a/cmuxTests/CmuxConfigTests.swift +++ b/cmuxTests/CmuxConfigTests.swift @@ -1,4 +1,3 @@ -import Combine import XCTest #if canImport(cmux_DEV) @@ -16,21 +15,6 @@ final class CmuxConfigDecodingTests: XCTestCase { return try JSONDecoder().decode(CmuxConfigFile.self, from: data) } - private func resolvedActions( - from config: CmuxConfigFile, - sourcePath: String? = nil - ) -> [String: CmuxResolvedConfigAction] { - Dictionary( - uniqueKeysWithValues: config.actions.compactMap { id, definition in - CmuxResolvedConfigAction.fromDefinition( - id: id, - definition: definition, - sourcePath: sourcePath - ).map { (id, $0) } - } - ) - } - // MARK: Simple commands func testDecodeSimpleCommand() throws { @@ -85,934 +69,6 @@ final class CmuxConfigDecodingTests: XCTestCase { XCTAssertEqual(config.commands.map(\.name), ["Build", "Test", "Lint"]) } - func testDecodeNewWorkspaceCommand() throws { - let json = """ - { - "newWorkspaceCommand": "Dev Environment", - "commands": [{ - "name": "Dev Environment", - "workspace": { "name": "Dev" } - }] - } - """ - let config = try decode(json) - XCTAssertEqual(config.newWorkspaceCommand, "Dev Environment") - } - - func testDecodeNewWorkspaceCommandTrimsWhitespace() throws { - let json = """ - { - "newWorkspaceCommand": " Dev Environment ", - "commands": [{ - "name": "Dev Environment", - "workspace": { "name": "Dev" } - }] - } - """ - let config = try decode(json) - XCTAssertEqual(config.newWorkspaceCommand, "Dev Environment") - } - - func testDecodeLegacySurfaceTabBarButtons() throws { - let json = """ - { - "surfaceTabBarButtons": ["newTerminal", "splitRight"], - "commands": [] - } - """ - let config = try decode(json) - XCTAssertEqual(config.surfaceTabBarButtons, [.newTerminal, .splitRight]) - } - - func testDecodeSurfaceTabBarButtonObjects() throws { - let json = """ - { - "surfaceTabBarButtons": [ - { - "id": "newTerminal", - "icon": { "type": "symbol", "name": "terminal.fill" }, - "tooltip": "New shell", - "action": "newTerminal" - }, - { - "id": "run-tests", - "icon": { "type": "symbol", "name": "checkmark.circle" }, - "tooltip": "Run tests", - "command": "npm test", - "confirm": true - } - ], - "commands": [] - } - """ - let config = try decode(json) - XCTAssertEqual(config.surfaceTabBarButtons?.count, 2) - let rawFirstButton = try XCTUnwrap(config.surfaceTabBarButtons?.first) - let firstButton = try rawFirstButton.resolved(actions: [:], codingPath: []) - XCTAssertEqual( - firstButton, - .builtIn(.newTerminal, id: "newTerminal", icon: .symbol("terminal.fill"), tooltip: "New shell") - ) - XCTAssertEqual(config.surfaceTabBarButtons?[1].id, "run-tests") - XCTAssertEqual(config.surfaceTabBarButtons?[1].icon, .symbol("checkmark.circle")) - XCTAssertEqual(config.surfaceTabBarButtons?[1].tooltip, "Run tests") - XCTAssertEqual(config.surfaceTabBarButtons?[1].action, .command("npm test")) - XCTAssertEqual(config.surfaceTabBarButtons?[1].confirm, true) - } - - func testDecodeSurfaceTabBarButtonCanOverrideBuiltInWithCommand() throws { - let json = """ - { - "surfaceTabBarButtons": [ - { - "id": "newTerminal", - "icon": { "type": "symbol", "name": "play.circle" }, - "command": "npm run dev" - } - ] - } - """ - let config = try decode(json) - let button = try XCTUnwrap(config.surfaceTabBarButtons?.first) - XCTAssertEqual(button.id, "newTerminal") - XCTAssertEqual(button.icon, .symbol("play.circle")) - XCTAssertEqual(button.command, "npm run dev") - } - - func testDecodeActionsSurfaceTabBarButtons() throws { - let json = """ - { - "actions": { - "start-codex": { "type": "agent", "agent": "codex" }, - "start-claude": { "type": "agent", "agent": "claude", "args": "--permission-mode acceptEdits" } - }, - "ui": { - "surfaceTabBar": { - "buttons": [ - { - "action": "start-codex", - "icon": { "type": "image", "path": "./icons/codex.png" }, - "tooltip": "Start Codex" - }, - { - "action": "start-claude", - "icon": { "type": "emoji", "value": "🤖" }, - "tooltip": "Start Claude Code" - } - ] - } - } - } - """ - let config = try decode(json) - let rawButtons = try XCTUnwrap(config.surfaceTabBarButtons) - let buttons = try rawButtons.map { - try $0.resolved(actions: resolvedActions(from: config), codingPath: []) - } - XCTAssertEqual(buttons.count, 2) - XCTAssertEqual(buttons[0].id, "start-codex") - XCTAssertEqual(buttons[0].icon, .imagePath("./icons/codex.png")) - XCTAssertEqual(buttons[0].terminalCommand, "codex") - XCTAssertEqual(buttons[1].id, "start-claude") - XCTAssertEqual(buttons[1].icon, .emoji("🤖")) - XCTAssertEqual(buttons[1].terminalCommand, "claude --permission-mode acceptEdits") - } - - func testDecodeSurfaceTabBarButtonsDefersUnknownActionReferences() throws { - let json = """ - { - "ui": { - "surfaceTabBar": { - "buttons": [ - { "action": "global-codex", "icon": { "type": "symbol", "name": "sparkles" } } - ] - } - } - } - """ - let config = try decode(json) - let button = try XCTUnwrap(config.surfaceTabBarButtons?.first) - XCTAssertEqual(button.id, "global-codex") - XCTAssertEqual(button.action, .actionReference("global-codex")) - } - - func testResolveSurfaceTabBarActionReferenceUsesActionTitle() throws { - let json = """ - { - "actions": { - "start-codex": { - "type": "command", - "title": "Start Codex", - "tooltip": "Open Codex in a new tab", - "command": "codex", - "icon": { "type": "symbol", "name": "sparkles" } - } - }, - "ui": { - "surfaceTabBar": { - "buttons": [ - { "action": "start-codex" } - ] - } - } - } - """ - let config = try decode(json) - let rawButton = try XCTUnwrap(config.surfaceTabBarButtons?.first) - let button = try rawButton.resolved(actions: resolvedActions(from: config), codingPath: []) - XCTAssertEqual(button.title, "Start Codex") - XCTAssertEqual(button.tooltip, "Open Codex in a new tab") - XCTAssertEqual(button.icon, .symbol("sparkles")) - XCTAssertEqual(button.action, .command("codex")) - } - - func testResolveSurfaceTabBarActionReferenceCanOverrideTitleAndIcon() throws { - let json = """ - { - "actions": { - "start-codex": { - "type": "command", - "title": "Start Codex", - "tooltip": "Open Codex in a new tab", - "command": "codex", - "icon": { "type": "symbol", "name": "sparkles" } - } - }, - "ui": { - "surfaceTabBar": { - "buttons": [ - { - "action": "start-codex", - "title": "Codex Here", - "icon": { "type": "emoji", "value": "🤖" } - } - ] - } - } - } - """ - let config = try decode(json) - let rawButton = try XCTUnwrap(config.surfaceTabBarButtons?.first) - let button = try rawButton.resolved(actions: resolvedActions(from: config), codingPath: []) - XCTAssertEqual(button.title, "Codex Here") - XCTAssertEqual(button.tooltip, "Open Codex in a new tab") - XCTAssertEqual(button.icon, .emoji("🤖")) - XCTAssertEqual(button.action, .command("codex")) - } - - @MainActor - func testSurfaceTabBarActionReferenceUsesActionSourcePath() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent( - "cmux-config-store-\(UUID().uuidString)", - isDirectory: true - ) - let globalDirectory = root.appendingPathComponent("global", isDirectory: true) - let localDirectory = root.appendingPathComponent("project", isDirectory: true) - try FileManager.default.createDirectory(at: globalDirectory, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: localDirectory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let globalConfigURL = globalDirectory.appendingPathComponent("cmux.json") - let localConfigURL = localDirectory.appendingPathComponent("cmux.json") - let globalJSON = """ - { - "actions": { - "start-codex": { "type": "command", "command": "codex --yolo", "confirm": true } - } - } - """ - let localJSON = """ - { - "ui": { - "surfaceTabBar": { - "buttons": [ - { "action": "start-codex", "icon": { "type": "symbol", "name": "sparkles" } } - ] - } - } - } - """ - try globalJSON.write(to: globalConfigURL, atomically: true, encoding: .utf8) - try localJSON.write(to: localConfigURL, atomically: true, encoding: .utf8) - - let store = CmuxConfigStore( - globalConfigPath: globalConfigURL.path, - localConfigPath: localConfigURL.path, - startFileWatchers: false - ) - store.loadAll() - - XCTAssertEqual(store.surfaceTabBarButtonSourcePath, localConfigURL.path) - XCTAssertEqual(store.surfaceTabBarButtons.first?.terminalCommand, "codex --yolo") - XCTAssertEqual(store.surfaceTabBarCommandSourcePaths["start-codex"], globalConfigURL.path) - } - - func testDecodeActionIconObjectsSupportAllFormats() throws { - let json = """ - { - "ui": { - "surfaceTabBar": { - "buttons": [ - { "id": "emoji", "icon": { "type": "emoji", "value": "🤖", "scale": 0.85 }, "command": "codex" }, - { "id": "svg", "icon": { "type": "image", "path": "./icons/codex.svg" }, "command": "codex" }, - { "id": "jpeg", "icon": { "type": "image", "path": "./icons/claude.jpg" }, "command": "claude" }, - { "id": "pdf", "icon": { "type": "image", "path": "./icons/logo.pdf" }, "command": "open ." }, - { "id": "bmp", "icon": { "type": "image", "path": "./icons/logo.bmp" }, "command": "open ." }, - { "id": "heif", "icon": { "type": "image", "path": "./icons/logo.heif" }, "command": "open ." }, - { "id": "avif", "icon": { "type": "image", "path": "./icons/logo.avif" }, "command": "open ." }, - { "id": "ico", "icon": { "type": "image", "path": "./icons/logo.ico" }, "command": "open ." } - ] - } - } - } - """ - let config = try decode(json) - XCTAssertEqual(config.surfaceTabBarButtons?[0].icon, .emoji("🤖", scale: 0.85)) - XCTAssertEqual(config.surfaceTabBarButtons?[1].icon, .imagePath("./icons/codex.svg")) - XCTAssertEqual(config.surfaceTabBarButtons?[2].icon, .imagePath("./icons/claude.jpg")) - XCTAssertEqual(config.surfaceTabBarButtons?[3].icon, .imagePath("./icons/logo.pdf")) - XCTAssertEqual(config.surfaceTabBarButtons?[4].icon, .imagePath("./icons/logo.bmp")) - XCTAssertEqual(config.surfaceTabBarButtons?[5].icon, .imagePath("./icons/logo.heif")) - XCTAssertEqual(config.surfaceTabBarButtons?[6].icon, .imagePath("./icons/logo.avif")) - XCTAssertEqual(config.surfaceTabBarButtons?[7].icon, .imagePath("./icons/logo.ico")) - } - - func testDecodeStringIconThrows() { - let json = """ - { - "actions": { - "start-codex": { - "type": "command", - "command": "codex", - "icon": "sparkles" - } - } - } - """ - XCTAssertThrowsError(try decode(json)) - } - - func testGlobalSVGIconAllowsNamespaceAndInternalGradient() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent( - "cmux-svg-\(UUID().uuidString)", - isDirectory: true - ) - let iconsDirectory = root.appendingPathComponent("icons", isDirectory: true) - try FileManager.default.createDirectory(at: iconsDirectory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let configPath = root.appendingPathComponent("cmux.json").path - let iconPath = iconsDirectory.appendingPathComponent("codex.svg") - let svg = """ - - - - - - - - - - """ - let data = Data(svg.utf8) - try data.write(to: iconPath) - - let icon = CmuxButtonIcon.imagePath("icons/codex.svg") - XCTAssertEqual( - icon.bonsplitIcon( - configSourcePath: configPath, - globalConfigPath: configPath - ), - .imageData(data) - ) - } - - func testProjectLocalSVGIconRejectsExternalReferences() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent( - "cmux-svg-\(UUID().uuidString)", - isDirectory: true - ) - let globalDirectory = root.appendingPathComponent("global", isDirectory: true) - let projectDirectory = root.appendingPathComponent("project", isDirectory: true) - let iconsDirectory = projectDirectory.appendingPathComponent("icons", isDirectory: true) - try FileManager.default.createDirectory(at: globalDirectory, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: iconsDirectory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let globalConfigPath = globalDirectory.appendingPathComponent("cmux.json").path - let projectConfigPath = projectDirectory.appendingPathComponent("cmux.json").path - let iconPath = iconsDirectory.appendingPathComponent("bad.svg") - let svg = """ - - - - """ - try Data(svg.utf8).write(to: iconPath) - - let icon = CmuxButtonIcon.imagePath("icons/bad.svg") - XCTAssertEqual( - icon.bonsplitIcon( - configSourcePath: projectConfigPath, - globalConfigPath: globalConfigPath - ), - .systemImage("questionmark.circle") - ) - } - - func testUntrustedProjectLocalIconUsesLockPlaceholder() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent( - "cmux-svg-\(UUID().uuidString)", - isDirectory: true - ) - let globalDirectory = root.appendingPathComponent("global", isDirectory: true) - let projectDirectory = root.appendingPathComponent("project", isDirectory: true) - let iconsDirectory = projectDirectory.appendingPathComponent("icons", isDirectory: true) - try FileManager.default.createDirectory(at: globalDirectory, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: iconsDirectory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let globalConfigPath = globalDirectory.appendingPathComponent("cmux.json").path - let projectConfigPath = projectDirectory.appendingPathComponent("cmux.json").path - let iconPath = iconsDirectory.appendingPathComponent("safe.svg") - let svg = """ - - - - """ - try Data(svg.utf8).write(to: iconPath) - - let icon = CmuxButtonIcon.imagePath("icons/safe.svg") - XCTAssertEqual( - icon.bonsplitIcon( - configSourcePath: projectConfigPath, - globalConfigPath: globalConfigPath, - allowProjectLocalImage: false - ), - .systemImage("lock.fill") - ) - } - - @MainActor - func testInlineSurfaceButtonIconUsesTabBarConfigSourceForTrust() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent( - "cmux-svg-\(UUID().uuidString)", - isDirectory: true - ) - let globalDirectory = root.appendingPathComponent("global", isDirectory: true) - let projectDirectory = root.appendingPathComponent("project", isDirectory: true) - let iconsDirectory = projectDirectory.appendingPathComponent("icons", isDirectory: true) - try FileManager.default.createDirectory(at: globalDirectory, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: iconsDirectory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let globalConfigPath = globalDirectory.appendingPathComponent("cmux.json").path - let projectConfigPath = projectDirectory.appendingPathComponent("cmux.json").path - let iconPath = iconsDirectory.appendingPathComponent("safe.svg") - let svg = """ - - - - """ - try Data(svg.utf8).write(to: iconPath) - - let button = CmuxSurfaceTabBarButton( - id: "inline-local", - icon: .imagePath("icons/safe.svg"), - action: .command("echo inline") - ) - XCTAssertFalse( - CmuxConfigExecutor.isTrustedSurfaceButton( - button, - workspaceCommand: nil, - terminalCommandSourcePath: nil, - surfaceTabBarConfigSourcePath: projectConfigPath, - globalConfigPath: globalConfigPath - ) - ) - } - - func testDecodeNewWorkspaceAction() throws { - let json = """ - { - "actions": { - "new-dev": { "type": "workspaceCommand", "commandName": "Dev Environment" } - }, - "ui": { - "newWorkspace": { "action": "new-dev" } - }, - "commands": [{ - "name": "Dev Environment", - "workspace": { "name": "Dev" } - }] - } - """ - let config = try decode(json) - XCTAssertEqual(config.ui?.newWorkspace?.action, "new-dev") - XCTAssertEqual(config.actions["new-dev"]?.action?.workspaceCommandName, "Dev Environment") - } - - func testDecodeActionShortcutString() throws { - let json = """ - { - "actions": { - "start-codex": { - "type": "command", - "command": "codex --dangerously-bypass-approvals-and-sandbox", - "shortcut": "cmd+shift+c" - } - } - } - """ - let config = try decode(json) - XCTAssertEqual( - config.actions["start-codex"]?.shortcut, - StoredShortcut.parseConfig("cmd+shift+c") - ) - } - - func testDecodeActionShortcutChord() throws { - let json = """ - { - "actions": { - "start-claude": { - "type": "command", - "command": "claude --dangerously-skip-permissions", - "shortcut": ["cmd+k", "cmd+c"] - } - } - } - """ - let config = try decode(json) - XCTAssertEqual( - config.actions["start-claude"]?.shortcut, - StoredShortcut.parseConfig(strokes: ["cmd+k", "cmd+c"]) - ) - } - - @MainActor - func testInvalidConfigExposesSchemaIssueAndClearsAfterFix() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent( - "cmux-config-store-\(UUID().uuidString)", - isDirectory: true - ) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let configURL = root.appendingPathComponent("cmux.json") - let invalidJSON = """ - { - "actions": { - "bad": { - "type": "command", - "command": "echo bad", - "icon": "sparkles" - } - } - } - """ - try invalidJSON.write(to: configURL, atomically: true, encoding: .utf8) - - let store = CmuxConfigStore( - globalConfigPath: root.appendingPathComponent("missing-global.json").path, - localConfigPath: configURL.path, - startFileWatchers: false - ) - store.loadAll() - - let issue = try XCTUnwrap(store.configurationIssues.first) - XCTAssertEqual(issue.kind, .schemaError) - XCTAssertEqual(issue.sourcePath, configURL.path) - XCTAssertTrue(issue.message?.contains("actions.bad.icon") ?? false) - XCTAssertNil(store.resolvedAction(id: "bad")) - - let validJSON = """ - { - "actions": { - "bad": { - "type": "command", - "command": "echo bad", - "icon": { "type": "symbol", "name": "sparkles" } - } - } - } - """ - try validJSON.write(to: configURL, atomically: true, encoding: .utf8) - store.loadAll() - - XCTAssertTrue(store.configurationIssues.isEmpty) - XCTAssertNotNil(store.resolvedAction(id: "bad")) - } - - @MainActor - func testLocalWatcherDetectsFirstCanonicalConfigAfterDirectoryCreation() async throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent( - "cmux-config-store-\(UUID().uuidString)", - isDirectory: true - ) - let projectDirectory = root.appendingPathComponent("project", isDirectory: true) - try FileManager.default.createDirectory(at: projectDirectory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let configDirectory = projectDirectory.appendingPathComponent(".cmux", isDirectory: true) - let configURL = configDirectory.appendingPathComponent("cmux.json", isDirectory: false) - let store = CmuxConfigStore( - globalConfigPath: root.appendingPathComponent("missing-global.json").path, - localConfigPath: configURL.path, - startFileWatchers: true - ) - store.loadAll() - XCTAssertNil(store.resolvedAction(id: "created")) - - let loaded = expectation(description: "created local cmux config is loaded") - loaded.assertForOverFulfill = false - var cancellable: AnyCancellable? - cancellable = store.$loadedActions.dropFirst().sink { actions in - if actions.contains(where: { $0.id == "created" }) { - loaded.fulfill() - } - } - - try FileManager.default.createDirectory(at: configDirectory, withIntermediateDirectories: true) - try """ - { - "actions": { - "created": { "type": "command", "command": "echo created" } - } - } - """.write(to: configURL, atomically: true, encoding: .utf8) - - await fulfillment(of: [loaded], timeout: 3) - cancellable?.cancel() - } - - @MainActor - func testLocalWatcherDetectsFirstLegacyConfigWhenCmuxDirectoryExists() async throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent( - "cmux-config-store-\(UUID().uuidString)", - isDirectory: true - ) - let projectDirectory = root.appendingPathComponent("project", isDirectory: true) - let configDirectory = projectDirectory.appendingPathComponent(".cmux", isDirectory: true) - try FileManager.default.createDirectory(at: configDirectory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let canonicalConfigURL = configDirectory.appendingPathComponent("cmux.json", isDirectory: false) - let legacyConfigURL = projectDirectory.appendingPathComponent("cmux.json", isDirectory: false) - let store = CmuxConfigStore( - globalConfigPath: root.appendingPathComponent("missing-global.json").path, - localConfigPath: canonicalConfigURL.path, - startFileWatchers: true - ) - store.loadAll() - XCTAssertNil(store.resolvedAction(id: "legacy-created")) - - let loaded = expectation(description: "created legacy cmux config is loaded") - loaded.assertForOverFulfill = false - var cancellable: AnyCancellable? - cancellable = store.$loadedActions.dropFirst().sink { actions in - if actions.contains(where: { $0.id == "legacy-created" }) { - loaded.fulfill() - } - } - - try """ - { - "actions": { - "legacy-created": { "type": "command", "command": "echo created" } - } - } - """.write(to: legacyConfigURL, atomically: true, encoding: .utf8) - - await fulfillment(of: [loaded], timeout: 3) - cancellable?.cancel() - } - - @MainActor - func testResolvedNewWorkspaceCommandReturnsConfiguredWorkspaceCommand() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent( - "cmux-config-store-\(UUID().uuidString)", - isDirectory: true - ) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let configURL = root.appendingPathComponent("cmux.json") - let json = """ - { - "newWorkspaceCommand": "Dev Environment", - "commands": [{ - "name": "Dev Environment", - "workspace": { "name": "Dev" } - }] - } - """ - try json.write(to: configURL, atomically: true, encoding: .utf8) - - let store = CmuxConfigStore( - globalConfigPath: root.appendingPathComponent("missing-global.json").path, - localConfigPath: configURL.path, - startFileWatchers: false - ) - store.loadAll() - - let resolved = try XCTUnwrap(store.resolvedNewWorkspaceCommand()) - XCTAssertEqual(resolved.command.name, "Dev Environment") - XCTAssertTrue(store.configurationIssues.isEmpty) - } - - @MainActor - func testGlobalNewWorkspaceActionUsesLocalActionOverride() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent( - "cmux-config-store-\(UUID().uuidString)", - isDirectory: true - ) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let globalConfigURL = root.appendingPathComponent("global-cmux.json") - let localConfigURL = root.appendingPathComponent("local-cmux.json") - try """ - { - "actions": { - "open-dev": { "type": "workspaceCommand", "commandName": "Global Dev" } - }, - "ui": { - "newWorkspace": { "action": "open-dev" } - }, - "commands": [{ - "name": "Global Dev", - "workspace": { "name": "Global" } - }] - } - """.write(to: globalConfigURL, atomically: true, encoding: .utf8) - try """ - { - "actions": { - "open-dev": { "type": "workspaceCommand", "commandName": "Local Dev" } - }, - "commands": [{ - "name": "Local Dev", - "workspace": { "name": "Local" } - }] - } - """.write(to: localConfigURL, atomically: true, encoding: .utf8) - - let store = CmuxConfigStore( - globalConfigPath: globalConfigURL.path, - localConfigPath: localConfigURL.path, - startFileWatchers: false - ) - store.loadAll() - - let resolved = try XCTUnwrap(store.resolvedNewWorkspaceCommand()) - XCTAssertEqual(resolved.command.name, "Local Dev") - XCTAssertEqual(resolved.sourcePath, localConfigURL.path) - XCTAssertTrue(store.configurationIssues.isEmpty) - } - - @MainActor - func testResolvedNewWorkspaceCommandExposesMissingCommandIssue() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent( - "cmux-config-store-\(UUID().uuidString)", - isDirectory: true - ) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let configURL = root.appendingPathComponent("cmux.json") - let json = """ - { - "newWorkspaceCommand": "Missing", - "commands": [{ - "name": "Dev Environment", - "workspace": { "name": "Dev" } - }] - } - """ - try json.write(to: configURL, atomically: true, encoding: .utf8) - - let store = CmuxConfigStore( - globalConfigPath: root.appendingPathComponent("missing-global.json").path, - localConfigPath: configURL.path, - startFileWatchers: false - ) - store.loadAll() - - XCTAssertNil(store.resolvedNewWorkspaceCommand()) - XCTAssertEqual(store.configurationIssues.first?.kind, .newWorkspaceCommandNotFound) - XCTAssertEqual(store.configurationIssues.first?.commandName, "Missing") - } - - @MainActor - func testResolvedNewWorkspaceCommandExposesNonWorkspaceIssue() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent( - "cmux-config-store-\(UUID().uuidString)", - isDirectory: true - ) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let configURL = root.appendingPathComponent("cmux.json") - let json = """ - { - "newWorkspaceCommand": "Run Tests", - "commands": [{ - "name": "Run Tests", - "command": "npm test" - }] - } - """ - try json.write(to: configURL, atomically: true, encoding: .utf8) - - let store = CmuxConfigStore( - globalConfigPath: root.appendingPathComponent("missing-global.json").path, - localConfigPath: configURL.path, - startFileWatchers: false - ) - store.loadAll() - - XCTAssertNil(store.resolvedNewWorkspaceCommand()) - XCTAssertEqual(store.configurationIssues.first?.kind, .newWorkspaceCommandRequiresWorkspace) - XCTAssertEqual(store.configurationIssues.first?.commandName, "Run Tests") - XCTAssertEqual(store.configurationIssues.first?.sourcePath, configURL.path) - } - - @MainActor - func testResolvedNewWorkspaceActionExposesNonWorkspaceActionIssue() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent( - "cmux-config-store-\(UUID().uuidString)", - isDirectory: true - ) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let configURL = root.appendingPathComponent("cmux.json") - let json = """ - { - "actions": { - "start-codex": { "type": "command", "command": "codex" } - }, - "ui": { - "newWorkspace": { "action": "start-codex" } - } - } - """ - try json.write(to: configURL, atomically: true, encoding: .utf8) - - let store = CmuxConfigStore( - globalConfigPath: root.appendingPathComponent("missing-global.json").path, - localConfigPath: configURL.path, - startFileWatchers: false - ) - store.loadAll() - - XCTAssertNil(store.resolvedNewWorkspaceCommand()) - XCTAssertEqual(store.configurationIssues.first?.kind, .newWorkspaceActionRequiresWorkspaceCommand) - XCTAssertEqual(store.configurationIssues.first?.sourcePath, configURL.path) - } - - func testDecodeActionsSurfaceTabBarButtonSupportsWorkspaceCommand() throws { - let json = """ - { - "actions": { - "new-dev": { "type": "workspaceCommand", "commandName": "Dev Environment" } - }, - "ui": { - "surfaceTabBar": { - "buttons": [ - { - "action": "new-dev", - "icon": { "type": "symbol", "name": "rectangle.stack.badge.plus" } - } - ] - } - }, - "commands": [{ - "name": "Dev Environment", - "workspace": { "name": "Dev" } - }] - } - """ - let config = try decode(json) - let rawButton = try XCTUnwrap(config.surfaceTabBarButtons?.first) - let button = try rawButton.resolved(actions: resolvedActions(from: config), codingPath: []) - XCTAssertEqual(button.workspaceCommandName, "Dev Environment") - XCTAssertNil(button.terminalCommand) - } - - func testSurfaceTabBarWorkspaceCommandButtonRoundTrips() throws { - let original = CmuxSurfaceTabBarButton( - id: "new-dev", - icon: .symbol("rectangle.stack.badge.plus"), - tooltip: "New dev workspace", - action: .workspaceCommand("Dev Environment"), - confirm: true - ) - - let data = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(CmuxSurfaceTabBarButton.self, from: data) - - XCTAssertEqual(decoded, original) - } - - @MainActor - func testSurfaceTabBarDropsUnresolvedWorkspaceCommandButtons() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent( - "cmux-config-store-\(UUID().uuidString)", - isDirectory: true - ) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let configURL = root.appendingPathComponent("cmux.json") - let json = """ - { - "ui": { - "surfaceTabBar": { - "buttons": [ - { "action": "newTerminal" }, - { "id": "dev", "type": "workspaceCommand", "commandName": "Dev Environment" }, - { "id": "typo", "type": "workspaceCommand", "commandName": "Typo" }, - { "id": "simple", "type": "workspaceCommand", "commandName": "Run Tests" } - ] - } - }, - "commands": [ - { - "name": "Dev Environment", - "workspace": { "name": "Dev" } - }, - { - "name": "Run Tests", - "command": "npm test" - } - ] - } - """ - try json.write(to: configURL, atomically: true, encoding: .utf8) - - let store = CmuxConfigStore( - globalConfigPath: root.appendingPathComponent("missing-global.json").path, - localConfigPath: configURL.path, - startFileWatchers: false - ) - store.loadAll() - - XCTAssertEqual(store.surfaceTabBarButtons.map(\.id), ["newTerminal", "dev"]) - XCTAssertEqual(store.surfaceTabBarButtons.last?.workspaceCommandName, "Dev Environment") - } - - func testDecodeEmptySurfaceTabBarButtons() throws { - let json = """ - { - "surfaceTabBarButtons": [] - } - """ - let config = try decode(json) - XCTAssertEqual(config.surfaceTabBarButtons, []) - XCTAssertTrue(config.commands.isEmpty) - } - func testDecodeEmptyCommandsArray() throws { let json = """ { "commands": [] } @@ -1275,26 +331,11 @@ final class CmuxConfigDecodingTests: XCTestCase { XCTAssertThrowsError(try decode(json)) } - func testDecodeMissingCommandsKeyAllowsActionOnlyConfig() throws { + func testDecodeMissingCommandsKeyThrows() { let json = """ - { - "actions": { - "start-codex": { "type": "agent", "agent": "codex" } - }, - "ui": { - "surfaceTabBar": { - "buttons": [ - { "action": "start-codex", "icon": { "type": "symbol", "name": "sparkles" } } - ] - } - } - } + { "notCommands": [] } """ - let config = try decode(json) - XCTAssertTrue(config.commands.isEmpty) - let rawButton = try XCTUnwrap(config.surfaceTabBarButtons?.first) - let button = try rawButton.resolved(actions: resolvedActions(from: config), codingPath: []) - XCTAssertEqual(button.terminalCommand, "codex") + XCTAssertThrowsError(try decode(json)) } func testDecodeInvalidSurfaceTypeThrows() { @@ -1467,47 +508,6 @@ final class CmuxConfigDecodingTests: XCTestCase { """ XCTAssertThrowsError(try decode(json)) } - - func testDecodeBlankNewWorkspaceCommandThrows() { - let json = """ - { - "newWorkspaceCommand": " ", - "commands": [] - } - """ - XCTAssertThrowsError(try decode(json)) - } - - func testDecodeDuplicateSurfaceTabBarButtonsThrows() { - let json = """ - { - "surfaceTabBarButtons": ["newTerminal", "newTerminal"], - "commands": [] - } - """ - XCTAssertThrowsError(try decode(json)) - } - - func testDecodeDuplicateSurfaceTabBarButtonIdsThrows() { - let json = """ - { - "surfaceTabBarButtons": [ - { - "id": "run", - "icon": { "type": "symbol", "name": "play" }, - "command": "npm run dev" - }, - { - "id": "run", - "icon": { "type": "symbol", "name": "checkmark" }, - "command": "npm test" - } - ], - "commands": [] - } - """ - XCTAssertThrowsError(try decode(json)) - } } // MARK: - Command identity diff --git a/cmuxTests/FileExplorerStoreTests.swift b/cmuxTests/FileExplorerStoreTests.swift index dcbadd6c7dc0..548cc81b11d6 100644 --- a/cmuxTests/FileExplorerStoreTests.swift +++ b/cmuxTests/FileExplorerStoreTests.swift @@ -293,25 +293,3 @@ final class FileExplorerStoreTests: XCTestCase { XCTAssertFalse(store.isExpanded(node)) } } - -final class FileSearchRipgrepParserTests: XCTestCase { - func testParseMatchLineBuildsRelativeSearchResult() { - let line = """ - {"type":"match","data":{"path":{"text":"/tmp/project/Sources/App.swift"},"lines":{"text":"let title = \\"Search files\\"\\n"},"line_number":42,"submatches":[{"match":{"text":"Search"},"start":13,"end":19}]}} - """ - - let result = FileSearchRipgrepParser.parseMatchLine(line, rootPath: "/tmp/project") - - XCTAssertEqual(result?.path, "/tmp/project/Sources/App.swift") - XCTAssertEqual(result?.relativePath, "Sources/App.swift") - XCTAssertEqual(result?.lineNumber, 42) - XCTAssertEqual(result?.columnNumber, 14) - XCTAssertEqual(result?.preview, "let title = \"Search files\"") - } - - func testParseMatchLineIgnoresNonMatchEvents() { - let line = #"{"type":"summary","data":{"elapsed_total":{"secs":0,"nanos":1}}}"# - - XCTAssertNil(FileSearchRipgrepParser.parseMatchLine(line, rootPath: "/tmp/project")) - } -} diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 8baa6b90da66..697830daa232 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -1,6 +1,5 @@ import XCTest import AppKit -import CoreText import WebKit import Darwin import SwiftUI @@ -94,36 +93,6 @@ final class GhosttyConfigTests: XCTestCase { XCTAssertTrue(paths.contains("\(pathB)/ghostty/themes/Solarized Light")) } - func testCmuxDefaultThemeConfigContentsSkipsInvalidUTF8Candidate() throws { - let fileManager = FileManager.default - let root = fileManager.temporaryDirectory - .appendingPathComponent("cmux-managed-theme-search-\(UUID().uuidString)") - try fileManager.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: root) } - - let firstDataDir = root.appendingPathComponent("first", isDirectory: true) - let secondDataDir = root.appendingPathComponent("second", isDirectory: true) - let firstThemeDir = firstDataDir.appendingPathComponent("ghostty/themes", isDirectory: true) - let secondThemeDir = secondDataDir.appendingPathComponent("ghostty/themes", isDirectory: true) - try fileManager.createDirectory(at: firstThemeDir, withIntermediateDirectories: true) - try fileManager.createDirectory(at: secondThemeDir, withIntermediateDirectories: true) - - let firstTheme = firstThemeDir.appendingPathComponent("Apple System Colors Light", isDirectory: false) - try Data([0xff, 0xfe]).write(to: firstTheme) - - let secondTheme = secondThemeDir.appendingPathComponent("Apple System Colors Light", isDirectory: false) - let expected = "foreground = #123456\n" - try expected.write(to: secondTheme, atomically: true, encoding: .utf8) - - let contents = GhosttyConfig.cmuxDefaultThemeConfigContents( - preferredColorScheme: .light, - environment: ["XDG_DATA_DIRS": "\(firstDataDir.path):\(secondDataDir.path)"], - bundleResourceURL: nil - ) - - XCTAssertEqual(contents, expected) - } - func testLoadReadsSymlinkedGhosttyConfigFile() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory @@ -767,7 +736,7 @@ final class WindowAppearanceSnapshotTests: XCTestCase { assertTerminalBackdrop(snapshot.policy(for: .windowRoot)) assertClearBackdrop(snapshot.policy(for: .terminalCanvas)) - assertClearBackdrop(snapshot.policy(for: .bonsplitChrome)) + assertClearBackdrop(snapshot.policy(for: .workspaceSplitChrome)) assertClearBackdrop(snapshot.policy(for: .titlebar)) assertClearBackdrop(snapshot.policy(for: .browserSurface)) assertClearBackdrop(snapshot.policy(for: .leftSidebar)) @@ -779,7 +748,7 @@ final class WindowAppearanceSnapshotTests: XCTestCase { assertTerminalBackdrop(snapshot.policy(for: .windowRoot)) assertClearBackdrop(snapshot.policy(for: .terminalCanvas)) - assertClearBackdrop(snapshot.policy(for: .bonsplitChrome)) + assertClearBackdrop(snapshot.policy(for: .workspaceSplitChrome)) assertClearBackdrop(snapshot.policy(for: .titlebar)) assertClearBackdrop(snapshot.policy(for: .browserSurface)) @@ -853,7 +822,7 @@ final class WindowAppearanceSnapshotTests: XCTestCase { } final class WindowChromeSeparatorColorTests: XCTestCase { - func testDarkChromeSeparatorMatchesBonsplitDerivation() { + func testDarkChromeSeparatorMatchesWorkspaceSplitDerivation() { guard let backgroundColor = NSColor(hex: "#272822") else { XCTFail("Expected valid test color") return @@ -868,7 +837,7 @@ final class WindowChromeSeparatorColorTests: XCTestCase { XCTAssertEqual(rgba.alpha, CGFloat(0.36), accuracy: 0.0001) } - func testLightChromeSeparatorMatchesBonsplitDerivation() { + func testLightChromeSeparatorMatchesWorkspaceSplitDerivation() { guard let backgroundColor = NSColor(hex: "#FDF6E3") else { XCTFail("Expected valid test color") return @@ -941,7 +910,7 @@ final class WorkspaceAppearanceConfigResolutionTests: XCTestCase { @MainActor final class WorkspaceChromeColorTests: XCTestCase { - func testBonsplitChromeHexIncludesAlphaWhenTranslucent() { + func testWorkspaceSplitChromeHexIncludesAlphaWhenTranslucent() { let color = NSColor( srgbRed: 17.0 / 255.0, green: 34.0 / 255.0, @@ -949,11 +918,11 @@ final class WorkspaceChromeColorTests: XCTestCase { alpha: 1.0 ) - let hex = Workspace.bonsplitChromeHex(backgroundColor: color, backgroundOpacity: 0.5) + let hex = Workspace.splitChromeHex(backgroundColor: color, backgroundOpacity: 0.5) XCTAssertEqual(hex, "#1122337F") } - func testBonsplitChromeHexOmitsAlphaWhenOpaque() { + func testWorkspaceSplitChromeHexOmitsAlphaWhenOpaque() { let color = NSColor( srgbRed: 17.0 / 255.0, green: 34.0 / 255.0, @@ -961,11 +930,11 @@ final class WorkspaceChromeColorTests: XCTestCase { alpha: 1.0 ) - let hex = Workspace.bonsplitChromeHex(backgroundColor: color, backgroundOpacity: 1.0) + let hex = Workspace.splitChromeHex(backgroundColor: color, backgroundOpacity: 1.0) XCTAssertEqual(hex, "#112233") } - func testBonsplitChromeHexKeepsBackdropWhenSharingWindowBackdrop() { + func testWorkspaceSplitChromeHexKeepsBackdropWhenSharingWindowBackdrop() { let color = NSColor( srgbRed: 17.0 / 255.0, green: 34.0 / 255.0, @@ -973,7 +942,7 @@ final class WorkspaceChromeColorTests: XCTestCase { alpha: 1.0 ) - let hex = Workspace.bonsplitChromeHex( + let hex = Workspace.splitChromeHex( backgroundColor: color, backgroundOpacity: 0.5, sharesWindowBackdrop: true @@ -981,7 +950,7 @@ final class WorkspaceChromeColorTests: XCTestCase { XCTAssertEqual(hex, "#1122337F") } - func testBonsplitChromeColorsKeepPaneClearWhenTerminalUsesHostLayerBackground() { + func testWorkspaceSplitChromeColorsKeepPaneClearWhenTerminalUsesHostLayerBackground() { let color = NSColor( srgbRed: 17.0 / 255.0, green: 34.0 / 255.0, @@ -989,7 +958,7 @@ final class WorkspaceChromeColorTests: XCTestCase { alpha: 1.0 ) - let colors = Workspace.bonsplitChromeColors( + let colors = Workspace.splitChromeColors( backgroundColor: color, backgroundOpacity: 0.5, renderingMode: .windowHostBackdrop @@ -1001,7 +970,7 @@ final class WorkspaceChromeColorTests: XCTestCase { XCTAssertEqual(colors.paneBackgroundHex, "#00000000") } - func testBonsplitChromeColorsKeepSemanticBackgroundButClearLocalBackdropsWhenSharingWindowBackdrop() { + func testWorkspaceSplitChromeColorsKeepSemanticBackgroundButClearLocalBackdropsWhenSharingWindowBackdrop() { let color = NSColor( srgbRed: 17.0 / 255.0, green: 34.0 / 255.0, @@ -1009,7 +978,7 @@ final class WorkspaceChromeColorTests: XCTestCase { alpha: 1.0 ) - let colors = Workspace.bonsplitChromeColors( + let colors = Workspace.splitChromeColors( backgroundColor: color, backgroundOpacity: 0.5, sharesWindowBackdrop: true, @@ -1426,41 +1395,6 @@ final class BrowserPanelPopupContextTests: XCTestCase { } } -final class BrowserNewTabNavigationSeedTests: XCTestCase { - func testPreservesOriginalRequestHeadersMethodBodyAndBypassHost() throws { - let url = try XCTUnwrap(URL(string: "https://www.linkedin.com/redir/redirect?url=https%3A%2F%2Fexample.com")) - let body = Data("payload=1".utf8) - var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData) - request.httpMethod = "POST" - request.httpBody = body - request.setValue("https://www.linkedin.com/feed/", forHTTPHeaderField: "Referer") - request.setValue("keep-me", forHTTPHeaderField: "X-Cmux-Test") - - let seed = try XCTUnwrap( - browserNewTabNavigationSeed( - from: request, - bypassInsecureHTTPHostOnce: "www.linkedin.com" - ) - ) - - // This covers the pure seeding helper only. WebKit may still rewrite - // programmatic loads when the request is replayed in the destination tab. - XCTAssertEqual(seed.url, url) - XCTAssertEqual(seed.bypassInsecureHTTPHostOnce, "www.linkedin.com") - XCTAssertEqual(seed.initialRequest.httpMethod, "POST") - XCTAssertEqual(seed.initialRequest.httpBody, body) - XCTAssertEqual( - seed.initialRequest.value(forHTTPHeaderField: "Referer"), - "https://www.linkedin.com/feed/" - ) - XCTAssertEqual( - seed.initialRequest.value(forHTTPHeaderField: "X-Cmux-Test"), - "keep-me" - ) - XCTAssertEqual(seed.initialRequest.cachePolicy, .reloadIgnoringLocalCacheData) - } -} - @MainActor final class BrowserPanelRemoteStoreTests: XCTestCase { func testRemoteWorkspacePanelsShareWorkspaceScopedWebsiteDataStore() { @@ -1531,8 +1465,8 @@ final class BrowserPanelRemoteStoreTests: XCTestCase { func testBrowserMoveIntoRemoteWorkspaceRebuildsWebsiteDataStoreScope() throws { let source = Workspace() - let sourcePaneId = try XCTUnwrap(source.bonsplitController.allPaneIds.first) - let sourceBrowser = try XCTUnwrap(source.newBrowserSurface(inPane: sourcePaneId, focus: false)) + let sourcePaneId = try XCTUnwrap(source.paneIds.first) + let sourceBrowser = try XCTUnwrap(source.createBrowserPanel(inPane: sourcePaneId, focus: false)) let localStore = sourceBrowser.webView.configuration.websiteDataStore XCTAssertTrue(localStore === WKWebsiteDataStore.default()) @@ -1552,8 +1486,8 @@ final class BrowserPanelRemoteStoreTests: XCTestCase { ), autoConnect: false ) - let destinationPaneId = try XCTUnwrap(destination.bonsplitController.allPaneIds.first) - let destinationBrowser = try XCTUnwrap(destination.newBrowserSurface(inPane: destinationPaneId, focus: false)) + let destinationPaneId = try XCTUnwrap(destination.paneIds.first) + let destinationBrowser = try XCTUnwrap(destination.createBrowserPanel(inPane: destinationPaneId, focus: false)) let destinationStore = destinationBrowser.webView.configuration.websiteDataStore XCTAssertFalse(destinationStore === WKWebsiteDataStore.default()) @@ -1584,14 +1518,14 @@ final class BrowserPanelRemoteStoreTests: XCTestCase { ), autoConnect: false ) - let sourcePaneId = try XCTUnwrap(source.bonsplitController.allPaneIds.first) - let movedBrowser = try XCTUnwrap(source.newBrowserSurface(inPane: sourcePaneId, focus: false)) - let remainingRemoteBrowser = try XCTUnwrap(source.newBrowserSurface(inPane: sourcePaneId, focus: false)) + let sourcePaneId = try XCTUnwrap(source.paneIds.first) + let movedBrowser = try XCTUnwrap(source.createBrowserPanel(inPane: sourcePaneId, focus: false)) + let remainingRemoteBrowser = try XCTUnwrap(source.createBrowserPanel(inPane: sourcePaneId, focus: false)) let remoteStore = remainingRemoteBrowser.webView.configuration.websiteDataStore XCTAssertFalse(remoteStore === WKWebsiteDataStore.default()) let destination = Workspace() - let destinationPaneId = try XCTUnwrap(destination.bonsplitController.allPaneIds.first) + let destinationPaneId = try XCTUnwrap(destination.paneIds.first) let detached = try XCTUnwrap(source.detachSurface(panelId: movedBrowser.id)) let attachedPanelId = try XCTUnwrap( destination.attachDetachedSurface(detached, inPane: destinationPaneId, focus: false) @@ -1605,7 +1539,7 @@ final class BrowserPanelRemoteStoreTests: XCTestCase { func testNewTerminalSurfaceStaysRemoteWhileBrowserPanelsKeepWorkspaceRemote() throws { let workspace = Workspace() - let paneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) + let paneId = try XCTUnwrap(workspace.paneIds.first) let initialTerminalId = try XCTUnwrap(workspace.focusedPanelId) let configuration = WorkspaceRemoteConfiguration( destination: "cmux-macmini", @@ -1621,14 +1555,14 @@ final class BrowserPanelRemoteStoreTests: XCTestCase { ) workspace.configureRemoteConnection(configuration, autoConnect: false) - _ = workspace.newBrowserSurface(inPane: paneId, url: URL(string: "https://example.com"), focus: false) + _ = workspace.createBrowserPanel(inPane: paneId, url: URL(string: "https://example.com"), focus: false) workspace.markRemoteTerminalSessionEnded(surfaceId: initialTerminalId, relayPort: configuration.relayPort) XCTAssertTrue(workspace.isRemoteWorkspace) XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) - _ = try XCTUnwrap(workspace.newTerminalSurface(inPane: paneId, focus: false)) + _ = try XCTUnwrap(workspace.createTerminalPanel(inPane: paneId, focus: false)) XCTAssertTrue(workspace.isRemoteWorkspace) XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 1) @@ -2592,38 +2526,6 @@ final class GhosttyMouseFocusTests: XCTestCase { XCTAssertFalse(hiraginoRanges.contains("U+AC00-U+D7AF"), "Hangul NOT in Hiragino") } - func testResolvedInjectedCJKFontNamePinsRegularWeightForHiraginoSans() throws { - guard let plain = GhosttyApp.discoveredCTFont(named: "Hiragino Sans"), - let pinned = GhosttyApp.discoveredCTFont( - named: GhosttyApp.resolvedInjectedCJKFontName(named: "Hiragino Sans") - ) else { - throw XCTSkip("Hiragino Sans is unavailable on this runner") - } - - let plainFullName = CTFontCopyFullName(plain) as String - let pinnedFullName = CTFontCopyFullName(pinned) as String - - XCTAssertEqual(CTFontCopyFamilyName(pinned) as String, "Hiragino Sans") - XCTAssertFalse(pinnedFullName.contains(" W0")) - if plainFullName.contains(" W0") { - XCTAssertNotEqual( - CTFontCopyPostScriptName(plain) as String, - CTFontCopyPostScriptName(pinned) as String - ) - } - } - - func testResolvedInjectedCJKFontNameLeavesPingFangSCStable() throws { - guard GhosttyApp.discoveredCTFont(named: "PingFang SC") != nil else { - throw XCTSkip("PingFang SC is unavailable on this runner") - } - - XCTAssertEqual( - GhosttyApp.resolvedInjectedCJKFontName(named: "PingFang SC"), - "PingFang SC" - ) - } - // MARK: autoInjectedCJKFontMappings func testAutoInjectedCJKFontMappingsSkipsRangesCoveredByConfiguredPrimaryFont() throws { @@ -2943,7 +2845,7 @@ final class GhosttyMouseFocusTests: XCTestCase { } } - func testLoadedCJKScanPathsIncludesNativeGhosttyAppSupportWhenTaggedConfigExists() throws { + func testLoadedCJKScanPathsSkipsReleaseAppSupportWhenTaggedConfigExists() throws { let appSupport = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-test-cjk-app-support-\(UUID().uuidString)") try FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true) @@ -2958,7 +2860,6 @@ final class GhosttyMouseFocusTests: XCTestCase { let releaseDir = appSupport.appendingPathComponent("com.mitchellh.ghostty", isDirectory: true) try FileManager.default.createDirectory(at: releaseDir, withIntermediateDirectories: true) let releaseConfig = releaseDir.appendingPathComponent("config", isDirectory: false) - let releaseConfigGhostty = releaseDir.appendingPathComponent("config.ghostty", isDirectory: false) try "font-family = LXGW WenKai Mono TC\n" .write(to: releaseConfig, atomically: true, encoding: .utf8) @@ -2968,145 +2869,14 @@ final class GhosttyMouseFocusTests: XCTestCase { ) XCTAssertTrue(paths.contains(taggedConfig.path)) - XCTAssertTrue(paths.contains(releaseConfig.path)) - XCTAssertTrue(paths.contains(releaseConfigGhostty.path)) - XCTAssertFalse( + XCTAssertFalse(paths.contains(releaseConfig.path)) + XCTAssertTrue( GhosttyApp.shouldInjectCJKFontFallback( preferredLanguages: ["zh-Hans-CN"], configPaths: paths ) ) } - - func testShouldApplyManagedDefaultAppearanceScansNativeGhosttyAppSupport() throws { - let appSupport = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-test-appearance-app-support-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: appSupport) } - - let ghosttyDir = appSupport.appendingPathComponent("com.mitchellh.ghostty", isDirectory: true) - try FileManager.default.createDirectory(at: ghosttyDir, withIntermediateDirectories: true) - let nativeConfig = ghosttyDir.appendingPathComponent("config", isDirectory: false) - try "theme = Dracula\n" - .write(to: nativeConfig, atomically: true, encoding: .utf8) - - let paths = GhosttyApp.loadedGhosttyConfigScanPaths( - currentBundleIdentifier: "com.example.cmux-dev", - appSupportDirectory: appSupport - ) - - XCTAssertTrue(paths.contains(nativeConfig.path)) - XCTAssertFalse(GhosttyApp.shouldApplyManagedDefaultAppearance(configPaths: paths)) - } - - // MARK: shouldApplyManagedDefaultAppearance - - func testShouldApplyManagedDefaultAppearanceAllowsNonAppearanceConfig() throws { - try withTempConfig(""" - font-family = JetBrains Mono - background-opacity = 0.92 - """) { path in - XCTAssertTrue( - GhosttyApp.shouldApplyManagedDefaultAppearance(configPaths: [path]) - ) - } - } - - func testShouldApplyManagedDefaultAppearanceSkipsExplicitTheme() throws { - try withTempConfig("theme = Catppuccin Mocha\n") { path in - XCTAssertFalse( - GhosttyApp.shouldApplyManagedDefaultAppearance(configPaths: [path]) - ) - } - } - - func testShouldApplyManagedDefaultAppearanceSkipsExplicitTerminalColorDirective() throws { - try withTempConfig("background = #101010\n") { path in - XCTAssertFalse( - GhosttyApp.shouldApplyManagedDefaultAppearance(configPaths: [path]) - ) - } - } - - func testShouldApplyManagedDefaultAppearanceFollowsConfigFileIncludes() throws { - let dir = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-test-theme-include-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: dir) } - - let included = dir.appendingPathComponent("appearance.conf") - try "theme = Catppuccin Latte\n" - .write(to: included, atomically: true, encoding: .utf8) - - let main = dir.appendingPathComponent("config") - try "font-family = JetBrains Mono\nconfig-file = \(included.path)\n" - .write(to: main, atomically: true, encoding: .utf8) - - XCTAssertFalse( - GhosttyApp.shouldApplyManagedDefaultAppearance(configPaths: [main.path]) - ) - } - - func testShouldApplyManagedDefaultAppearancePreservesQuotedQuestionMarkConfigFile() throws { - let dir = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-test-theme-quoted-include-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: dir) } - - let included = dir.appendingPathComponent("?appearance.conf") - try "theme = Catppuccin Latte\n" - .write(to: included, atomically: true, encoding: .utf8) - - let main = dir.appendingPathComponent("config") - try "config-file = \"?appearance.conf\"\n" - .write(to: main, atomically: true, encoding: .utf8) - - XCTAssertFalse( - GhosttyApp.shouldApplyManagedDefaultAppearance(configPaths: [main.path]) - ) - } - - func testShouldApplyManagedDefaultAppearanceProcessesIncludeQueuedAfterReset() throws { - let dir = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-test-theme-reset-include-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: dir) } - - let themed = dir.appendingPathComponent("appearance.conf") - try "theme = Catppuccin Latte\n" - .write(to: themed, atomically: true, encoding: .utf8) - - let first = dir.appendingPathComponent("first.conf") - try """ - config-file = - config-file = appearance.conf - """ - .write(to: first, atomically: true, encoding: .utf8) - - let main = dir.appendingPathComponent("config") - try "config-file = first.conf\n" - .write(to: main, atomically: true, encoding: .utf8) - - XCTAssertFalse( - GhosttyApp.shouldApplyManagedDefaultAppearance(configPaths: [main.path]) - ) - } - - func testStartupAppearanceFreshInstallPreviewUsesManagedDefaultColorsWithoutSettingTheme() { - #if DEBUG - let previousProfile = GhosttyStartupAppearancePreviewState.profile - GhosttyStartupAppearancePreviewState.profile = .freshInstall - GhosttyConfig.invalidateLoadCache() - defer { - GhosttyStartupAppearancePreviewState.profile = previousProfile - GhosttyConfig.invalidateLoadCache() - } - - let config = GhosttyConfig.load(preferredColorScheme: .light, useCache: false) - XCTAssertNil(config.theme) - XCTAssertEqual(config.backgroundColor.hexString(), "#FEFFFF") - #endif - } } final class SidebarBackgroundConfigTests: XCTestCase { diff --git a/cmuxTests/GhosttyEnsureFocusWindowActivationTests.swift b/cmuxTests/GhosttyEnsureFocusWindowActivationTests.swift index 308c5ce2eab4..03174331ab7e 100644 --- a/cmuxTests/GhosttyEnsureFocusWindowActivationTests.swift +++ b/cmuxTests/GhosttyEnsureFocusWindowActivationTests.swift @@ -67,4 +67,277 @@ final class GhosttyEnsureFocusWindowActivationTests: XCTestCase { ) ) } + + func testRightSidebarFocusOwnerBlocksDeferredTerminalFirstResponderRequest() { +#if DEBUG + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let tabManager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = tabManager.selectedWorkspace, + let terminalPanel = workspace.focusedTerminalPanel else { + XCTFail("Expected a focused terminal panel") + return + } + + let hostedView = terminalPanel.hostedView + hostedView.setVisibleInUI(true) + hostedView.setActive(true) + appDelegate.noteRightSidebarKeyboardFocusIntent(mode: .files, in: window) + + XCTAssertFalse( + hostedView.debugRequestSurfaceFirstResponderForTesting(in: window, reason: "test.rightSidebar"), + "Deferred terminal focus retries should not steal first responder while the right sidebar owns keyboard focus" + ) + XCTAssertFalse(hostedView.isSurfaceViewFirstResponder()) +#else + XCTFail("debugRequestSurfaceFirstResponderForTesting is only available in DEBUG") +#endif + } + + func testRightSidebarFocusOwnerBlocksAlreadyFirstResponderTerminalReassertion() { +#if DEBUG + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let tabManager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = tabManager.selectedWorkspace, + let terminalPanel = workspace.focusedTerminalPanel else { + XCTFail("Expected a focused terminal panel") + return + } + + let hostedView = terminalPanel.hostedView + hostedView.setVisibleInUI(true) + hostedView.setActive(true) + window.makeKeyAndOrderFront(nil) + + XCTAssertTrue( + hostedView.debugRequestSurfaceFirstResponderForTesting(in: window, reason: "test.initialTerminal"), + "Test setup should put the terminal surface in the responder chain" + ) + XCTAssertTrue(hostedView.isSurfaceViewFirstResponder()) + XCTAssertTrue(terminalPanel.surface.debugDesiredFocusState()) + + appDelegate.noteRightSidebarKeyboardFocusIntent(mode: .files, in: window) + hostedView.yieldTerminalSurfaceFocusForForeignResponder(reason: "test.rightSidebar") + XCTAssertFalse(terminalPanel.surface.debugDesiredFocusState()) + + hostedView.debugApplyFirstResponderIfNeededForTesting() + + XCTAssertFalse( + terminalPanel.surface.debugDesiredFocusState(), + "Deferred terminal focus applies must not restart Ghostty cursor blinking while the right sidebar owns keyboard focus" + ) +#else + XCTFail("debugApplyFirstResponderIfNeededForTesting is only available in DEBUG") +#endif + } + + func testRightSidebarResponderBlocksDeferredTerminalApplyAfterIntentDrift() { +#if DEBUG + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let tabManager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = tabManager.selectedWorkspace, + let terminalPanel = workspace.focusedTerminalPanel else { + XCTFail("Expected a focused terminal panel") + return + } + + let hostedView = terminalPanel.hostedView + hostedView.setVisibleInUI(true) + hostedView.setActive(true) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1)) + + XCTAssertTrue( + appDelegate.focusRightSidebarInActiveMainWindow(preferredWindow: window), + "Test setup should move AppKit focus into the right sidebar" + ) + guard let rightSidebarResponder = window.firstResponder else { + XCTFail("Expected a right sidebar first responder") + return + } + XCTAssertTrue( + appDelegate.isRightSidebarFocusResponder(rightSidebarResponder, in: window), + "Test setup should produce a right-sidebar-owned first responder" + ) + + appDelegate.noteMainPanelKeyboardFocusIntent(workspaceId: workspace.id, panelId: terminalPanel.id, in: window) + + hostedView.debugApplyFirstResponderIfNeededForTesting() + + XCTAssertTrue( + window.firstResponder === rightSidebarResponder, + "Deferred terminal focus must not steal AppKit focus from a right-sidebar responder even if model intent drifted" + ) + XCTAssertFalse( + terminalPanel.surface.debugDesiredFocusState(), + "Deferred terminal focus must not restart Ghostty cursor blinking while right-sidebar AppKit focus is active" + ) +#else + XCTFail("debugApplyFirstResponderIfNeededForTesting is only available in DEBUG") +#endif + } + + func testRightSidebarIntentBlocksDirectTerminalFirstResponderSteal() { +#if DEBUG + AppDelegate.installWindowResponderSwizzlesForTesting() + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let tabManager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = tabManager.selectedWorkspace, + let terminalPanel = workspace.focusedTerminalPanel, + let terminalSurfaceView = surfaceView(in: terminalPanel.hostedView) else { + XCTFail("Expected a focused terminal surface") + return + } + + terminalPanel.hostedView.setVisibleInUI(true) + terminalPanel.hostedView.setActive(true) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1)) + + XCTAssertTrue( + appDelegate.focusRightSidebarInActiveMainWindow(preferredWindow: window), + "Test setup should move AppKit focus into the right sidebar" + ) + guard let rightSidebarResponder = window.firstResponder else { + XCTFail("Expected a right sidebar first responder") + return + } + XCTAssertTrue(appDelegate.isRightSidebarFocusResponder(rightSidebarResponder, in: window)) + + XCTAssertFalse( + window.makeFirstResponder(terminalSurfaceView), + "Programmatic AppKit first responder changes must not steal focus back from the right sidebar" + ) + XCTAssertTrue( + window.firstResponder === rightSidebarResponder, + "The right sidebar first responder should remain active after a blocked terminal focus request" + ) + XCTAssertFalse( + terminalPanel.surface.debugDesiredFocusState(), + "Blocked AppKit focus requests must not restart Ghostty cursor blinking" + ) +#else + XCTFail("Debug-only regression test") +#endif + } + + func testPointerTerminalFocusEscapesRightSidebarIntent() { +#if DEBUG + AppDelegate.installWindowResponderSwizzlesForTesting() + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let tabManager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = tabManager.selectedWorkspace, + let terminalPanel = workspace.focusedTerminalPanel, + let terminalSurfaceView = surfaceView(in: terminalPanel.hostedView) else { + XCTFail("Expected a focused terminal surface") + return + } + + terminalPanel.hostedView.setVisibleInUI(true) + terminalPanel.hostedView.setActive(true) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1)) + + XCTAssertTrue( + appDelegate.focusRightSidebarInActiveMainWindow(preferredWindow: window), + "Test setup should move AppKit focus into the right sidebar" + ) + guard let event = makeMouseDownEvent(in: window) else { + XCTFail("Failed to build mouse event") + return + } + AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: event, hitView: terminalSurfaceView) + defer { AppDelegate.clearWindowFirstResponderGuardTesting() } + + XCTAssertTrue( + window.makeFirstResponder(terminalSurfaceView), + "A real pointer click on the terminal should be allowed to move focus back from the right sidebar" + ) + XCTAssertTrue(terminalPanel.hostedView.isSurfaceViewFirstResponder()) + XCTAssertTrue( + terminalPanel.surface.debugDesiredFocusState(), + "Pointer-initiated terminal focus should restart Ghostty cursor focus" + ) +#else + XCTFail("Debug-only regression test") +#endif + } + + private func window(withId windowId: UUID) -> NSWindow? { + let identifier = "cmux.main.\(windowId.uuidString)" + return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) + } + + private func closeWindow(withId windowId: UUID) { + guard let window = window(withId: windowId) else { return } + window.performClose(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + } + + private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> GhosttyNSView? { + var stack: [NSView] = [hostedView] + while let current = stack.popLast() { + if let surfaceView = current as? GhosttyNSView { + return surfaceView + } + stack.append(contentsOf: current.subviews) + } + return nil + } + + private func makeMouseDownEvent(in window: NSWindow) -> NSEvent? { + NSEvent.mouseEvent( + with: .leftMouseDown, + location: .zero, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1.0 + ) + } } diff --git a/cmuxTests/NotificationAndMenuBarTests.swift b/cmuxTests/NotificationAndMenuBarTests.swift index 8003ed5760b1..cf2f3bf4232d 100644 --- a/cmuxTests/NotificationAndMenuBarTests.swift +++ b/cmuxTests/NotificationAndMenuBarTests.swift @@ -4,7 +4,6 @@ import SwiftUI import UniformTypeIdentifiers import WebKit import ObjectiveC.runtime -import Bonsplit import UserNotifications #if canImport(cmux_DEV) @@ -1029,7 +1028,7 @@ final class FocusedNotificationIndicatorTests: XCTestCase { guard let workspace = manager.selectedWorkspace, let leftPanelId = workspace.focusedPanelId, - let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + let rightPanel = workspace.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .horizontal) else { XCTFail("Expected split workspace setup") return } diff --git a/cmuxTests/OmnibarAndToolsTests.swift b/cmuxTests/OmnibarAndToolsTests.swift index 8efdcc694f69..3e0e56961bf1 100644 --- a/cmuxTests/OmnibarAndToolsTests.swift +++ b/cmuxTests/OmnibarAndToolsTests.swift @@ -4,7 +4,6 @@ import SwiftUI import UniformTypeIdentifiers import WebKit import ObjectiveC.runtime -import Bonsplit import UserNotifications #if canImport(cmux_DEV) @@ -124,22 +123,6 @@ final class FinderServicePathResolverTests: XCTestCase { } } - func testOrderedUniqueDirectoriesDedupesSymlinkAndRealPaths() throws { - try withTemporaryDirectory { root in - let actualDirectory = root.appendingPathComponent("actual/project", isDirectory: true) - let aliasDirectory = root.appendingPathComponent("alias-project", isDirectory: true) - - try FileManager.default.createDirectory(at: actualDirectory, withIntermediateDirectories: true) - try FileManager.default.createSymbolicLink(at: aliasDirectory, withDestinationURL: actualDirectory) - - let directories = FinderServicePathResolver.orderedUniqueDirectories( - from: [aliasDirectory, actualDirectory] - ) - - XCTAssertEqual(directories, [aliasDirectory.standardizedFileURL.path]) - } - } - func testOrderedUniqueDirectoriesResolvesSymlinksOnlyForExcludedRootComparison() throws { try withTemporaryDirectory { root in let applicationsDirectory = root.appendingPathComponent("Applications", isDirectory: true) @@ -434,6 +417,24 @@ final class OmnibarStateMachineTests: XCTestCase { XCTAssertTrue(effects.shouldBlurToWebView) } + func testEscapeBlursWhenFocusedWithoutEditingEvenIfSuggestionsAreOpen() throws { + var state = OmnibarState() + + _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/")) + _ = omnibarReduce( + state: &state, + event: .suggestionsUpdated([.history(url: "https://example.com/", title: "Example")]) + ) + XCTAssertFalse(state.isUserEditing) + XCTAssertEqual(state.suggestions.count, 1) + + let effects = omnibarReduce(state: &state, event: .escape) + + XCTAssertTrue(effects.shouldBlurToWebView) + XCTAssertFalse(effects.shouldSelectAll) + XCTAssertTrue(state.suggestions.isEmpty) + } + func testPanelURLChangeDoesNotClobberUserBufferWhileEditing() throws { var state = OmnibarState() _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://a.test/")) diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index fd8ed4cb665d..428c46d5eb57 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -23,9 +23,9 @@ final class SessionPersistenceTests: XCTestCase { try "# hello\n".write(to: markdownURL, atomically: true, encoding: .utf8) let workspace = Workspace() - let paneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) + let paneId = try XCTUnwrap(workspace.paneIds.first) let panel = try XCTUnwrap( - workspace.newMarkdownSurface( + workspace.createMarkdownPanel( inPane: paneId, filePath: markdownURL.path, focus: true @@ -64,7 +64,7 @@ final class SessionPersistenceTests: XCTestCase { ) workspace.configureRemoteConnection(configuration, autoConnect: false) - workspace.surfaceListeningPorts[panelId] = [6969] + workspace.setSurfaceListeningPorts(panelId: panelId, ports: [6969]) let snapshot = workspace.sessionSnapshot(includeScrollback: false) let panelSnapshot = try XCTUnwrap(snapshot.panels.first { $0.id == panelId }) @@ -98,52 +98,6 @@ final class SessionPersistenceTests: XCTestCase { XCTAssertEqual(visibleFrame.y, 25, accuracy: 0.001) } - func testLoadReopenSessionSnapshotRequiresPreviousSnapshotFile() throws { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) - try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: tempDir) } - - let bundleIdentifier = "dev.cmux.tests.\(UUID().uuidString)" - let activeSnapshotURL = try XCTUnwrap( - SessionPersistenceStore.defaultSnapshotFileURL( - bundleIdentifier: bundleIdentifier, - appSupportDirectory: tempDir - ) - ) - let previousSnapshotURL = try XCTUnwrap( - SessionPersistenceStore.manualRestoreSnapshotFileURL( - bundleIdentifier: bundleIdentifier, - appSupportDirectory: tempDir - ) - ) - - XCTAssertTrue( - SessionPersistenceStore.save( - makeSnapshot(version: SessionSnapshotSchema.currentVersion), - fileURL: activeSnapshotURL - ) - ) - XCTAssertNil( - SessionPersistenceStore.loadReopenSessionSnapshot( - bundleIdentifier: bundleIdentifier, - appSupportDirectory: tempDir - ) - ) - - var previousSnapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion) - previousSnapshot.windows[0].sidebar.width = 321 - XCTAssertTrue(SessionPersistenceStore.save(previousSnapshot, fileURL: previousSnapshotURL)) - - let loaded = try XCTUnwrap( - SessionPersistenceStore.loadReopenSessionSnapshot( - bundleIdentifier: bundleIdentifier, - appSupportDirectory: tempDir - ) - ) - XCTAssertEqual(loaded.windows.first?.sidebar.width, 321) - } - func testSaveAndLoadRoundTripPreservesWorkspaceCustomColor() { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) @@ -479,22 +433,22 @@ final class SessionPersistenceTests: XCTestCase { ) } - func testShouldSkipSessionSaveDuringRestorePolicy() { + func testShouldSkipSessionSaveDuringStartupRestorePolicy() { XCTAssertTrue( - AppDelegate.shouldSkipSessionSaveDuringRestore( - isApplyingSessionRestore: true, + AppDelegate.shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: true, includeScrollback: false ) ) XCTAssertFalse( - AppDelegate.shouldSkipSessionSaveDuringRestore( - isApplyingSessionRestore: true, + AppDelegate.shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: true, includeScrollback: true ) ) XCTAssertFalse( - AppDelegate.shouldSkipSessionSaveDuringRestore( - isApplyingSessionRestore: false, + AppDelegate.shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: false, includeScrollback: false ) ) @@ -536,19 +490,6 @@ final class SessionPersistenceTests: XCTestCase { ) } - func testRestoreCompletionSavePolicySkipsManualReopen() { - XCTAssertTrue( - AppDelegate.shouldSaveSessionSnapshotOnRestoreCompletion( - isManualReopen: false - ) - ) - XCTAssertFalse( - AppDelegate.shouldSaveSessionSnapshotOnRestoreCompletion( - isManualReopen: true - ) - ) - } - func testUnchangedAutosaveFingerprintSkipsWithinStalenessWindow() { let now = Date() XCTAssertTrue( @@ -603,47 +544,6 @@ final class SessionPersistenceTests: XCTestCase { ) } - func testSessionAutosaveFingerprintIncludesRestorableAgentMetadata() throws { - let workspaceId = UUID() - let panelId = UUID() - let baselineFingerprint = TabManager.restorableAgentSnapshotFingerprint(nil) - - let firstIndex = try makeRestorableAgentIndex( - workspaceId: workspaceId, - panelId: panelId, - sessionId: "codex-session-1", - arguments: [ - "/usr/local/bin/codex", - "--model", - "gpt-5.4", - "resume", - "codex-session-1", - ] - ) - let firstFingerprint = TabManager.restorableAgentSnapshotFingerprint( - try XCTUnwrap(firstIndex.snapshot(workspaceId: workspaceId, panelId: panelId)) - ) - - let secondIndex = try makeRestorableAgentIndex( - workspaceId: workspaceId, - panelId: panelId, - sessionId: "codex-session-2", - arguments: [ - "/usr/local/bin/codex", - "--model", - "gpt-5.4-mini", - "resume", - "codex-session-2", - ] - ) - let secondFingerprint = TabManager.restorableAgentSnapshotFingerprint( - try XCTUnwrap(secondIndex.snapshot(workspaceId: workspaceId, panelId: panelId)) - ) - - XCTAssertNotEqual(baselineFingerprint, firstFingerprint) - XCTAssertNotEqual(firstFingerprint, secondFingerprint) - } - func testResolvedWindowFramePrefersSavedDisplayIdentity() { let savedFrame = SessionRectSnapshot(x: 1_200, y: 100, width: 600, height: 400) let savedDisplay = SessionDisplaySnapshot( @@ -972,341 +872,6 @@ final class SessionPersistenceTests: XCTestCase { XCTAssertNil(resolved) } - func testRestorableAgentRestoreSuppressesSavedScrollbackReplay() { - let agent = SessionRestorableAgentSnapshot( - kind: .claude, - sessionId: "claude-session-123", - workingDirectory: "/tmp/repo", - launchCommand: nil - ) - - XCTAssertFalse(Workspace.shouldReplaySessionScrollback(restorableAgent: agent)) - XCTAssertTrue(Workspace.shouldReplaySessionScrollback(restorableAgent: nil)) - } - - @MainActor - func testRestoredAgentFirstAutoResumeCommandDoesNotClearSnapshot() throws { - let source = Workspace() - let sourcePanelId = try XCTUnwrap(source.focusedPanelId) - let sourceIndex = try makeRestorableAgentIndex( - workspaceId: source.id, - panelId: sourcePanelId, - sessionId: "codex-restored-session", - arguments: [ - "/usr/local/bin/codex", - "--model", - "gpt-5.4", - ] - ) - let snapshot = source.sessionSnapshot( - includeScrollback: false, - restorableAgentIndex: sourceIndex - ) - - let restored = Workspace() - restored.restoreSessionSnapshot(snapshot) - let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) - - restored.updatePanelShellActivityState(panelId: restoredPanelId, state: .commandRunning) - let autoResumeSnapshot = restored.sessionSnapshot(includeScrollback: false) - XCTAssertEqual(autoResumeSnapshot.panels.first?.terminal?.agent?.sessionId, "codex-restored-session") - - restored.updatePanelShellActivityState(panelId: restoredPanelId, state: .promptIdle) - restored.updatePanelShellActivityState(panelId: restoredPanelId, state: .commandRunning) - let userCommandSnapshot = restored.sessionSnapshot(includeScrollback: false) - XCTAssertNil(userCommandSnapshot.panels.first?.terminal?.agent) - } - - @MainActor - func testRestoredAgentWithoutResumeCommandInvalidatesOnFirstCommand() throws { - let source = Workspace() - let sourcePanelId = try XCTUnwrap(source.focusedPanelId) - let sourceIndex = try makeRestorableAgentIndex( - kind: .claude, - workspaceId: source.id, - panelId: sourcePanelId, - sessionId: "claude-print-session", - arguments: [ - "/usr/local/bin/claude", - "--print", - ] - ) - let snapshot = source.sessionSnapshot( - includeScrollback: false, - restorableAgentIndex: sourceIndex - ) - - let restored = Workspace() - restored.restoreSessionSnapshot(snapshot) - let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) - XCTAssertNil(restored.sessionSnapshot(includeScrollback: false).panels.first?.terminal?.agent?.resumeCommand) - - restored.updatePanelShellActivityState(panelId: restoredPanelId, state: .commandRunning) - let userCommandSnapshot = restored.sessionSnapshot(includeScrollback: false) - XCTAssertNil(userCommandSnapshot.panels.first?.terminal?.agent) - } - - @MainActor - func testPruneSurfaceMetadataRemovesRestoredAgentBookkeeping() throws { - let source = Workspace() - let sourcePanelId = try XCTUnwrap(source.focusedPanelId) - let sourceIndex = try makeRestorableAgentIndex( - workspaceId: source.id, - panelId: sourcePanelId, - sessionId: "codex-prune-pending-session", - arguments: [ - "/usr/local/bin/codex", - "--model", - "gpt-5.4", - ] - ) - let snapshot = source.sessionSnapshot( - includeScrollback: false, - restorableAgentIndex: sourceIndex - ) - - let restored = Workspace() - restored.restoreSessionSnapshot(snapshot) - let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) - restored.pruneSurfaceMetadata(validSurfaceIds: []) - - let postPruneIndex = try makeRestorableAgentIndex( - workspaceId: restored.id, - panelId: restoredPanelId, - sessionId: "codex-post-prune-session", - arguments: [ - "/usr/local/bin/codex", - "--model", - "gpt-5.4-mini", - ] - ) - let postPruneSnapshot = restored.sessionSnapshot( - includeScrollback: false, - restorableAgentIndex: postPruneIndex - ) - XCTAssertEqual( - postPruneSnapshot.panels.first?.terminal?.agent?.sessionId, - "codex-post-prune-session" - ) - - restored.updatePanelShellActivityState(panelId: restoredPanelId, state: .promptIdle) - restored.updatePanelShellActivityState(panelId: restoredPanelId, state: .commandRunning) - let userCommandSnapshot = restored.sessionSnapshot(includeScrollback: false) - XCTAssertNil(userCommandSnapshot.panels.first?.terminal?.agent) - - let staleWorkspace = Workspace() - let stalePanelId = try XCTUnwrap(staleWorkspace.focusedPanelId) - let staleIndex = try makeRestorableAgentIndex( - workspaceId: staleWorkspace.id, - panelId: stalePanelId, - sessionId: "codex-prune-invalidated-session", - arguments: [ - "/usr/local/bin/codex", - "--model", - "gpt-5.4", - ] - ) - _ = staleWorkspace.sessionSnapshot( - includeScrollback: false, - restorableAgentIndex: staleIndex - ) - - staleWorkspace.updatePanelShellActivityState(panelId: stalePanelId, state: .promptIdle) - staleWorkspace.updatePanelShellActivityState(panelId: stalePanelId, state: .commandRunning) - let staleSnapshot = staleWorkspace.sessionSnapshot( - includeScrollback: false, - restorableAgentIndex: staleIndex - ) - XCTAssertNil(staleSnapshot.panels.first?.terminal?.agent) - - staleWorkspace.pruneSurfaceMetadata(validSurfaceIds: []) - let acceptedSnapshot = staleWorkspace.sessionSnapshot( - includeScrollback: false, - restorableAgentIndex: staleIndex - ) - XCTAssertEqual( - acceptedSnapshot.panels.first?.terminal?.agent?.sessionId, - "codex-prune-invalidated-session" - ) - } - - @MainActor - func testUserCommandInvalidatesStaleRestoredAgentForAllProviders() throws { - let scenarios: [(kind: RestorableAgentKind, arguments: [String])] = [ - ( - .claude, - [ - "/usr/local/bin/claude", - "--model", - "sonnet", - ] - ), - ( - .codex, - [ - "/usr/local/bin/codex", - "--model", - "gpt-5.4", - ] - ), - ( - .opencode, - [ - "/usr/local/bin/opencode", - "--model", - "anthropic/claude-sonnet-4-5", - ] - ), - ] - - for scenario in scenarios { - let workspace = Workspace() - let panelId = try XCTUnwrap(workspace.focusedPanelId) - let staleIndex = try makeRestorableAgentIndex( - kind: scenario.kind, - workspaceId: workspace.id, - panelId: panelId, - sessionId: "\(scenario.kind.rawValue)-old-session", - arguments: scenario.arguments - ) - let initialSnapshot = workspace.sessionSnapshot( - includeScrollback: false, - restorableAgentIndex: staleIndex - ) - XCTAssertEqual(initialSnapshot.panels.first?.terminal?.agent?.kind, scenario.kind) - - workspace.updatePanelShellActivityState(panelId: panelId, state: .promptIdle) - workspace.updatePanelShellActivityState(panelId: panelId, state: .commandRunning) - - let staleSnapshot = workspace.sessionSnapshot( - includeScrollback: false, - restorableAgentIndex: staleIndex - ) - XCTAssertNil(staleSnapshot.panels.first?.terminal?.agent, scenario.kind.rawValue) - } - } - - @MainActor - func testUserCommandInvalidatesStaleRestoredAgentButAcceptsNewHookFlags() throws { - let workspace = Workspace() - let panelId = try XCTUnwrap(workspace.focusedPanelId) - let staleIndex = try makeRestorableAgentIndex( - workspaceId: workspace.id, - panelId: panelId, - sessionId: "codex-old-session", - arguments: [ - "/usr/local/bin/codex", - "--model", - "gpt-5.4", - ] - ) - let initialSnapshot = workspace.sessionSnapshot( - includeScrollback: false, - restorableAgentIndex: staleIndex - ) - XCTAssertEqual(initialSnapshot.panels.first?.terminal?.agent?.sessionId, "codex-old-session") - - workspace.updatePanelShellActivityState(panelId: panelId, state: .promptIdle) - workspace.updatePanelShellActivityState(panelId: panelId, state: .commandRunning) - - let staleSnapshot = workspace.sessionSnapshot( - includeScrollback: false, - restorableAgentIndex: staleIndex - ) - XCTAssertNil(staleSnapshot.panels.first?.terminal?.agent) - - let newIndex = try makeRestorableAgentIndex( - workspaceId: workspace.id, - panelId: panelId, - sessionId: "codex-new-session", - arguments: [ - "/usr/local/bin/codex", - "--model", - "gpt-5.4-mini", - "--sandbox", - "danger-full-access", - ] - ) - let newSnapshot = workspace.sessionSnapshot( - includeScrollback: false, - restorableAgentIndex: newIndex - ) - let newAgent = try XCTUnwrap(newSnapshot.panels.first?.terminal?.agent) - XCTAssertEqual(newAgent.sessionId, "codex-new-session") - XCTAssertEqual( - newAgent.launchCommand?.arguments, - [ - "/usr/local/bin/codex", - "--model", - "gpt-5.4-mini", - "--sandbox", - "danger-full-access", - ] - ) - } - - private func makeRestorableAgentIndex( - kind: RestorableAgentKind = .codex, - workspaceId: UUID, - panelId: UUID, - sessionId: String, - arguments: [String], - launcher: String? = nil, - executablePath: String? = nil, - environment: [String: String]? = nil - ) throws -> RestorableAgentSessionIndex { - let home = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-agent-hook-store-\(UUID().uuidString)", isDirectory: true) - let storeURL = kind.hookStoreFileURL(homeDirectory: home.path) - try FileManager.default.createDirectory( - at: storeURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - defer { try? FileManager.default.removeItem(at: home) } - - let resolvedEnvironment: [String: String] - if let environment { - resolvedEnvironment = environment - } else { - switch kind { - case .claude: - resolvedEnvironment = ["CLAUDE_CONFIG_DIR": "/tmp/claude"] - case .codex: - resolvedEnvironment = ["CODEX_HOME": "/tmp/codex"] - case .opencode: - resolvedEnvironment = ["OPENCODE_CONFIG_DIR": "/tmp/opencode"] - } - } - let resolvedExecutablePath = executablePath ?? arguments.first ?? "/usr/local/bin/\(kind.rawValue)" - let resolvedLauncher = launcher ?? kind.rawValue - - let jsonObject: [String: Any] = [ - "version": 1, - "sessions": [ - sessionId: [ - "sessionId": sessionId, - "workspaceId": workspaceId.uuidString, - "surfaceId": panelId.uuidString, - "cwd": "/tmp/repo", - "updatedAt": Date().timeIntervalSince1970, - "launchCommand": [ - "launcher": resolvedLauncher, - "executablePath": resolvedExecutablePath, - "arguments": arguments, - "workingDirectory": "/tmp/repo", - "environment": resolvedEnvironment, - "capturedAt": Date().timeIntervalSince1970, - "source": "process", - ], - ], - ], - ] - let data = try JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted]) - try data.write(to: storeURL, options: .atomic) - - return RestorableAgentSessionIndex.load(homeDirectory: home.path) - } - private func makeSnapshot(version: Int) -> AppSessionSnapshot { let workspace = SessionWorkspaceSnapshot( processTitle: "Terminal", @@ -1522,606 +1087,6 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { ) ) } - - func testClaudeResumeCommandPreservesLaunchFlagsAndDropsInjectedHookSettings() { - let snapshot = SessionRestorableAgentSnapshot( - kind: .claude, - sessionId: "claude-session-123", - workingDirectory: "/tmp/cmux project", - launchCommand: AgentLaunchCommandSnapshot( - launcher: "claude", - executablePath: "/opt/Claude Code/bin/claude", - arguments: [ - "/opt/Claude Code/bin/claude", - "--model", - "sonnet", - "--permission-mode", - "auto", - "--settings", - #"{"hooks":{"SessionStart":[{"hooks":[{"command":"cmux claude-hook session-start"}]}]}}"#, - "--session-id", - "old-session", - "initial prompt should not replay" - ], - workingDirectory: "/tmp/cmux project", - environment: ["CLAUDE_CONFIG_DIR": "/tmp/claude config"], - capturedAt: 123, - source: "environment" - ) - ) - - XCTAssertEqual( - snapshot.resumeCommand, - "cd '/tmp/cmux project' && 'env' 'CLAUDE_CONFIG_DIR=/tmp/claude config' '/opt/Claude Code/bin/claude' '--resume' 'claude-session-123' '--model' 'sonnet' '--permission-mode' 'auto'" - ) - } - - func testRestorableAgentStartupInputUsesInlineCommandWhenShort() { - let snapshot = SessionRestorableAgentSnapshot( - kind: .claude, - sessionId: "claude-session-123", - workingDirectory: "/tmp/cmux project", - launchCommand: AgentLaunchCommandSnapshot( - launcher: "claude", - executablePath: "/opt/Claude Code/bin/claude", - arguments: [ - "/opt/Claude Code/bin/claude", - "--model", - "sonnet" - ], - workingDirectory: "/tmp/cmux project", - environment: nil, - capturedAt: 123, - source: "environment" - ) - ) - - XCTAssertEqual(snapshot.resumeStartupInput(), snapshot.resumeCommand.map { $0 + "\n" }) - } - - func testRestorableAgentStartupInputUsesLauncherScriptWhenCommandExceedsTerminalInputBudget() throws { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-agent-resume-test-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: tempDir) } - - let longPath = "/tmp/" + String(repeating: "nested-path-", count: 120) - let snapshot = SessionRestorableAgentSnapshot( - kind: .codex, - sessionId: "019dad34-d218-7943-b81a-eddac5c87951", - workingDirectory: "/tmp/repo", - launchCommand: AgentLaunchCommandSnapshot( - launcher: "codex", - executablePath: "/Users/example/.bun/bin/codex", - arguments: [ - "/Users/example/.bun/bin/codex", - "--model", - "gpt-5.4", - "--add-dir", - longPath, - "initial prompt should not replay" - ], - workingDirectory: "/tmp/repo", - environment: ["CODEX_HOME": "/tmp/codex"], - capturedAt: 123, - source: "environment" - ) - ) - - let input = try XCTUnwrap(snapshot.resumeStartupInput(temporaryDirectory: tempDir)) - XCTAssertLessThanOrEqual(input.utf8.count, SessionRestorableAgentSnapshot.maxInlineStartupInputBytes) - XCTAssertTrue(input.hasPrefix("/bin/zsh '")) - XCTAssertFalse(input.contains(longPath)) - - let trimmedInput = input.trimmingCharacters(in: .whitespacesAndNewlines) - let prefix = "/bin/zsh '" - let scriptPath = String(trimmedInput.dropFirst(prefix.count).dropLast()) - let scriptContents = try String(contentsOfFile: scriptPath, encoding: .utf8) - XCTAssertTrue(scriptContents.contains(longPath)) - XCTAssertTrue(scriptContents.contains("'resume'")) - XCTAssertTrue(scriptContents.contains("'019dad34-d218-7943-b81a-eddac5c87951'")) - - let attributes = try FileManager.default.attributesOfItem(atPath: scriptPath) - let permissions = try XCTUnwrap(attributes[.posixPermissions] as? NSNumber).intValue & 0o777 - XCTAssertEqual(permissions, 0o600) - } - - func testRestorableAgentStartupInputSkipsOversizedCommandWhenScriptCannotBeWritten() throws { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-agent-resume-test-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: tempDir) } - - let blockedDirectory = tempDir.appendingPathComponent("not-a-directory", isDirectory: false) - try "occupied".write(to: blockedDirectory, atomically: true, encoding: .utf8) - let longPath = "/tmp/" + String(repeating: "nested-path-", count: 120) - let snapshot = SessionRestorableAgentSnapshot( - kind: .codex, - sessionId: "019dad34-d218-7943-b81a-eddac5c87951", - workingDirectory: "/tmp/repo", - launchCommand: AgentLaunchCommandSnapshot( - launcher: "codex", - executablePath: "/Users/example/.bun/bin/codex", - arguments: [ - "/Users/example/.bun/bin/codex", - "--model", - "gpt-5.4", - "--add-dir", - longPath, - "initial prompt should not replay" - ], - workingDirectory: "/tmp/repo", - environment: ["CODEX_HOME": "/tmp/codex"], - capturedAt: 123, - source: "environment" - ) - ) - - XCTAssertNil(snapshot.resumeStartupInput(temporaryDirectory: blockedDirectory)) - } - - func testClaudeResumeCommandPreservesDangerouslySkipPermissionsAndObservedEnvironment() { - let snapshot = SessionRestorableAgentSnapshot( - kind: .claude, - sessionId: "24ec0052-450c-4914-b1dd-2ee80d4bc84b", - workingDirectory: "/Users/lawrence/fun", - launchCommand: AgentLaunchCommandSnapshot( - launcher: "claude", - executablePath: "/Users/lawrence/.local/bin/claude", - arguments: [ - "/Users/lawrence/.local/bin/claude", - "--dangerously-skip-permissions" - ], - workingDirectory: "/Users/lawrence/fun", - environment: [ - "CLAUDE_CONFIG_DIR": "/Users/lawrence/.codex-accounts/claude/_p1775010019397", - "PATH": "/Users/lawrence/.local/bin:/usr/bin", - "SHELL": "/bin/zsh" - ], - capturedAt: 123, - source: "environment" - ) - ) - - XCTAssertEqual( - snapshot.resumeCommand, - "cd '/Users/lawrence/fun' && 'env' 'CLAUDE_CONFIG_DIR=/Users/lawrence/.codex-accounts/claude/_p1775010019397' '/Users/lawrence/.local/bin/claude' '--resume' '24ec0052-450c-4914-b1dd-2ee80d4bc84b' '--dangerously-skip-permissions'" - ) - } - - func testCodexResumeCommandPreservesFlagsAndDropsOriginalPrompt() { - let snapshot = SessionRestorableAgentSnapshot( - kind: .codex, - sessionId: "019dad34-d218-7943-b81a-eddac5c87951", - workingDirectory: "/Users/example/repo", - launchCommand: AgentLaunchCommandSnapshot( - launcher: "codex", - executablePath: "/Users/example/.bun/bin/codex", - arguments: [ - "/Users/example/.bun/bin/codex", - "--model", - "gpt-5.4", - "--sandbox", - "danger-full-access", - "--ask-for-approval", - "never", - "--search", - "--cd", - "/Users/example/repo", - "initial prompt should not replay" - ], - workingDirectory: "/Users/example/repo", - environment: ["CODEX_HOME": "/tmp/codex home"], - capturedAt: 123, - source: "process" - ) - ) - - XCTAssertEqual( - snapshot.resumeCommand, - "cd '/Users/example/repo' && 'env' 'CODEX_HOME=/tmp/codex home' '/Users/example/.bun/bin/codex' 'resume' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access' '--ask-for-approval' 'never' '--search' '--cd' '/Users/example/repo' '019dad34-d218-7943-b81a-eddac5c87951'" - ) - } - - func testClaudeTeamsResumeCommandPreservesRemoteControlLauncher() { - let snapshot = SessionRestorableAgentSnapshot( - kind: .claude, - sessionId: "claude-team-session", - workingDirectory: "/tmp/team repo", - launchCommand: AgentLaunchCommandSnapshot( - launcher: "claudeTeams", - executablePath: "/Applications/cmux.app/Contents/Resources/bin/cmux", - arguments: [ - "/Applications/cmux.app/Contents/Resources/bin/cmux", - "claude-teams", - "--teammate-mode", - "auto", - "--model", - "sonnet", - "--remote-control-session-name-prefix", - "cmux-team", - "--tmux", - "side effect should be dropped", - "--permission-mode", - "auto", - "initial team prompt" - ], - workingDirectory: "/tmp/team repo", - environment: [ - "CMUX_CUSTOM_CLAUDE_PATH": "/opt/Claude Code/bin/claude", - "PATH": "/opt/Claude Code/bin:/usr/bin" - ], - capturedAt: 123, - source: "environment" - ) - ) - - XCTAssertEqual( - snapshot.resumeCommand, - "cd '/tmp/team repo' && 'env' 'CMUX_CUSTOM_CLAUDE_PATH=/opt/Claude Code/bin/claude' '/Applications/cmux.app/Contents/Resources/bin/cmux' 'claude-teams' '--resume' 'claude-team-session' '--teammate-mode' 'auto' '--model' 'sonnet' '--remote-control-session-name-prefix' 'cmux-team' '--permission-mode' 'auto'" - ) - } - - func testClaudeResumeCommandHandlesOptionalDebugValueAndFilteredEnvironment() { - let snapshot = SessionRestorableAgentSnapshot( - kind: .claude, - sessionId: "claude-session-debug", - workingDirectory: nil, - launchCommand: AgentLaunchCommandSnapshot( - launcher: "claude", - executablePath: "claude", - arguments: [ - "claude", - "--debug", - "api,mcp", - "--model", - "sonnet", - "prompt should not replay" - ], - workingDirectory: nil, - environment: [ - "UNSAFE_TOKEN": "secret", - "NODE_OPTIONS": "--max-old-space-size=4096" - ], - capturedAt: nil, - source: nil - ) - ) - - XCTAssertEqual( - snapshot.resumeCommand, - "'env' 'NODE_OPTIONS=--max-old-space-size=4096' 'claude' '--resume' 'claude-session-debug' '--debug' 'api,mcp' '--model' 'sonnet'" - ) - } - - func testResumeCommandPreservesSafeProviderEnvironmentValuesOnly() { - let snapshot = SessionRestorableAgentSnapshot( - kind: .claude, - sessionId: "claude-session-env", - workingDirectory: nil, - launchCommand: AgentLaunchCommandSnapshot( - launcher: "claude", - executablePath: "claude", - arguments: ["claude"], - workingDirectory: nil, - environment: [ - "ANTHROPIC_MODEL": "", - "PATH": " /tmp/bin ", - "UNSAFE_TOKEN": "secret" - ], - capturedAt: nil, - source: nil - ) - ) - - XCTAssertEqual( - snapshot.resumeCommand, - "'env' 'ANTHROPIC_MODEL=' 'claude' '--resume' 'claude-session-env'" - ) - } - - func testClaudeResumeCommandStripsStaleCmuxNodeOptionsRestoreModule() { - let snapshot = SessionRestorableAgentSnapshot( - kind: .claude, - sessionId: "claude-session-node-options", - workingDirectory: nil, - launchCommand: AgentLaunchCommandSnapshot( - launcher: "claude", - executablePath: "claude", - arguments: ["claude", "--model", "sonnet"], - workingDirectory: nil, - environment: [ - "NODE_OPTIONS": "--require=/tmp/cmux-claude-node-options/restore-node-options.cjs --max-old-space-size=4096 --trace-warnings" - ], - capturedAt: nil, - source: nil - ) - ) - - XCTAssertEqual( - snapshot.resumeCommand, - "'env' 'NODE_OPTIONS=--trace-warnings' 'claude' '--resume' 'claude-session-node-options' '--model' 'sonnet'" - ) - } - - func testClaudeResumeCommandDropsEmptyStaleCmuxNodeOptionsEnvironment() { - let snapshot = SessionRestorableAgentSnapshot( - kind: .claude, - sessionId: "claude-session-empty-node-options", - workingDirectory: nil, - launchCommand: AgentLaunchCommandSnapshot( - launcher: "claude", - executablePath: "claude", - arguments: ["claude", "--model", "sonnet"], - workingDirectory: nil, - environment: [ - "NODE_OPTIONS": "--require /tmp/cmux-claude-node-options/restore-node-options.cjs --max-old-space-size 4096" - ], - capturedAt: nil, - source: nil - ) - ) - - XCTAssertEqual( - snapshot.resumeCommand, - "'claude' '--resume' 'claude-session-empty-node-options' '--model' 'sonnet'" - ) - } - - func testHookStoreDirectoryCanBeOverriddenForTests() { - let url = RestorableAgentKind.codex.hookStoreFileURL( - homeDirectory: "/Users/example", - environment: ["CMUX_AGENT_HOOK_STATE_DIR": "/tmp/cmux hook state"] - ) - - XCTAssertEqual(url.path, "/tmp/cmux hook state/codex-hook-sessions.json") - } - - func testOpenCodeWrapperResumeCommandAndUnsupportedOhMyLaunchers() { - let direct = SessionRestorableAgentSnapshot( - kind: .opencode, - sessionId: "direct-opencode-session-456", - workingDirectory: "/tmp/direct opencode repo", - launchCommand: AgentLaunchCommandSnapshot( - launcher: "opencode", - executablePath: "/opt/homebrew/bin/opencode", - arguments: [ - "/opt/homebrew/bin/opencode", - "--model", - "anthropic/claude-sonnet-4-6", - "--session", - "old-session", - "--prompt", - "old prompt", - "--port", - "4096", - "/tmp/direct opencode repo", - "initial prompt" - ], - workingDirectory: "/tmp/direct opencode repo", - environment: ["OPENCODE_CONFIG_DIR": "/tmp/opencode config"], - capturedAt: 123, - source: "environment" - ) - ) - let omo = SessionRestorableAgentSnapshot( - kind: .opencode, - sessionId: "opencode-session-123", - workingDirectory: "/tmp/opencode repo", - launchCommand: AgentLaunchCommandSnapshot( - launcher: "omo", - executablePath: "/usr/local/bin/cmux", - arguments: [ - "/usr/local/bin/cmux", - "omo", - "--model", - "anthropic/claude-sonnet-4-6", - "/tmp/opencode repo", - "initial prompt" - ], - workingDirectory: "/tmp/opencode repo", - environment: ["OPENCODE_CONFIG_DIR": "/tmp/opencode config"], - capturedAt: 123, - source: "environment" - ) - ) - let staleBunWorker = SessionRestorableAgentSnapshot( - kind: .opencode, - sessionId: "ses_24b0be92affeVRRBplLmUzbXQl", - workingDirectory: "/Users/lawrence/fun", - launchCommand: AgentLaunchCommandSnapshot( - launcher: "opencode", - executablePath: "/Users/lawrence/.bun/bin/opencode", - arguments: [ - "/Users/lawrence/.bun/bin/opencode", - "/$bunfs/root/src/cli/cmd/tui/worker.js" - ], - workingDirectory: "/Users/lawrence/fun", - environment: [ - "PATH": "/Users/lawrence/.bun/bin:/usr/bin", - "SHELL": "/bin/zsh" - ], - capturedAt: 123, - source: "environment" - ) - ) - let omx = SessionRestorableAgentSnapshot( - kind: .codex, - sessionId: "codex-session-123", - workingDirectory: nil, - launchCommand: AgentLaunchCommandSnapshot( - launcher: "omx", - executablePath: "/usr/local/bin/cmux", - arguments: ["/usr/local/bin/cmux", "omx", "team"], - workingDirectory: nil, - environment: nil, - capturedAt: nil, - source: nil - ) - ) - let omc = SessionRestorableAgentSnapshot( - kind: .claude, - sessionId: "claude-session-123", - workingDirectory: nil, - launchCommand: AgentLaunchCommandSnapshot( - launcher: "omc", - executablePath: "/usr/local/bin/cmux", - arguments: ["/usr/local/bin/cmux", "omc", "team"], - workingDirectory: nil, - environment: nil, - capturedAt: nil, - source: nil - ) - ) - - XCTAssertEqual( - direct.resumeCommand, - "cd '/tmp/direct opencode repo' && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/opt/homebrew/bin/opencode' '--session' 'direct-opencode-session-456' '--model' 'anthropic/claude-sonnet-4-6' '--port' '4096' '/tmp/direct opencode repo'" - ) - XCTAssertEqual( - omo.resumeCommand, - "cd '/tmp/opencode repo' && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/usr/local/bin/cmux' 'omo' '--session' 'opencode-session-123' '--model' 'anthropic/claude-sonnet-4-6' '/tmp/opencode repo'" - ) - XCTAssertEqual( - staleBunWorker.resumeCommand, - "cd '/Users/lawrence/fun' && '/Users/lawrence/.bun/bin/opencode' '--session' 'ses_24b0be92affeVRRBplLmUzbXQl'" - ) - XCTAssertNil(omx.resumeCommand) - XCTAssertNil(omc.resumeCommand) - } - - func testNonInteractiveAgentLaunchesAreNotAutoRestored() { - let claudePrint = SessionRestorableAgentSnapshot( - kind: .claude, - sessionId: "claude-session-123", - workingDirectory: nil, - launchCommand: AgentLaunchCommandSnapshot( - launcher: "claude", - executablePath: "claude", - arguments: ["claude", "--print", "summarize this"], - workingDirectory: nil, - environment: nil, - capturedAt: nil, - source: nil - ) - ) - let claudePrintEquals = SessionRestorableAgentSnapshot( - kind: .claude, - sessionId: "claude-session-456", - workingDirectory: nil, - launchCommand: AgentLaunchCommandSnapshot( - launcher: "claude", - executablePath: "claude", - arguments: ["claude", "--print=summarize this"], - workingDirectory: nil, - environment: nil, - capturedAt: nil, - source: nil - ) - ) - let codexExec = SessionRestorableAgentSnapshot( - kind: .codex, - sessionId: "codex-session-123", - workingDirectory: nil, - launchCommand: AgentLaunchCommandSnapshot( - launcher: "codex", - executablePath: "codex", - arguments: ["codex", "exec", "fix this"], - workingDirectory: nil, - environment: nil, - capturedAt: nil, - source: nil - ) - ) - let opencodeRun = SessionRestorableAgentSnapshot( - kind: .opencode, - sessionId: "opencode-session-123", - workingDirectory: nil, - launchCommand: AgentLaunchCommandSnapshot( - launcher: "opencode", - executablePath: "opencode", - arguments: ["opencode", "run", "fix this"], - workingDirectory: nil, - environment: nil, - capturedAt: nil, - source: nil - ) - ) - let opencodePR = SessionRestorableAgentSnapshot( - kind: .opencode, - sessionId: "opencode-pr-session-123", - workingDirectory: nil, - launchCommand: AgentLaunchCommandSnapshot( - launcher: "opencode", - executablePath: "opencode", - arguments: ["opencode", "pr", "123"], - workingDirectory: nil, - environment: nil, - capturedAt: nil, - source: nil - ) - ) - - XCTAssertNil(claudePrint.resumeCommand) - XCTAssertNil(claudePrintEquals.resumeCommand) - XCTAssertNil(codexExec.resumeCommand) - XCTAssertNil(opencodeRun.resumeCommand) - XCTAssertNil(opencodePR.resumeCommand) - } - - func testRestorableAgentIndexLoadsLaunchCommandFromHookStore() throws { - let home = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-agent-hook-store-\(UUID().uuidString)", isDirectory: true) - let storeDir = home.appendingPathComponent(".cmuxterm", isDirectory: true) - try FileManager.default.createDirectory(at: storeDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: home) } - - let workspaceId = UUID() - let panelId = UUID() - let storeURL = storeDir.appendingPathComponent("codex-hook-sessions.json", isDirectory: false) - let json = """ - { - "version": 1, - "sessions": { - "codex-session-123": { - "sessionId": "codex-session-123", - "workspaceId": "\(workspaceId.uuidString)", - "surfaceId": "\(panelId.uuidString)", - "cwd": "/tmp/repo", - "updatedAt": 123, - "launchCommand": { - "launcher": "codex", - "executablePath": "/usr/local/bin/codex", - "arguments": [ - "/usr/local/bin/codex", - "--model", - "gpt-5.4", - "--search", - "old prompt" - ], - "workingDirectory": "/tmp/repo", - "environment": { - "CODEX_HOME": "/tmp/codex" - }, - "capturedAt": 122, - "source": "process" - } - } - } - } - """ - try json.write(to: storeURL, atomically: true, encoding: .utf8) - - let index = RestorableAgentSessionIndex.load(homeDirectory: home.path) - let snapshot = try XCTUnwrap(index.snapshot(workspaceId: workspaceId, panelId: panelId)) - - XCTAssertEqual(snapshot.launchCommand?.arguments.first, "/usr/local/bin/codex") - XCTAssertEqual( - snapshot.resumeCommand, - "cd '/tmp/repo' && 'env' 'CODEX_HOME=/tmp/codex' '/usr/local/bin/codex' 'resume' '--model' 'gpt-5.4' '--search' 'codex-session-123'" - ) - } - } final class SidebarDragFailsafePolicyTests: XCTestCase { diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index 6367149fc79c..4dd29d2a9ea0 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -4,7 +4,6 @@ import SwiftUI import UniformTypeIdentifiers import WebKit import ObjectiveC.runtime -import Bonsplit import UserNotifications #if canImport(cmux_DEV) @@ -178,8 +177,7 @@ final class ReactGrabPastebackTargetTests: XCTestCase { XCTFail("Expected initial terminal panel") return } - guard let browserPanel = workspace.newBrowserSplit( - from: terminalId, + guard let browserPanel = workspace.splitBrowserPanel(fromPanelId: terminalId, orientation: .horizontal ) else { XCTFail("Expected browser split panel") @@ -201,8 +199,7 @@ final class ReactGrabPastebackTargetTests: XCTestCase { func testDoesNotFallbackWhenPreferredTerminalTargetIsMissing() { let workspace = Workspace(title: "Tests") guard let terminalId = workspace.focusedPanelId, - let browserPanel = workspace.newBrowserSplit( - from: terminalId, + let browserPanel = workspace.splitBrowserPanel(fromPanelId: terminalId, orientation: .horizontal ) else { XCTFail("Expected initial workspace split") @@ -223,8 +220,7 @@ final class ReactGrabPastebackTargetTests: XCTestCase { let manager = TabManager() guard let workspace = manager.selectedWorkspace, let terminalId = workspace.focusedPanelId, - let browserPanel = workspace.newBrowserSplit( - from: terminalId, + let browserPanel = workspace.splitBrowserPanel(fromPanelId: terminalId, orientation: .horizontal ) else { XCTFail("Expected initial workspace with terminal and browser split") @@ -242,8 +238,7 @@ final class ReactGrabPastebackTargetTests: XCTestCase { let manager = TabManager() guard let workspace = manager.selectedWorkspace, let terminalId = workspace.focusedPanelId, - let browserPanel = workspace.newBrowserSplit( - from: terminalId, + let browserPanel = workspace.splitBrowserPanel(fromPanelId: terminalId, orientation: .horizontal ) else { XCTFail("Expected initial workspace with terminal and browser split") @@ -252,10 +247,10 @@ final class ReactGrabPastebackTargetTests: XCTestCase { workspace.focusPanel(terminalId) XCTAssertTrue(workspace.toggleSplitZoom(panelId: terminalId)) - XCTAssertTrue(workspace.bonsplitController.isSplitZoomed) + XCTAssertTrue(workspace.isSplitZoomed) XCTAssertTrue(manager.toggleReactGrabFromCurrentFocus()) - XCTAssertFalse(workspace.bonsplitController.isSplitZoomed) + XCTAssertFalse(workspace.isSplitZoomed) XCTAssertEqual(workspace.focusedPanelId, browserPanel.id) XCTAssertEqual(browserPanel.pendingReactGrabReturnTargetPanelId, terminalId) } @@ -450,25 +445,6 @@ final class CommandPaletteKeyboardNavigationTests: XCTestCase { ) } - func testNavigationIgnoresCapsLockModifier() { - XCTAssertEqual( - commandPaletteSelectionDeltaForKeyboardNavigation( - flags: [.capsLock], - chars: "", - keyCode: 125 - ), - 1 - ) - XCTAssertEqual( - commandPaletteSelectionDeltaForKeyboardNavigation( - flags: [.control, .capsLock], - chars: "p", - keyCode: 35 - ), - -1 - ) - } - func testDoesNotTreatControlJKAsPaletteNavigation() { XCTAssertNil( commandPaletteSelectionDeltaForKeyboardNavigation( @@ -658,19 +634,6 @@ final class CommandPaletteOpenShortcutConsumptionTests: XCTestCase { final class CommandPaletteFocusStealerClassificationTests: XCTestCase { private final class NonViewTextDelegate: NSObject, NSTextViewDelegate {} private final class UnrelatedViewTextDelegate: NSView, NSTextViewDelegate {} - private final class DelegateTrackingTextView: NSTextView { - private(set) var delegateReadCount = 0 - - override var delegate: NSTextViewDelegate? { - get { - delegateReadCount += 1 - return super.delegate - } - set { - super.delegate = newValue - } - } - } func testTreatsGhosttySurfaceViewAsFocusStealer() { let surfaceView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) @@ -697,17 +660,6 @@ final class CommandPaletteFocusStealerClassificationTests: XCTestCase { XCTAssertFalse(isCommandPaletteFocusStealingTerminalOrBrowserResponder(textField)) } - func testDoesNotReadTextViewDelegateForFocusStealerClassification() { - let textView = DelegateTrackingTextView(frame: NSRect(x: 0, y: 0, width: 120, height: 24)) - - XCTAssertFalse(isCommandPaletteFocusStealingTerminalOrBrowserResponder(textView)) - XCTAssertEqual( - textView.delegateReadCount, - 0, - "Command palette focus-stealer classification must avoid NSTextView.delegate because AppKit exposes it as unsafe-unretained" - ) - } - func testTreatsTextViewInsideTerminalHostedViewAsFocusStealerWhenDelegateIsNotAView() { let hostedView = GhosttySurfaceScrollView( surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) @@ -839,13 +791,12 @@ final class CommandPaletteSelectionScrollBehaviorTests: XCTestCase { final class ShortcutHintModifierPolicyTests: XCTestCase { - func testShortcutHintRequiresEnabledCommandOrControlOnlyModifier() { + func testShortcutHintRequiresEnabledCommandOnlyModifier() { withDefaultsSuite { defaults in defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnControlHoldKey) XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults)) - XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [], defaults: defaults)) XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command, .shift], defaults: defaults)) XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control, .shift], defaults: defaults)) @@ -855,86 +806,25 @@ final class ShortcutHintModifierPolicyTests: XCTestCase { } } - func testShortcutHintShowsForControlModifier() { - withDefaultsSuite { defaults in - defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnControlHoldKey) - - XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) - } - } - - func testControlOnlyShortcutHintRequiresControlModifier() { - withDefaultsSuite { defaults in - defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnControlHoldKey) - - XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowControlHints(for: [.control], defaults: defaults)) - XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowControlHints(for: [.command], defaults: defaults)) - XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowControlHints(for: [.control, .shift], defaults: defaults)) - XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowControlHints(for: [.control, .option], defaults: defaults)) - XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowControlHints(for: [], defaults: defaults)) - } - } - - func testControlOnlyShortcutHintRespectsControlVisibilitySetting() { - withDefaultsSuite { defaults in - defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnControlHoldKey) - - XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowControlHints(for: [.control], defaults: defaults)) - } - } - - func testCommandOnlyShortcutHintRequiresCommandModifier() { - withDefaultsSuite { defaults in - defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - - XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowCommandHints(for: [.command], defaults: defaults)) - XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowCommandHints(for: [.control], defaults: defaults)) - XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowCommandHints(for: [.command, .shift], defaults: defaults)) - XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowCommandHints(for: [.command, .option], defaults: defaults)) - XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowCommandHints(for: [], defaults: defaults)) - } - } - - func testCommandOnlyShortcutHintRespectsCommandVisibilitySetting() { - withDefaultsSuite { defaults in - defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - - XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowCommandHints(for: [.command], defaults: defaults)) - } - } - - func testCommandHintCanBeDisabledIndependently() { + func testCommandHintCanBeDisabledInSettings() { withDefaultsSuite { defaults in defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnControlHoldKey) XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults)) - XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) - } - } - - func testControlHintCanBeDisabledIndependently() { - withDefaultsSuite { defaults in - defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnControlHoldKey) - - XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults)) XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) } } - func testCommandAndControlHintsDefaultToEnabledWhenSettingsAreMissing() { + func testCommandHintDefaultsToEnabledWhenSettingMissing() { withDefaultsSuite { defaults in defaults.removeObject(forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - defaults.removeObject(forKey: ShortcutHintDebugSettings.showHintsOnControlHoldKey) XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults)) - XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) } } - func testShortcutHintIgnoresCustomizedWorkspaceShortcutModifiers() { + func testShortcutHintStaysCommandOnlyWhenWorkspaceShortcutIsCustomized() { let action = KeyboardShortcutSettings.Action.selectWorkspaceByNumber let originalShortcut = KeyboardShortcutSettings.shortcut(for: action) defer { @@ -948,14 +838,13 @@ final class ShortcutHintModifierPolicyTests: XCTestCase { withDefaultsSuite { defaults in defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnControlHoldKey) XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults)) - XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) } } - func testShortcutHintIgnoresWorkspaceShortcutChords() { + func testShortcutHintStaysCommandOnlyWhenWorkspaceShortcutUsesChord() { let action = KeyboardShortcutSettings.Action.selectWorkspaceByNumber let originalShortcut = KeyboardShortcutSettings.shortcut(for: action) defer { @@ -980,10 +869,9 @@ final class ShortcutHintModifierPolicyTests: XCTestCase { withDefaultsSuite { defaults in defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnControlHoldKey) XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults)) - XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) } } @@ -1023,7 +911,6 @@ final class ShortcutHintModifierPolicyTests: XCTestCase { func testWindowScopedShortcutHintsUseKeyWindowWhenNoEventWindowIsAvailable() { withDefaultsSuite { defaults in defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnControlHoldKey) XCTAssertTrue( ShortcutHintModifierPolicy.shouldShowHints( @@ -1048,6 +935,17 @@ final class ShortcutHintModifierPolicyTests: XCTestCase { ) XCTAssertTrue( + ShortcutHintModifierPolicy.shouldShowHints( + for: [.command], + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: nil, + keyWindowNumber: 42, + defaults: defaults + ) + ) + + XCTAssertFalse( ShortcutHintModifierPolicy.shouldShowHints( for: [.control], hostWindowNumber: 42, @@ -1074,224 +972,135 @@ final class ShortcutHintModifierPolicyTests: XCTestCase { } -final class RightSidebarModeShortcutHintTests: XCTestCase { - private let touchedShortcutActions: [KeyboardShortcutSettings.Action] = [ - .focusRightSidebar, - .switchRightSidebarToFiles, - .switchRightSidebarToFind, - .switchRightSidebarToSessions, - .switchRightSidebarToFeed, - ] - private var originalSettingsFileStore: KeyboardShortcutSettingsFileStore! - private var savedShortcutData: [KeyboardShortcutSettings.Action: Data?] = [:] - private var temporaryDirectoryURL: URL? - - override func setUpWithError() throws { - try super.setUpWithError() - originalSettingsFileStore = KeyboardShortcutSettings.settingsFileStore - savedShortcutData = Dictionary( - uniqueKeysWithValues: touchedShortcutActions.map { action in - (action, UserDefaults.standard.data(forKey: action.defaultsKey)) - } - ) - - let directoryURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) - temporaryDirectoryURL = directoryURL - KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore( - primaryPath: directoryURL.appendingPathComponent("settings.json", isDirectory: false).path, - fallbackPath: nil, - startWatching: false - ) - for action in touchedShortcutActions { - UserDefaults.standard.removeObject(forKey: action.defaultsKey) - } - KeyboardShortcutSettings.notifySettingsFileDidChange() - } - - override func tearDownWithError() throws { - for action in touchedShortcutActions { - if case let .some(.some(data)) = savedShortcutData[action] { - UserDefaults.standard.set(data, forKey: action.defaultsKey) - } else { - UserDefaults.standard.removeObject(forKey: action.defaultsKey) - } - } - KeyboardShortcutSettings.settingsFileStore = originalSettingsFileStore - KeyboardShortcutSettings.notifySettingsFileDidChange() - if let temporaryDirectoryURL { - try? FileManager.default.removeItem(at: temporaryDirectoryURL) +final class WorkspaceLayoutPaneTabShortcutHintPolicyTests: XCTestCase { + func testPaneTabShortcutHintDefaultsToConfiguredSurfaceShortcut() { + let action = KeyboardShortcutSettings.Action.selectSurfaceByNumber + let originalShortcut = KeyboardShortcutSettings.shortcut(for: action) + defer { + KeyboardShortcutSettings.setShortcut(originalShortcut, for: action) } - try super.tearDownWithError() - } - - func testModeShortcutActionsMatchModeSwitchingActions() { - XCTAssertEqual(RightSidebarMode.files.shortcutAction, .switchRightSidebarToFiles) - XCTAssertEqual(RightSidebarMode.find.shortcutAction, .switchRightSidebarToFind) - XCTAssertEqual(RightSidebarMode.sessions.shortcutAction, .switchRightSidebarToSessions) - XCTAssertEqual(RightSidebarMode.feed.shortcutAction, .switchRightSidebarToFeed) - } - func testModeShortcutUsesConfiguredBindings() { - let customFilesShortcut = StoredShortcut( - key: "4", - command: false, - shift: false, - option: false, - control: true - ) - KeyboardShortcutSettings.setShortcut(customFilesShortcut, for: .switchRightSidebarToFiles) - - XCTAssertEqual( - RightSidebarMode.modeShortcut(for: makeKeyDownEvent(key: "4", modifiers: [.control], keyCode: 21)), - .files - ) - XCTAssertNil( - RightSidebarMode.modeShortcut(for: makeKeyDownEvent(key: "1", modifiers: [.control], keyCode: 18)) + KeyboardShortcutSettings.setShortcut( + StoredShortcut(key: "1", command: false, shift: false, option: false, control: true), + for: action ) - } - func testFocusRightSidebarShortcutCanBeOverwrittenForHintRendering() { - let customShortcut = StoredShortcut( - key: "e", - command: true, - shift: true, - option: true, - control: false - ) - KeyboardShortcutSettings.setShortcut(customShortcut, for: .focusRightSidebar) + withDefaultsSuite { defaults in + defaults.removeObject(forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - let resolvedShortcut = KeyboardShortcutSettings.shortcut(for: .focusRightSidebar) - XCTAssertEqual(resolvedShortcut, customShortcut) - XCTAssertEqual( - KeyboardShortcutSettings.Action.focusRightSidebar.displayedShortcutString(for: resolvedShortcut), - customShortcut.displayString - ) - } - - private func makeKeyDownEvent( - key: String, - modifiers: NSEvent.ModifierFlags, - keyCode: UInt16 - ) -> NSEvent { - guard let event = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: modifiers, - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: 0, - context: nil, - characters: key, - charactersIgnoringModifiers: key, - isARepeat: false, - keyCode: keyCode - ) else { - fatalError("Failed to construct key event") + XCTAssertEqual( + WorkspaceLayoutPaneTabShortcutHintPolicy.hintModifier( + for: [.control], + defaults: defaults + )?.symbol, + "⌃" + ) + XCTAssertEqual( + WorkspaceLayoutPaneTabShortcutHintPolicy.hintModifier( + for: [.command], + defaults: defaults + )?.symbol, + "⌃" + ) } - return event } -} -final class MainWindowFocusControllerRightSidebarHideTests: XCTestCase { - private final class TestRightSidebarResponder: NSView, FeedKeyboardFocusResponder { - override var acceptsFirstResponder: Bool { true } - } + func testPaneTabShortcutHintTracksCustomizedModifiers() { + let action = KeyboardShortcutSettings.Action.selectSurfaceByNumber + let originalShortcut = KeyboardShortcutSettings.shortcut(for: action) + defer { + KeyboardShortcutSettings.setShortcut(originalShortcut, for: action) + } - @MainActor - func testHiddenRightSidebarClearsFocusIntentWhenNoTerminalCanRestore() { - let controller = MainWindowFocusController( - windowId: UUID(), - window: nil, - tabManager: TabManager(), - fileExplorerState: FileExplorerState() + KeyboardShortcutSettings.setShortcut( + StoredShortcut(key: "1", command: true, shift: false, option: false, control: false), + for: action ) - let workspaceId = UUID() - let panelId = UUID() - XCTAssertTrue(controller.allowsTerminalFocus(workspaceId: workspaceId, panelId: panelId)) - controller.noteRightSidebarInteraction(mode: .feed) - XCTAssertFalse(controller.allowsTerminalFocus(workspaceId: workspaceId, panelId: panelId)) + withDefaultsSuite { defaults in + defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - XCTAssertFalse(controller.restoreTerminalFocusAfterRightSidebarHiddenIfNeeded()) - XCTAssertTrue(controller.allowsTerminalFocus(workspaceId: workspaceId, panelId: panelId)) + XCTAssertEqual( + WorkspaceLayoutPaneTabShortcutHintPolicy.hintModifier( + for: [.command], + defaults: defaults + )?.symbol, + "⌘" + ) + XCTAssertNil( + WorkspaceLayoutPaneTabShortcutHintPolicy.hintModifier( + for: [.control], + defaults: defaults + ) + ) + } } - @MainActor - func testHiddenRightSidebarDoesNotRestoreWhenTerminalAlreadyOwnsFocus() { - let controller = MainWindowFocusController( - windowId: UUID(), - window: nil, - tabManager: TabManager(), - fileExplorerState: FileExplorerState() - ) - let workspaceId = UUID() - let panelId = UUID() - - controller.noteTerminalInteraction(workspaceId: workspaceId, panelId: panelId) + func testPaneTabShortcutHintCanBeDisabled() { + withDefaultsSuite { defaults in + defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - XCTAssertFalse(controller.shouldRestoreTerminalFocusWhenRightSidebarHides(currentResponder: nil)) - XCTAssertTrue(controller.allowsTerminalFocus(workspaceId: workspaceId, panelId: panelId)) + XCTAssertNil( + WorkspaceLayoutPaneTabShortcutHintPolicy.hintModifier( + for: [.control], + defaults: defaults + ) + ) + } } - @MainActor - func testMainPanelInteractionKeepsFeedSelectionInactive() { - let controller = MainWindowFocusController( - windowId: UUID(), - window: nil, - tabManager: TabManager(), - fileExplorerState: FileExplorerState() - ) - let itemId = UUID() - let workspaceId = UUID() - let panelId = UUID() - - XCTAssertTrue(controller.selectFeedItem(itemId, focusFeed: false)) - XCTAssertEqual(controller.feedFocusSnapshot().selectedItemId, itemId) - XCTAssertTrue(controller.feedFocusSnapshot().isKeyboardActive) - - controller.noteMainPanelInteraction(workspaceId: workspaceId, panelId: panelId) - - XCTAssertEqual(controller.feedFocusSnapshot().selectedItemId, itemId) - XCTAssertFalse(controller.feedFocusSnapshot().isKeyboardActive) - XCTAssertTrue(controller.allowsTerminalFocus(workspaceId: workspaceId, panelId: panelId)) - XCTAssertEqual(controller.focusToggleDestination(), .rightSidebar) - } + func testPaneTabShortcutHintIgnoresChordedShortcut() { + let action = KeyboardShortcutSettings.Action.selectSurfaceByNumber + let originalShortcut = KeyboardShortcutSettings.shortcut(for: action) + defer { + KeyboardShortcutSettings.setShortcut(originalShortcut, for: action) + } - @MainActor - func testFocusShortcutToggleUsesActualRightSidebarResponderOverStaleIntent() { - let controller = MainWindowFocusController( - windowId: UUID(), - window: nil, - tabManager: TabManager(), - fileExplorerState: FileExplorerState() + KeyboardShortcutSettings.setShortcut( + StoredShortcut( + key: "1", + command: false, + shift: false, + option: false, + control: true, + chordKey: "2", + chordCommand: false, + chordShift: false, + chordOption: false, + chordControl: true + ), + for: action ) - let responder = TestRightSidebarResponder(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) - let workspaceId = UUID() - let panelId = UUID() - controller.noteTerminalInteraction(workspaceId: workspaceId, panelId: panelId) + withDefaultsSuite { defaults in + defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - XCTAssertEqual(controller.focusToggleDestination(currentResponder: responder), .terminal) - XCTAssertTrue(controller.allowsTerminalFocus(workspaceId: workspaceId, panelId: panelId)) + XCTAssertNil( + WorkspaceLayoutPaneTabShortcutHintPolicy.hintModifier( + for: [.control], + defaults: defaults + ) + ) + } } - @MainActor - func testFocusShortcutToggleClearsRightSidebarIntentWhenTerminalIsUnavailable() { - let controller = MainWindowFocusController( - windowId: UUID(), - window: nil, - tabManager: TabManager(), - fileExplorerState: FileExplorerState() - ) - let workspaceId = UUID() - let panelId = UUID() + func testPaneTabShortcutDigitMapsLastDigitToLastTab() { + XCTAssertEqual(workspaceLayoutTabControlShortcutDigit(for: 0, tabCount: 12), 1) + XCTAssertEqual(workspaceLayoutTabControlShortcutDigit(for: 7, tabCount: 12), 8) + XCTAssertNil(workspaceLayoutTabControlShortcutDigit(for: 8, tabCount: 12)) + XCTAssertEqual(workspaceLayoutTabControlShortcutDigit(for: 11, tabCount: 12), 9) + } - controller.noteRightSidebarInteraction(mode: .feed) - XCTAssertFalse(controller.allowsTerminalFocus(workspaceId: workspaceId, panelId: panelId)) + private func withDefaultsSuite(_ body: (UserDefaults) -> Void) { + let suiteName = "WorkspaceLayoutPaneTabShortcutHintPolicyTests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create defaults suite") + return + } - XCTAssertFalse(controller.toggleRightSidebarOrTerminalFocus()) - XCTAssertTrue(controller.allowsTerminalFocus(workspaceId: workspaceId, panelId: panelId)) + defaults.removePersistentDomain(forName: suiteName) + body(defaults) + defaults.removePersistentDomain(forName: suiteName) } } @@ -1313,7 +1122,6 @@ final class ShortcutHintDebugSettingsTests: XCTestCase { XCTAssertEqual(ShortcutHintDebugSettings.defaultPaneHintY, 0.0) XCTAssertFalse(ShortcutHintDebugSettings.defaultAlwaysShowHints) XCTAssertTrue(ShortcutHintDebugSettings.defaultShowHintsOnCommandHold) - XCTAssertTrue(ShortcutHintDebugSettings.defaultShowHintsOnControlHold) } func testShowHintsOnCommandHoldSettingRespectsStoredValue() { @@ -1336,27 +1144,7 @@ final class ShortcutHintDebugSettingsTests: XCTestCase { XCTAssertTrue(ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults)) } - func testShowHintsOnControlHoldSettingRespectsStoredValue() { - let suiteName = "ShortcutHintDebugSettingsTests-\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create defaults suite") - return - } - - defaults.removePersistentDomain(forName: suiteName) - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.removeObject(forKey: ShortcutHintDebugSettings.showHintsOnControlHoldKey) - XCTAssertTrue(ShortcutHintDebugSettings.showHintsOnControlHoldEnabled(defaults: defaults)) - - defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnControlHoldKey) - XCTAssertFalse(ShortcutHintDebugSettings.showHintsOnControlHoldEnabled(defaults: defaults)) - - defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnControlHoldKey) - XCTAssertTrue(ShortcutHintDebugSettings.showHintsOnControlHoldEnabled(defaults: defaults)) - } - - func testResetVisibilityDefaultsRestoresAlwaysShowAndHoldFlags() { + func testResetVisibilityDefaultsRestoresAlwaysShowAndCommandHoldFlags() { let suiteName = "ShortcutHintDebugSettingsTests-\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { XCTFail("Failed to create defaults suite") @@ -1368,7 +1156,6 @@ final class ShortcutHintDebugSettingsTests: XCTestCase { defaults.set(true, forKey: ShortcutHintDebugSettings.alwaysShowHintsKey) defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnControlHoldKey) ShortcutHintDebugSettings.resetVisibilityDefaults(defaults: defaults) @@ -1380,10 +1167,6 @@ final class ShortcutHintDebugSettingsTests: XCTestCase { defaults.object(forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) as? Bool, ShortcutHintDebugSettings.defaultShowHintsOnCommandHold ) - XCTAssertEqual( - defaults.object(forKey: ShortcutHintDebugSettings.showHintsOnControlHoldKey) as? Bool, - ShortcutHintDebugSettings.defaultShowHintsOnControlHold - ) } } diff --git a/cmuxTests/SidebarOrderingTests.swift b/cmuxTests/SidebarOrderingTests.swift index da58fd0761da..4ff0e8006bc8 100644 --- a/cmuxTests/SidebarOrderingTests.swift +++ b/cmuxTests/SidebarOrderingTests.swift @@ -4,7 +4,6 @@ import SwiftUI import UniformTypeIdentifiers import WebKit import ObjectiveC.runtime -import Bonsplit import UserNotifications #if canImport(cmux_DEV) @@ -493,7 +492,7 @@ final class SidebarBranchOrderingTests: XCTestCase { status: .open ) - XCTAssertEqual(workspace.panelPullRequests[panelId]?.isStale, false) + XCTAssertEqual(workspace.surfaceStateSnapshot(panelId: panelId).pullRequest?.isStale, false) XCTAssertEqual(workspace.pullRequest?.isStale, false) } @@ -533,7 +532,7 @@ final class SidebarBranchOrderingTests: XCTestCase { workspace.updatePanelGitBranch(panelId: panelId, branch: "main", isDirty: false) XCTAssertNil(workspace.pullRequest) - XCTAssertNil(workspace.panelPullRequests[panelId]) + XCTAssertNil(workspace.surfaceStateSnapshot(panelId: panelId).pullRequest) XCTAssertTrue(workspace.sidebarPullRequestsInDisplayOrder().isEmpty) } diff --git a/cmuxTests/SidebarWidthPolicyTests.swift b/cmuxTests/SidebarWidthPolicyTests.swift index db1d68fddc9f..40009e959568 100644 --- a/cmuxTests/SidebarWidthPolicyTests.swift +++ b/cmuxTests/SidebarWidthPolicyTests.swift @@ -15,49 +15,42 @@ final class SidebarWidthPolicyTests: XCTestCase { ) } - func testRightSidebarClampAllowsWideExplorerOnLargeWindows() { + func testMinimalModeSidebarWorkspaceRowsStartAtTop() { XCTAssertEqual( - ContentView.clampedRightSidebarWidth(900, availableWidth: 1600), - 900, - accuracy: 0.001 + VerticalTabsSidebarLayoutMetrics.workspaceScrollTopVisibilityInset(titlebarHeight: 32, isMinimalMode: true), + 0, + accuracy: 0.5, + "Minimal mode should not push workspace tabs below titlebar space" ) - } - - func testRightSidebarClampLeavesTerminalWidth() { XCTAssertEqual( - ContentView.clampedRightSidebarWidth(10_000, availableWidth: 1000), - 640, - accuracy: 0.001 + VerticalTabsSidebarLayoutMetrics.sidebarTopScrimHeight(titlebarHeight: 32, isMinimalMode: true), + 0, + accuracy: 0.5, + "Minimal mode should not keep the titlebar scrim above workspace tabs" ) - } - - func testRightSidebarClampKeepsMinimumWidth() { XCTAssertEqual( - ContentView.clampedRightSidebarWidth(20, availableWidth: 1000), - 276, - accuracy: 0.001 + VerticalTabsSidebarLayoutMetrics.titlebarDragHandleHeight(titlebarHeight: 32, isMinimalMode: true), + 0, + accuracy: 0.5, + "Minimal mode should not keep a titlebar drag strip above workspace tabs" ) } - func testLeadingSidebarResizeRangeFavorsSidebarSide() { - let range = SidebarResizeInteraction.Edge.leading.hitRange(dividerX: 200) - - XCTAssertEqual(range.lowerBound, 194, accuracy: 0.001) - XCTAssertEqual(range.upperBound, 204, accuracy: 0.001) - XCTAssertTrue(range.contains(196)) - XCTAssertTrue(range.contains(202)) - XCTAssertFalse(range.contains(193.9)) - XCTAssertFalse(range.contains(204.1)) - } - - func testTrailingSidebarResizeRangeFavorsSidebarSide() { - let range = SidebarResizeInteraction.Edge.trailing.hitRange(dividerX: 680) - - XCTAssertEqual(range.lowerBound, 676, accuracy: 0.001) - XCTAssertEqual(range.upperBound, 686, accuracy: 0.001) - XCTAssertTrue(range.contains(678)) - XCTAssertTrue(range.contains(684)) - XCTAssertFalse(range.contains(675.9)) - XCTAssertFalse(range.contains(686.1)) + func testStandardModeSidebarKeepsTitlebarReservation() { + XCTAssertEqual( + VerticalTabsSidebarLayoutMetrics.workspaceScrollTopVisibilityInset(titlebarHeight: 32, isMinimalMode: false), + 40, + accuracy: 0.5 + ) + XCTAssertEqual( + VerticalTabsSidebarLayoutMetrics.sidebarTopScrimHeight(titlebarHeight: 32, isMinimalMode: false), + 52, + accuracy: 0.5 + ) + XCTAssertEqual( + VerticalTabsSidebarLayoutMetrics.titlebarDragHandleHeight(titlebarHeight: 32, isMinimalMode: false), + 32, + accuracy: 0.5 + ) } } diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index b0d448561e48..ad4c551124c5 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -63,8 +63,8 @@ final class TabManagerSessionSnapshotTests: XCTestCase { terminalStartupCommand: "ssh cmux-macmini" ) remoteWorkspace.configureRemoteConnection(configuration, autoConnect: false) - let paneId = try XCTUnwrap(remoteWorkspace.bonsplitController.allPaneIds.first) - _ = remoteWorkspace.newBrowserSurface(inPane: paneId, url: URL(string: "http://localhost:3000"), focus: false) + let paneId = try XCTUnwrap(remoteWorkspace.paneIds.first) + _ = remoteWorkspace.createBrowserPanel(inPane: paneId, url: URL(string: "http://localhost:3000"), focus: false) let snapshot = manager.sessionSnapshot(includeScrollback: false) diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index 45b3085ae78e..e834e53d5182 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -1,10 +1,10 @@ import XCTest import AppKit +import Combine import SwiftUI import UniformTypeIdentifiers import WebKit import ObjectiveC.runtime -import Bonsplit import UserNotifications #if canImport(cmux_DEV) @@ -76,6 +76,15 @@ private func splitNodes(in node: ExternalTreeNode) -> [ExternalSplitNode] { } } +private func paneLeafCount(in node: ExternalTreeNode) -> Int { + switch node { + case .pane: + return 1 + case .split(let split): + return paneLeafCount(in: split.first) + paneLeafCount(in: split.second) + } +} + private func runProcess( executablePath: String, arguments: [String], @@ -262,7 +271,7 @@ final class TabManagerChildExitCloseTests: XCTestCase { return } - guard let splitPanel = workspace.newTerminalSplit(from: initialPanelId, orientation: .horizontal) else { + guard let splitPanel = workspace.splitTerminalPanel(fromPanelId: initialPanelId, orientation: .horizontal) else { XCTFail("Expected split terminal panel to be created") return } @@ -288,14 +297,43 @@ final class TabManagerWorkspaceOwnershipTests: XCTestCase { let externalWorkspace = Workspace(title: "External workspace") let externalPanelCountBefore = externalWorkspace.panels.count - let externalPanelTitlesBefore = externalWorkspace.panelTitles + let externalPanelTitlesBefore = externalWorkspace.surfaceStatesSnapshot().compactMapValues(\.title) manager.closeWorkspace(externalWorkspace) XCTAssertEqual(manager.tabs.map(\.id), initialTabIds) XCTAssertEqual(manager.selectedTabId, initialSelectedTabId) XCTAssertEqual(externalWorkspace.panels.count, externalPanelCountBefore) - XCTAssertEqual(externalWorkspace.panelTitles, externalPanelTitlesBefore) + XCTAssertEqual(externalWorkspace.surfaceStatesSnapshot().compactMapValues(\.title), externalPanelTitlesBefore) + } + + func testClosingSelectedWorkspacePublishesTabsAfterSelectionMovesToSurvivor() { + let manager = TabManager() + guard let firstWorkspace = manager.selectedWorkspace else { + XCTFail("Expected initial workspace") + return + } + + let closingWorkspace = manager.addWorkspace() + XCTAssertEqual(manager.selectedTabId, closingWorkspace.id) + + var tabsSnapshots: [([UUID], UUID?)] = [] + let cancellable = manager.$tabs + .dropFirst() + .sink { newTabs in + tabsSnapshots.append((newTabs.map(\.id), manager.selectedTabId)) + } + + manager.closeWorkspace(closingWorkspace) + drainMainQueue() + + XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id]) + XCTAssertEqual(manager.selectedTabId, firstWorkspace.id) + XCTAssertEqual(tabsSnapshots.count, 1) + XCTAssertEqual(tabsSnapshots[0].0, [firstWorkspace.id]) + XCTAssertEqual(tabsSnapshots[0].1, firstWorkspace.id) + + withExtendedLifetime(cancellable) {} } } @@ -393,47 +431,6 @@ final class TabManagerPullRequestProbeTests: XCTestCase { ) } - func testPullRequestMapDropsStaleMergedHeadPullRequestForLongLivedBaseBranch() throws { - let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2026-04-20T12:00:00Z")) - let pullRequests = [ - TabManager.GitHubPullRequestProbeItem( - number: 2400, - state: "MERGED", - url: "https://github.com/manaflow-ai/cmux/pull/2400", - updatedAt: "2026-03-06T12:00:00Z", - mergedAt: "2026-03-06T12:00:00Z", - headRefName: "develop", - baseRefName: "main" - ), - TabManager.GitHubPullRequestProbeItem( - number: 2501, - state: "MERGED", - url: "https://github.com/manaflow-ai/cmux/pull/2501", - updatedAt: "2026-04-19T12:00:00Z", - mergedAt: "2026-04-19T12:00:00Z", - headRefName: "feature/recent-one", - baseRefName: "develop" - ), - TabManager.GitHubPullRequestProbeItem( - number: 2502, - state: "OPEN", - url: "https://github.com/manaflow-ai/cmux/pull/2502", - updatedAt: "2026-04-20T12:00:00Z", - headRefName: "feature/recent-two", - baseRefName: "develop" - ), - ] - - let pullRequestsByBranch = TabManager.pullRequestMapByNormalizedBranchForTesting( - from: pullRequests, - now: now - ) - - XCTAssertNil(pullRequestsByBranch["develop"]) - XCTAssertEqual(pullRequestsByBranch["feature/recent-one"]?.number, 2501) - XCTAssertEqual(pullRequestsByBranch["feature/recent-two"]?.number, 2502) - } - func testShouldSkipWorkspacePullRequestLookupOnlyForExactMainAndMaster() { XCTAssertTrue(TabManager.shouldSkipWorkspacePullRequestLookup(branch: "main")) XCTAssertTrue(TabManager.shouldSkipWorkspacePullRequestLookup(branch: "master")) @@ -489,9 +486,9 @@ final class TabManagerPullRequestProbeTests: XCTestCase { return } - guard let masterPanel = workspace.newTerminalSplit(from: mainPanelId, orientation: .horizontal), - let featurePanel = workspace.newTerminalSplit(from: mainPanelId, orientation: .vertical), - let mainlinePanel = workspace.newTerminalSplit(from: mainPanelId, orientation: .horizontal) else { + guard let masterPanel = workspace.splitTerminalPanel(fromPanelId: mainPanelId, orientation: .horizontal), + let featurePanel = workspace.splitTerminalPanel(fromPanelId: mainPanelId, orientation: .vertical), + let mainlinePanel = workspace.splitTerminalPanel(fromPanelId: mainPanelId, orientation: .horizontal) else { XCTFail("Expected split panels to be created") return } @@ -560,7 +557,7 @@ final class TabManagerPullRequestProbeTests: XCTestCase { manager.activeWorkspaceGitProbePanelIdsForTesting(workspaceId: workspace.id).isEmpty && manager.trackedWorkspaceGitMetadataPollCandidatePanelIdsForTesting(workspaceId: workspace.id) .isEmpty && - workspace.panelGitBranches[panelId] == nil + workspace.surfaceStateSnapshot(panelId: panelId).gitBranch == nil } ) } @@ -600,8 +597,8 @@ final class TabManagerPullRequestProbeTests: XCTestCase { XCTAssertNotEqual(manager.selectedTabId, backgroundWorkspace.id) XCTAssertTrue( - waitForCondition { - backgroundWorkspace.panelGitBranches[backgroundPanelId]?.branch == "main" + waitForCondition(timeout: 12.0) { + backgroundWorkspace.surfaceStateSnapshot(panelId: backgroundPanelId).gitBranch?.branch == "main" } ) XCTAssertEqual(backgroundWorkspace.sidebarGitBranchesInDisplayOrder().map(\.branch), ["main"]) @@ -644,8 +641,8 @@ final class TabManagerPullRequestProbeTests: XCTestCase { manager.refreshTrackedWorkspaceGitMetadataForTesting() XCTAssertTrue( - waitForCondition { - workspace.panelGitBranches[panelId]?.branch == "feature/sidebar-live-refresh" + waitForCondition(timeout: 10.0) { + workspace.surfaceStateSnapshot(panelId: panelId).gitBranch?.branch == "feature/sidebar-live-refresh" } ) XCTAssertEqual(workspace.gitBranch?.branch, "feature/sidebar-live-refresh") @@ -682,13 +679,13 @@ final class TabManagerPullRequestProbeTests: XCTestCase { manager.updateSurfaceGitBranch(tabId: workspace.id, surfaceId: panelId, branch: "main", isDirty: false) manager.clearSurfaceGitBranch(tabId: workspace.id, surfaceId: panelId) - XCTAssertNil(workspace.panelGitBranches[panelId]) + XCTAssertNil(workspace.surfaceStateSnapshot(panelId: panelId).gitBranch) manager.refreshTrackedWorkspaceGitMetadataForTesting() XCTAssertTrue( - waitForCondition { - workspace.panelGitBranches[panelId]?.branch == "main" + waitForCondition(timeout: 10.0) { + workspace.surfaceStateSnapshot(panelId: panelId).gitBranch?.branch == "main" } ) XCTAssertEqual(workspace.sidebarGitBranchesInDisplayOrder().map(\.branch), ["main"]) @@ -724,7 +721,7 @@ final class TabManagerPullRequestProbeTests: XCTestCase { autoConnect: false ) - guard let splitPanel = workspace.newTerminalSplit(from: panelId, orientation: .horizontal, focus: false) else { + guard let splitPanel = workspace.splitTerminalPanel(fromPanelId: panelId, orientation: .horizontal, focus: false) else { XCTFail("Expected remote split terminal panel to be created") return } @@ -798,8 +795,8 @@ final class TabManagerPullRequestProbeTests: XCTestCase { branch: "feature/sidebar-pr" ) - XCTAssertEqual(workspace.panelGitBranches[panelId]?.branch, "feature/sidebar-pr") - XCTAssertEqual(workspace.panelPullRequests[panelId]?.number, 1052) + XCTAssertEqual(workspace.surfaceStateSnapshot(panelId: panelId).gitBranch?.branch, "feature/sidebar-pr") + XCTAssertEqual(workspace.surfaceStateSnapshot(panelId: panelId).pullRequest?.number, 1052) XCTAssertEqual(workspace.sidebarPullRequestsInDisplayOrder().map(\.number), [1052]) try runGit(["checkout", "main"], in: repoURL) @@ -807,9 +804,9 @@ final class TabManagerPullRequestProbeTests: XCTestCase { manager.refreshTrackedWorkspaceGitMetadataForTesting() XCTAssertTrue( - waitForCondition { - workspace.panelGitBranches[panelId]?.branch == "main" - && workspace.panelPullRequests[panelId] == nil + waitForCondition(timeout: 10.0) { + workspace.surfaceStateSnapshot(panelId: panelId).gitBranch?.branch == "main" + && workspace.surfaceStateSnapshot(panelId: panelId).pullRequest == nil } ) XCTAssertEqual(workspace.gitBranch?.branch, "main") @@ -927,72 +924,6 @@ final class TabManagerCloseWorkspacesWithConfirmationTests: XCTestCase { } } -@MainActor -final class TabManagerCloseCurrentTabSpamTests: XCTestCase { - func testCloseCurrentTabSpamWithConfirmationEnabledPromptsOnceAndClosesOneWorkspace() { - let manager = TabManager() - while manager.tabs.count < 6 { - _ = manager.addWorkspace() - } - - for workspace in manager.tabs { - guard let panelId = workspace.focusedPanelId, - let terminalPanel = workspace.terminalPanel(for: panelId) else { - XCTFail("Expected each workspace to have a focused terminal panel") - return - } - terminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(true) - } - - var prompts: [(title: String, message: String, acceptCmdD: Bool)] = [] - manager.confirmCloseHandler = { title, message, acceptCmdD in - prompts.append((title, message, acceptCmdD)) - return true - } - - for _ in 0..<5 { - manager.closeCurrentTabWithConfirmation() - } - - XCTAssertEqual(prompts.count, 1, "Expected close-tab spam to surface only one confirmation prompt") - XCTAssertEqual( - prompts.first?.title, - String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?") - ) - XCTAssertEqual(prompts.first?.acceptCmdD, false) - XCTAssertEqual(manager.tabs.count, 5, "Expected only one workspace to close after the first accepted confirmation") - } - - func testCloseCurrentTabSpamWithConfirmationDisabledClosesEveryRequestedWorkspace() { - let manager = TabManager() - while manager.tabs.count < 6 { - _ = manager.addWorkspace() - } - - for workspace in manager.tabs { - guard let panelId = workspace.focusedPanelId, - let terminalPanel = workspace.terminalPanel(for: panelId) else { - XCTFail("Expected each workspace to have a focused terminal panel") - return - } - terminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(false) - } - - var promptCount = 0 - manager.confirmCloseHandler = { _, _, _ in - promptCount += 1 - return true - } - - for _ in 0..<5 { - manager.closeCurrentTabWithConfirmation() - } - - XCTAssertEqual(promptCount, 0, "Expected warning-disabled close-tab spam to bypass confirmation entirely") - XCTAssertEqual(manager.tabs.count, 1, "Expected warning-disabled close-tab spam to close all requested workspaces") - } -} - @MainActor final class TabManagerCloseCurrentPanelTests: XCTestCase { @@ -1188,7 +1119,7 @@ final class TabManagerCloseCurrentPanelTests: XCTestCase { XCTAssertEqual(secondWorkspace.panels.count, 1) guard let secondSurfaceId = secondWorkspace.surfaceIdFromPanelId(secondPanelId) else { - XCTFail("Expected bonsplit surface ID for focused panel") + XCTFail("Expected WorkspaceSplit surface ID for focused panel") return } @@ -1226,7 +1157,7 @@ final class TabManagerCloseCurrentPanelTests: XCTestCase { } guard let secondSurfaceId = secondWorkspace.surfaceIdFromPanelId(secondPanelId) else { - XCTFail("Expected bonsplit surface ID for focused panel") + XCTFail("Expected WorkspaceSplit surface ID for focused panel") return } @@ -1324,21 +1255,21 @@ final class TabManagerNotificationFocusTests: XCTestCase { let manager = TabManager() guard let workspace = manager.selectedWorkspace, let leftPanelId = workspace.focusedPanelId, - let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + let rightPanel = workspace.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .horizontal) else { XCTFail("Expected split setup to succeed") return } workspace.focusPanel(leftPanelId) XCTAssertTrue(workspace.toggleSplitZoom(panelId: leftPanelId), "Expected split zoom to enable") - XCTAssertTrue(workspace.bonsplitController.isSplitZoomed, "Expected workspace to start zoomed") + XCTAssertTrue(workspace.isSplitZoomed, "Expected workspace to start zoomed") XCTAssertTrue(manager.focusTabFromNotification(workspace.id, surfaceId: rightPanel.id)) drainMainQueue() drainMainQueue() XCTAssertFalse( - workspace.bonsplitController.isSplitZoomed, + workspace.isSplitZoomed, "Expected notification focus to exit split zoom so the target pane becomes visible" ) XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected notification target panel to be focused") @@ -1372,7 +1303,7 @@ final class TabManagerNotificationFocusTests: XCTestCase { appDelegate.notificationStore = store AppFocusState.overrideIsFocused = true defaults.set(true, forKey: TmuxOverlayExperimentSettings.enabledKey) - defaults.set(TmuxOverlayExperimentTarget.bonsplitPane.rawValue, forKey: TmuxOverlayExperimentSettings.targetKey) + defaults.set(TmuxOverlayExperimentTarget.workspacePane.rawValue, forKey: TmuxOverlayExperimentSettings.targetKey) defer { store.replaceNotificationsForTesting([]) @@ -1394,7 +1325,7 @@ final class TabManagerNotificationFocusTests: XCTestCase { guard let workspace = manager.selectedWorkspace, let leftPanelId = workspace.focusedPanelId, - let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + let rightPanel = workspace.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .horizontal) else { XCTFail("Expected split terminal panels") return } @@ -1414,11 +1345,11 @@ final class TabManagerNotificationFocusTests: XCTestCase { XCTAssertTrue(manager.focusTabFromNotification(workspace.id, surfaceId: rightPanel.id)) - let expectation = XCTestExpectation(description: "notification focus flash") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - expectation.fulfill() - } - wait(for: [expectation], timeout: 1) + XCTAssertTrue( + waitForCondition(timeout: 3.0) { + workspace.focusedPanelId == rightPanel.id && workspace.tmuxWorkspaceFlashToken == 1 + } + ) XCTAssertEqual(workspace.focusedPanelId, rightPanel.id) XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: rightPanel.id)) @@ -1430,36 +1361,6 @@ final class TabManagerNotificationFocusTests: XCTestCase { } -@MainActor -final class TabManagerPendingUnfocusPolicyTests: XCTestCase { - func testDoesNotUnfocusWhenPendingTabIsCurrentlySelected() { - let tabId = UUID() - - XCTAssertFalse( - TabManager.shouldUnfocusPendingWorkspace( - pendingTabId: tabId, - selectedTabId: tabId - ) - ) - } - - func testUnfocusesWhenPendingTabIsNotSelected() { - XCTAssertTrue( - TabManager.shouldUnfocusPendingWorkspace( - pendingTabId: UUID(), - selectedTabId: UUID() - ) - ) - XCTAssertTrue( - TabManager.shouldUnfocusPendingWorkspace( - pendingTabId: UUID(), - selectedTabId: nil - ) - ) - } -} - - @MainActor final class TabManagerSurfaceCreationTests: XCTestCase { func testNewSurfaceFocusesCreatedSurface() { @@ -1487,27 +1388,27 @@ final class TabManagerSurfaceCreationTests: XCTestCase { func testOpenBrowserInsertAtEndPlacesNewBrowserAtPaneEnd() { let manager = TabManager() guard let workspace = manager.selectedWorkspace, - let paneId = workspace.bonsplitController.focusedPaneId else { + let paneId = workspace.focusedPaneId else { XCTFail("Expected focused workspace and pane") return } // Add one extra surface so we verify append-to-end rather than first insert behavior. - _ = workspace.newTerminalSurface(inPane: paneId, focus: false) + _ = workspace.createTerminalPanel(inPane: paneId, focus: false) guard let browserPanelId = manager.openBrowser(insertAtEnd: true) else { XCTFail("Expected browser panel to be created") return } - let tabs = workspace.bonsplitController.tabs(inPane: paneId) - guard let lastSurfaceId = tabs.last?.id else { + let tabIds = workspace.surfaceIds(inPane: paneId) + guard let lastSurfaceId = tabIds.last else { XCTFail("Expected at least one surface in pane") return } XCTAssertEqual( - workspace.panelIdFromSurfaceId(lastSurfaceId), + workspace.panels[lastSurfaceId]?.id, browserPanelId, "Expected Cmd+Shift+B/Cmd+L open path to append browser surface at end" ) @@ -1527,7 +1428,7 @@ final class TabManagerSurfaceCreationTests: XCTestCase { let targetWorkspace = manager.addWorkspace(select: false) manager.selectWorkspace(initialWorkspace) - let initialPaneCount = targetWorkspace.bonsplitController.allPaneIds.count + let initialPaneCount = targetWorkspace.paneCount let initialPanelCount = targetWorkspace.panels.count guard let browserPanelId = manager.openBrowser( @@ -1542,7 +1443,7 @@ final class TabManagerSurfaceCreationTests: XCTestCase { XCTAssertEqual(manager.selectedTabId, targetWorkspace.id, "Expected target workspace to become selected") XCTAssertEqual( - targetWorkspace.bonsplitController.allPaneIds.count, + targetWorkspace.paneCount, initialPaneCount + 1, "Expected split-right browser open to create a new pane" ) @@ -1566,15 +1467,15 @@ final class TabManagerSurfaceCreationTests: XCTestCase { let manager = TabManager() guard let workspace = manager.selectedWorkspace, let leftPanelId = workspace.focusedPanelId, - let topRightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal), - workspace.newTerminalSplit(from: topRightPanel.id, orientation: .vertical) != nil, + let topRightPanel = workspace.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .horizontal), + workspace.splitTerminalPanel(fromPanelId: topRightPanel.id, orientation: .vertical) != nil, let topRightPaneId = workspace.paneId(forPanelId: topRightPanel.id), let url = URL(string: "https://example.com/pull/456") else { XCTFail("Expected split setup to succeed") return } - let initialPaneCount = workspace.bonsplitController.allPaneIds.count + let initialPaneCount = workspace.paneCount guard let browserPanelId = manager.openBrowser( inWorkspace: workspace.id, @@ -1587,7 +1488,7 @@ final class TabManagerSurfaceCreationTests: XCTestCase { } XCTAssertEqual( - workspace.bonsplitController.allPaneIds.count, + workspace.paneCount, initialPaneCount, "Expected split-right browser open to reuse existing panes" ) @@ -1597,13 +1498,13 @@ final class TabManagerSurfaceCreationTests: XCTestCase { "Expected browser to open in the top-right pane when multiple splits already exist" ) - let targetPaneTabs = workspace.bonsplitController.tabs(inPane: topRightPaneId) - guard let lastSurfaceId = targetPaneTabs.last?.id else { + let targetPaneTabIds = workspace.surfaceIds(inPane: topRightPaneId) + guard let lastSurfaceId = targetPaneTabIds.last else { XCTFail("Expected top-right pane to contain tabs") return } XCTAssertEqual( - workspace.panelIdFromSurfaceId(lastSurfaceId), + workspace.panels[lastSurfaceId]?.id, browserPanelId, "Expected browser surface to be appended at end in the reused top-right pane" ) @@ -1617,13 +1518,13 @@ final class TabManagerEqualizeSplitsTests: XCTestCase { let manager = TabManager() guard let workspace = manager.selectedWorkspace, let leftPanelId = workspace.focusedPanelId, - let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal), - workspace.newTerminalSplit(from: rightPanel.id, orientation: .vertical) != nil else { + let rightPanel = workspace.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .horizontal), + workspace.splitTerminalPanel(fromPanelId: rightPanel.id, orientation: .vertical) != nil else { XCTFail("Expected nested split setup to succeed") return } - let initialSplits = splitNodes(in: workspace.bonsplitController.treeSnapshot()) + let initialSplits = splitNodes(in: workspace.treeSnapshot()) XCTAssertGreaterThanOrEqual(initialSplits.count, 2, "Expected at least two split nodes in nested layout") for (index, split) in initialSplits.enumerated() { @@ -1633,17 +1534,20 @@ final class TabManagerEqualizeSplitsTests: XCTestCase { } let targetPosition: CGFloat = index.isMultiple(of: 2) ? 0.2 : 0.8 XCTAssertTrue( - workspace.bonsplitController.setDividerPosition(targetPosition, forSplit: splitId), + workspace.setDividerPosition(targetPosition, forSplit: splitId), "Expected to seed divider position for split \(splitId)" ) } XCTAssertTrue(manager.equalizeSplits(tabId: workspace.id), "Expected equalize splits command to succeed") - let equalizedSplits = splitNodes(in: workspace.bonsplitController.treeSnapshot()) + let equalizedSplits = splitNodes(in: workspace.treeSnapshot()) XCTAssertEqual(equalizedSplits.count, initialSplits.count) for split in equalizedSplits { - XCTAssertEqual(split.dividerPosition, 0.5, accuracy: 0.000_1) + let firstLeaves = paneLeafCount(in: split.first) + let totalLeaves = firstLeaves + paneLeafCount(in: split.second) + let expectedPosition = Double(firstLeaves) / Double(totalLeaves) + XCTAssertEqual(split.dividerPosition, expectedPosition, accuracy: 0.000_1) } } } @@ -1654,19 +1558,19 @@ final class TabManagerResizeSplitsTests: XCTestCase { let manager = TabManager() guard let workspace = manager.selectedWorkspace, let leftPanelId = workspace.focusedPanelId, - workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) != nil else { + workspace.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .horizontal) != nil else { XCTFail("Expected split setup to succeed") return } - guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first, + guard let split = splitNodes(in: workspace.treeSnapshot()).first, let splitId = UUID(uuidString: split.id) else { XCTFail("Expected a split node in tree snapshot") return } XCTAssertTrue( - workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId), + workspace.setDividerPosition(0.5, forSplit: splitId), "Expected to seed divider position" ) @@ -1675,7 +1579,7 @@ final class TabManagerResizeSplitsTests: XCTestCase { "Expected resizeSplit to succeed for the right edge of the left pane" ) - guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + guard let updatedSplit = splitNodes(in: workspace.treeSnapshot()).first else { XCTFail("Expected updated split node in tree snapshot") return } @@ -1691,19 +1595,19 @@ final class TabManagerResizeSplitsTests: XCTestCase { let manager = TabManager() guard let workspace = manager.selectedWorkspace, let leftPanelId = workspace.focusedPanelId, - let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + let rightPanel = workspace.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .horizontal) else { XCTFail("Expected split setup to succeed") return } - guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first, + guard let split = splitNodes(in: workspace.treeSnapshot()).first, let splitId = UUID(uuidString: split.id) else { XCTFail("Expected a split node in tree snapshot") return } XCTAssertTrue( - workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId), + workspace.setDividerPosition(0.5, forSplit: splitId), "Expected to seed divider position" ) @@ -1712,7 +1616,7 @@ final class TabManagerResizeSplitsTests: XCTestCase { "Expected resizeSplit to succeed for the left edge of the right pane" ) - guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + guard let updatedSplit = splitNodes(in: workspace.treeSnapshot()).first else { XCTFail("Expected updated split node in tree snapshot") return } @@ -1728,19 +1632,19 @@ final class TabManagerResizeSplitsTests: XCTestCase { let manager = TabManager() guard let workspace = manager.selectedWorkspace, let topPanelId = workspace.focusedPanelId, - workspace.newTerminalSplit(from: topPanelId, orientation: .vertical) != nil else { + workspace.splitTerminalPanel(fromPanelId: topPanelId, orientation: .vertical) != nil else { XCTFail("Expected split setup to succeed") return } - guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first, + guard let split = splitNodes(in: workspace.treeSnapshot()).first, let splitId = UUID(uuidString: split.id) else { XCTFail("Expected a split node in tree snapshot") return } XCTAssertTrue( - workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId), + workspace.setDividerPosition(0.5, forSplit: splitId), "Expected to seed divider position" ) @@ -1749,7 +1653,7 @@ final class TabManagerResizeSplitsTests: XCTestCase { "Expected resizeSplit to succeed for the bottom edge of the top pane" ) - guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + guard let updatedSplit = splitNodes(in: workspace.treeSnapshot()).first else { XCTFail("Expected updated split node in tree snapshot") return } @@ -1765,19 +1669,19 @@ final class TabManagerResizeSplitsTests: XCTestCase { let manager = TabManager() guard let workspace = manager.selectedWorkspace, let topPanelId = workspace.focusedPanelId, - let bottomPanel = workspace.newTerminalSplit(from: topPanelId, orientation: .vertical) else { + let bottomPanel = workspace.splitTerminalPanel(fromPanelId: topPanelId, orientation: .vertical) else { XCTFail("Expected split setup to succeed") return } - guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first, + guard let split = splitNodes(in: workspace.treeSnapshot()).first, let splitId = UUID(uuidString: split.id) else { XCTFail("Expected a split node in tree snapshot") return } XCTAssertTrue( - workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId), + workspace.setDividerPosition(0.5, forSplit: splitId), "Expected to seed divider position" ) @@ -1786,7 +1690,7 @@ final class TabManagerResizeSplitsTests: XCTestCase { "Expected resizeSplit to succeed for the top edge of the bottom pane" ) - guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + guard let updatedSplit = splitNodes(in: workspace.treeSnapshot()).first else { XCTFail("Expected updated split node in tree snapshot") return } @@ -1802,12 +1706,12 @@ final class TabManagerResizeSplitsTests: XCTestCase { let manager = TabManager() guard let workspace = manager.selectedWorkspace, let leftPanelId = workspace.focusedPanelId, - workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) != nil else { + workspace.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .horizontal) != nil else { XCTFail("Expected split setup to succeed") return } - guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + guard let split = splitNodes(in: workspace.treeSnapshot()).first else { XCTFail("Expected a split node in tree snapshot") return } @@ -1817,7 +1721,7 @@ final class TabManagerResizeSplitsTests: XCTestCase { "Expected resizeSplit to fail when the pane has no adjacent border in that direction" ) - guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + guard let updatedSplit = splitNodes(in: workspace.treeSnapshot()).first else { XCTFail("Expected updated split node in tree snapshot") return } @@ -1828,19 +1732,19 @@ final class TabManagerResizeSplitsTests: XCTestCase { let manager = TabManager() guard let workspace = manager.selectedWorkspace, let leftPanelId = workspace.focusedPanelId, - workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) != nil else { + workspace.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .horizontal) != nil else { XCTFail("Expected split setup to succeed") return } - guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first, + guard let split = splitNodes(in: workspace.treeSnapshot()).first, let splitId = UUID(uuidString: split.id) else { XCTFail("Expected a split node in tree snapshot") return } XCTAssertTrue( - workspace.bonsplitController.setDividerPosition(0.89, forSplit: splitId), + workspace.setDividerPosition(0.89, forSplit: splitId), "Expected to seed divider position near upper bound" ) @@ -1849,7 +1753,7 @@ final class TabManagerResizeSplitsTests: XCTestCase { "Expected resizeSplit to clamp instead of failing" ) - guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + guard let updatedSplit = splitNodes(in: workspace.treeSnapshot()).first else { XCTFail("Expected updated split node in tree snapshot") return } @@ -1861,19 +1765,19 @@ final class TabManagerResizeSplitsTests: XCTestCase { let manager = TabManager() guard let workspace = manager.selectedWorkspace, let topPanelId = workspace.focusedPanelId, - let bottomPanel = workspace.newTerminalSplit(from: topPanelId, orientation: .vertical) else { + let bottomPanel = workspace.splitTerminalPanel(fromPanelId: topPanelId, orientation: .vertical) else { XCTFail("Expected split setup to succeed") return } - guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first, + guard let split = splitNodes(in: workspace.treeSnapshot()).first, let splitId = UUID(uuidString: split.id) else { XCTFail("Expected a split node in tree snapshot") return } XCTAssertTrue( - workspace.bonsplitController.setDividerPosition(0.11, forSplit: splitId), + workspace.setDividerPosition(0.11, forSplit: splitId), "Expected to seed divider position near lower bound" ) @@ -1882,7 +1786,7 @@ final class TabManagerResizeSplitsTests: XCTestCase { "Expected resizeSplit to clamp instead of failing" ) - guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + guard let updatedSplit = splitNodes(in: workspace.treeSnapshot()).first else { XCTFail("Expected updated split node in tree snapshot") return } @@ -1911,7 +1815,7 @@ final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase { guard let workspace = manager.selectedWorkspace, let terminalPanelId = workspace.focusedPanelId, let paneId = workspace.paneId(forPanelId: terminalPanelId), - let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else { + let browserPanel = workspace.createBrowserPanel(inPane: paneId, focus: true) else { XCTFail("Expected selected workspace setup to succeed") return } @@ -1930,14 +1834,14 @@ final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase { let manager = TabManager() guard let workspace = manager.selectedWorkspace, let leftTerminalPanelId = workspace.focusedPanelId, - let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal), + let rightTerminalPanel = workspace.splitTerminalPanel(fromPanelId: leftTerminalPanelId, orientation: .horizontal), let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else { XCTFail("Expected split setup to succeed") return } workspace.focusPanel(leftTerminalPanelId) - _ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true) + _ = workspace.createBrowserPanel(inPane: rightPaneId, focus: true) XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId) let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource() @@ -1970,7 +1874,7 @@ final class TabManagerFocusedNotificationIndicatorTests: XCTestCase { appDelegate.notificationStore = store AppFocusState.overrideIsFocused = true defaults.set(true, forKey: TmuxOverlayExperimentSettings.enabledKey) - defaults.set(TmuxOverlayExperimentTarget.bonsplitPane.rawValue, forKey: TmuxOverlayExperimentSettings.targetKey) + defaults.set(TmuxOverlayExperimentTarget.workspacePane.rawValue, forKey: TmuxOverlayExperimentSettings.targetKey) defer { store.replaceNotificationsForTesting([]) @@ -1992,7 +1896,7 @@ final class TabManagerFocusedNotificationIndicatorTests: XCTestCase { guard let workspace = manager.selectedWorkspace, let leftPanelId = workspace.focusedPanelId, - let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + let rightPanel = workspace.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .horizontal) else { XCTFail("Expected split terminal panels") return } @@ -2076,7 +1980,7 @@ final class TabManagerFocusedNotificationIndicatorTests: XCTestCase { appDelegate.notificationStore = store AppFocusState.overrideIsFocused = true defaults.set(true, forKey: TmuxOverlayExperimentSettings.enabledKey) - defaults.set(TmuxOverlayExperimentTarget.bonsplitPane.rawValue, forKey: TmuxOverlayExperimentSettings.targetKey) + defaults.set(TmuxOverlayExperimentTarget.workspacePane.rawValue, forKey: TmuxOverlayExperimentSettings.targetKey) defer { store.replaceNotificationsForTesting([]) @@ -2175,13 +2079,12 @@ final class TabManagerReopenClosedBrowserFocusTests: XCTestCase { let manager = TabManager() guard let workspace1 = manager.selectedWorkspace, let sourcePanelId = workspace1.focusedPanelId, - let splitBrowserId = manager.newBrowserSplit( - tabId: workspace1.id, + let splitBrowserId = workspace1.splitBrowserPanel( fromPanelId: sourcePanelId, orientation: .horizontal, insertFirst: false, url: URL(string: "https://example.com/collapsed-split") - ) else { + )?.id else { XCTFail("Expected to create browser split") return } diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index ffbb17d3a78e..92cbfeeec835 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -4,7 +4,6 @@ import SwiftUI import UniformTypeIdentifiers import WebKit import ObjectiveC.runtime -import Bonsplit import UserNotifications #if canImport(cmux_DEV) @@ -26,14 +25,6 @@ final class GhosttyPasteboardHelperTests: XCTestCase { return try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) } - private func makeHTMLDocument(containing text: String) -> String { - let escaped = text - .replacingOccurrences(of: "&", with: "&") - .replacingOccurrences(of: "<", with: "<") - .replacingOccurrences(of: ">", with: ">") - return "
\(escaped)
" - } - func testHTMLOnlyPasteboardExtractsPlainText() { let pasteboard = NSPasteboard(name: .init("cmux-test-html-\(UUID().uuidString)")) pasteboard.clearContents() @@ -118,65 +109,6 @@ final class GhosttyPasteboardHelperTests: XCTestCase { ) } - /// Regression test for https://github.com/manaflow-ai/cmux/issues/2940. - /// Some apps place the same large clipboard payload onto `.string`, `.html`, - /// and `.rtf`. cmux should hand the plain text to the terminal quickly - /// instead of first rendering the rich-text variants on the paste path. - func testLargePlainTextPasteStaysFastWhenRichTextTypesAreAlsoPresent() throws { - final class MockPTY { - private(set) var receivedText = "" - - func write(_ text: String) { - receivedText += text - } - } - - let pasteboard = NSPasteboard(name: .init("cmux-test-large-fast-paste-\(UUID().uuidString)")) - pasteboard.clearContents() - - let text = String( - repeating: "abcdefghijklmnopqrstuvwxyz0123456789\n", - count: 65_536 - ) - let rtfData = try NSAttributedString(string: text).data( - from: NSRange(location: 0, length: text.utf16.count), - documentAttributes: [.documentType: NSAttributedString.DocumentType.rtf] - ) - pasteboard.setString(text, forType: .string) - pasteboard.setString(makeHTMLDocument(containing: text), forType: .html) - pasteboard.setData(rtfData, forType: .rtf) - - let mockPTY = MockPTY() - let startedAt = ProcessInfo.processInfo.systemUptime - - let plan = TerminalImageTransferPlanner.plan( - pasteboard: pasteboard, - mode: .paste, - target: .local - ) - TerminalImageTransferPlanner.executeForTesting( - plan: plan, - uploadWorkspaceRemote: { _, _, _ in - XCTFail("large text paste should not trigger remote upload") - }, - uploadDetectedSSH: { _, _, _, _ in - XCTFail("large text paste should not trigger SSH upload") - }, - insertText: { mockPTY.write($0) }, - onFailure: { error in - XCTFail("unexpected paste failure: \(error)") - } - ) - - let elapsed = ProcessInfo.processInfo.systemUptime - startedAt - XCTAssertEqual(mockPTY.receivedText, text) - XCTAssertLessThan( - elapsed, - 0.5, - "large plain-text pastes should not spend hundreds of milliseconds decoding HTML/RTF before writing to the PTY" - ) - } - func testXHTMLTypeFallsBackToRenderedHTMLText() { let pasteboard = NSPasteboard(name: .init("cmux-test-xhtml-html-fallback-\(UUID().uuidString)")) pasteboard.clearContents() @@ -189,31 +121,6 @@ final class GhosttyPasteboardHelperTests: XCTestCase { XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello world") } - func testPublicURLPastePreservesOriginalURLText() throws { - let pasteboard = NSPasteboard(name: .init("cmux-test-public-url-\(UUID().uuidString)")) - pasteboard.clearContents() - - let rawURL = "https://example.com?a=1&b=2" - let nsURL = try XCTUnwrap(NSURL(string: rawURL)) - XCTAssertTrue(pasteboard.writeObjects([nsURL])) - XCTAssertTrue(pasteboard.types?.contains(.URL) == true) - XCTAssertFalse(pasteboard.types?.contains(.fileURL) == true) - - XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), rawURL) - - let plan = TerminalImageTransferPlanner.plan( - pasteboard: pasteboard, - mode: .paste, - target: .local - ) - - guard case .insertText(let text) = plan else { - return XCTFail("expected URL text insertion, got \(plan)") - } - - XCTAssertEqual(text, rawURL) - } - func testImageClipboardWithPlainTextFallbackStillFallsBackToImagePath() throws { let pasteboard = NSPasteboard(name: .init("cmux-test-image-plain-text-fallback-\(UUID().uuidString)")) pasteboard.clearContents() @@ -370,38 +277,6 @@ final class GhosttyPasteboardHelperTests: XCTestCase { XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath)) } - func testAttachmentOnlyRTFDClipboardWithPlainTextFallbackStillFallsBackToImagePath() throws { - let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-attachment-string-\(UUID().uuidString)")) - pasteboard.clearContents() - pasteboard.setString( - "https://example.com/keyboard.tiff", - forType: .string - ) - - let image = NSImage(size: NSSize(width: 1, height: 1)) - image.lockFocus() - NSColor.orange.setFill() - NSRect(x: 0, y: 0, width: 1, height: 1).fill() - image.unlockFocus() - - let attachment = NSTextAttachment() - attachment.image = image - let attributed = NSAttributedString(attachment: attachment) - let data = try attributed.data( - from: NSRange(location: 0, length: attributed.length), - documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd] - ) - pasteboard.setData(data, forType: .rtfd) - - XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard)) - - let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard)) - defer { try? FileManager.default.removeItem(atPath: imagePath) } - - XCTAssertTrue(imagePath.hasSuffix(".tiff")) - XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath)) - } - func testAttachmentOnlyRTFDNonImageClipboardDoesNotFallBackToImagePath() throws { let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-non-image-\(UUID().uuidString)")) pasteboard.clearContents() @@ -1505,20 +1380,6 @@ final class GhosttyResponderResolutionTests: XCTestCase { override var acceptsFirstResponder: Bool { true } } - private final class DelegateTrackingTextView: NSTextView { - private(set) var delegateReadCount = 0 - - override var delegate: NSTextViewDelegate? { - get { - delegateReadCount += 1 - return super.delegate - } - set { - super.delegate = newValue - } - } - } - func testResolvesGhosttyViewFromDescendantResponder() { let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) let descendant = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40)) @@ -1536,17 +1397,6 @@ final class GhosttyResponderResolutionTests: XCTestCase { let view = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40)) XCTAssertNil(cmuxOwningGhosttyView(for: view)) } - - func testDoesNotReadTextViewDelegateForGhosttyResponderResolution() { - let textView = DelegateTrackingTextView(frame: NSRect(x: 0, y: 0, width: 40, height: 40)) - - XCTAssertNil(cmuxOwningGhosttyView(for: textView)) - XCTAssertEqual( - textView.delegateReadCount, - 0, - "Ghostty responder resolution must avoid NSTextView.delegate because AppKit exposes it as unsafe-unretained" - ) - } } @@ -2053,59 +1903,6 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { ) #else throw XCTSkip("Debug-only regression test") -#endif - } - - func testVisibilityRestoreRefreshesSurfaceWhileTerminalIsInactive() throws { -#if DEBUG - let window = makeWindow() - defer { window.orderOut(nil) } - - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let surface = TerminalSurface( - tabId: UUID(), - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: nil, - workingDirectory: nil - ) - let hostedView = surface.hostedView - hostedView.frame = contentView.bounds - hostedView.autoresizingMask = [.width, .height] - contentView.addSubview(hostedView) - - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - contentView.layoutSubtreeIfNeeded() - hostedView.layoutSubtreeIfNeeded() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - XCTAssertNotNil( - surface.surface, - "Expected runtime surface before measuring visibility-restore redraws" - ) - - hostedView.setActive(false) - hostedView.setVisibleInUI(false) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - surface.resetDebugForceRefreshCount() - hostedView.setVisibleInUI(true) - - let drained = expectation(description: "visible toggle drained") - DispatchQueue.main.async { drained.fulfill() } - wait(for: [drained], timeout: 1.0) - - XCTAssertEqual( - surface.debugForceRefreshCount(), - 1, - "Restoring panel visibility should force a redraw even when focus recovery is inactive" - ) -#else - throw XCTSkip("Debug-only regression test") #endif } } @@ -2119,7 +1916,7 @@ final class WindowTerminalHostViewTests: XCTestCase { } } - private final class BonsplitMockSplitDelegate: NSObject, NSSplitViewDelegate {} + private final class WorkspaceSplitMockSplitDelegate: NSObject, NSSplitViewDelegate {} private func makeHostedTerminalView(frame: NSRect) -> GhosttySurfaceScrollView { let surfaceView = GhosttyNSView(frame: frame) @@ -2181,7 +1978,7 @@ final class WindowTerminalHostViewTests: XCTestCase { splitView.autoresizingMask = [.width, .height] splitView.isVertical = true splitView.dividerStyle = .thin - let splitDelegate = BonsplitMockSplitDelegate() + let splitDelegate = WorkspaceSplitMockSplitDelegate() splitView.delegate = splitDelegate let first = NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height)) let second = NSView(frame: NSRect(x: 121, y: 0, width: 179, height: contentView.bounds.height)) @@ -2238,7 +2035,7 @@ final class WindowTerminalHostViewTests: XCTestCase { splitView.autoresizingMask = [.width, .height] splitView.isVertical = true splitView.dividerStyle = .thin - let splitDelegate = BonsplitMockSplitDelegate() + let splitDelegate = WorkspaceSplitMockSplitDelegate() splitView.delegate = splitDelegate let first = NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height)) let second = NSView(frame: NSRect(x: 121, y: 0, width: 179, height: contentView.bounds.height)) @@ -4137,56 +3934,6 @@ final class TerminalControllerSocketTextChunkTests: XCTestCase { } } - -final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { - func testImmediateStateUpdateAllowedWhenHostNotInWindow() { - XCTAssertTrue( - GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( - hostedViewHasSuperview: true, - isBoundToCurrentHost: false - ) - ) - } - - func testImmediateStateUpdateAllowedWhenBoundToCurrentHost() { - XCTAssertTrue( - GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( - hostedViewHasSuperview: true, - isBoundToCurrentHost: true - ) - ) - } - - func testImmediateStateUpdateSkippedForStaleHostBoundElsewhere() { - XCTAssertFalse( - GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( - hostedViewHasSuperview: true, - isBoundToCurrentHost: false - ) - ) - } - - func testImmediateStateUpdateAllowedWhenUnboundAndNotAttachedAnywhere() { - XCTAssertTrue( - GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( - hostedViewHasSuperview: false, - isBoundToCurrentHost: false - ) - ) - } - - func testInteractiveGeometryResizeUsesImmediatePortalSyncDecision() { - XCTAssertTrue( - GhosttyTerminalView.shouldSynchronizePortalGeometryImmediately( - hostInLiveResize: false, - windowInLiveResize: false, - interactiveGeometryResizeActive: true - ), - "Interactive resize should use the immediate portal sync path" - ) - } -} - final class GhosttyModifierFlagsChangedActionTests: XCTestCase { func testLeftShiftPressReturnsPress() { XCTAssertEqual( @@ -4324,8 +4071,6 @@ final class GhosttyModifierFlagsChangedActionTests: XCTestCase { ) } } - - final class TerminalControllerSocketListenerHealthTests: XCTestCase { func testStableSocketBindPermissionFailureFallsBackToUserScopedSocket() { XCTAssertEqual( diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index 1ff6a3c9cafd..3328e02dbd5a 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -205,7 +205,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTFail("Expected selected workspace with a focused panel") return } - guard let targetPanel = workspace.newTerminalSplit(from: focusedPanelId, orientation: .horizontal) else { + guard let targetPanel = workspace.splitTerminalPanel(fromPanelId: focusedPanelId, orientation: .horizontal) else { XCTFail("Expected split panel to be created") return } @@ -279,7 +279,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertEqual(reportTTYResponse["ok"] as? Bool, true, "Unexpected JSON-RPC response: \(reportTTYResponse)") let reportTTYResult = try XCTUnwrap(reportTTYResponse["result"] as? [String: Any], "Unexpected JSON-RPC response: \(reportTTYResponse)") XCTAssertEqual(reportTTYResult["surface_id"] as? String, focusedPanelId.uuidString) - XCTAssertEqual(workspace.surfaceTTYNames[focusedPanelId], "ttys999") + XCTAssertEqual(workspace.surfaceTTYName(panelId: focusedPanelId), "ttys999") let portsKickResponse = try await sendV2RequestAsync( method: "surface.ports_kick", @@ -327,7 +327,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertEqual(reportTTYError["code"] as? String, "not_found") let reportTTYData = try XCTUnwrap(reportTTYError["data"] as? [String: Any], "Expected error data payload") XCTAssertEqual(reportTTYData["surface_id"] as? String, unknownSurfaceId.uuidString) - XCTAssertTrue(workspace.surfaceTTYNames.isEmpty) + XCTAssertTrue(workspace.surfaceStatesSnapshot().compactMapValues(\.ttyName).isEmpty) let portsKickResponse = try await sendV2RequestAsync( method: "surface.ports_kick", diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index 8b3a7accf868..bcbbd887c2cf 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -193,6 +193,63 @@ final class TitlebarControlsHoverPolicyTests: XCTestCase { } } +final class TitlebarAccessoryWindowSupportTests: XCTestCase { + private func restoreDefaultsValue(_ value: Any?, forKey key: String, defaults: UserDefaults) { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + + @MainActor + func testMainLikeBorderlessWindowDoesNotAttachTitlebarAccessories() { + let defaults = UserDefaults.standard + let savedMode = defaults.object(forKey: WorkspacePresentationModeSettings.modeKey) + defaults.set(WorkspacePresentationModeSettings.Mode.standard.rawValue, forKey: WorkspacePresentationModeSettings.modeKey) + defer { + restoreDefaultsValue(savedMode, forKey: WorkspacePresentationModeSettings.modeKey, defaults: defaults) + } + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 200), + styleMask: .borderless, + backing: .buffered, + defer: false + ) + window.identifier = NSUserInterfaceItemIdentifier("cmux.main.unsupported-titlebar") + defer { window.close() } + + XCTAssertFalse(window.cmuxSupportsTitlebarAccessoryControllers) + + let controller = UpdateTitlebarAccessoryController(viewModel: UpdateViewModel()) + controller.attach(to: window) + } + + @MainActor + func testMinimalModeSkipsUnsupportedWindowAccessoryRemoval() { + let defaults = UserDefaults.standard + let savedMode = defaults.object(forKey: WorkspacePresentationModeSettings.modeKey) + defaults.set(WorkspacePresentationModeSettings.Mode.minimal.rawValue, forKey: WorkspacePresentationModeSettings.modeKey) + defer { + restoreDefaultsValue(savedMode, forKey: WorkspacePresentationModeSettings.modeKey, defaults: defaults) + } + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 200), + styleMask: .borderless, + backing: .buffered, + defer: false + ) + defer { window.close() } + + XCTAssertFalse(window.cmuxSupportsTitlebarAccessoryControllers) + + let controller = UpdateTitlebarAccessoryController(viewModel: UpdateViewModel()) + controller.attach(to: window) + } +} + final class AppIconAppearanceObserverTests: XCTestCase { private final class ObservationToken: AppIconAppearanceObservation { private(set) var invalidateCallCount = 0 diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index ef39c0327ca7..4274b6640b62 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -4,7 +4,6 @@ import SwiftUI import UniformTypeIdentifiers import WebKit import ObjectiveC.runtime -import Bonsplit import UserNotifications #if canImport(cmux_DEV) @@ -77,16 +76,14 @@ final class AppDelegateWindowContextRoutingTests: XCTestCase { windowId: windowAId, tabManager: managerA, sidebarState: SidebarState(), - sidebarSelectionState: SidebarSelectionState(), - fileExplorerState: FileExplorerState() + sidebarSelectionState: SidebarSelectionState() ) app.registerMainWindow( windowB, windowId: windowBId, tabManager: managerB, sidebarState: SidebarState(), - sidebarSelectionState: SidebarSelectionState(), - fileExplorerState: FileExplorerState() + sidebarSelectionState: SidebarSelectionState() ) windowB.makeKeyAndOrderFront(nil) @@ -119,16 +116,14 @@ final class AppDelegateWindowContextRoutingTests: XCTestCase { windowId: windowAId, tabManager: managerA, sidebarState: SidebarState(), - sidebarSelectionState: SidebarSelectionState(), - fileExplorerState: FileExplorerState() + sidebarSelectionState: SidebarSelectionState() ) app.registerMainWindow( windowB, windowId: windowBId, tabManager: managerB, sidebarState: SidebarState(), - sidebarSelectionState: SidebarSelectionState(), - fileExplorerState: FileExplorerState() + sidebarSelectionState: SidebarSelectionState() ) // Seed active manager and clear focus windows to force fallback routing. @@ -157,8 +152,7 @@ final class AppDelegateWindowContextRoutingTests: XCTestCase { windowId: windowId, tabManager: manager, sidebarState: SidebarState(), - sidebarSelectionState: SidebarSelectionState(), - fileExplorerState: FileExplorerState() + sidebarSelectionState: SidebarSelectionState() ) // SwiftUI can replace the NSWindow identifier string at runtime. @@ -189,16 +183,14 @@ final class AppDelegateWindowContextRoutingTests: XCTestCase { windowId: windowAId, tabManager: managerA, sidebarState: SidebarState(), - sidebarSelectionState: SidebarSelectionState(), - fileExplorerState: FileExplorerState() + sidebarSelectionState: SidebarSelectionState() ) app.registerMainWindow( windowB, windowId: windowBId, tabManager: managerB, sidebarState: SidebarState(), - sidebarSelectionState: SidebarSelectionState(), - fileExplorerState: FileExplorerState() + sidebarSelectionState: SidebarSelectionState() ) windowA.makeKeyAndOrderFront(nil) @@ -233,8 +225,7 @@ final class AppDelegateWindowContextRoutingTests: XCTestCase { windowId: windowId, tabManager: manager, sidebarState: SidebarState(), - sidebarSelectionState: SidebarSelectionState(), - fileExplorerState: FileExplorerState() + sidebarSelectionState: SidebarSelectionState() ) window.makeKeyAndOrderFront(nil) @@ -283,8 +274,7 @@ final class AppDelegateWindowContextRoutingTests: XCTestCase { windowId: windowId, tabManager: manager, sidebarState: SidebarState(), - sidebarSelectionState: SidebarSelectionState(), - fileExplorerState: FileExplorerState() + sidebarSelectionState: SidebarSelectionState() ) window.makeKeyAndOrderFront(nil) @@ -1036,40 +1026,6 @@ final class WindowMoveSuppressionHitPathTests: XCTestCase { @MainActor final class FileDropOverlayViewTests: XCTestCase { - private func makeContentViewWindow(windowId: UUID = UUID()) -> NSWindow { - _ = NSApplication.shared - - let root = ContentView(updateViewModel: UpdateViewModel(), windowId: windowId) - .environmentObject(TabManager()) - .environmentObject(TerminalNotificationStore.shared) - .environmentObject(SidebarState()) - .environmentObject(SidebarSelectionState()) - .environmentObject(FileExplorerState()) - .environmentObject(CmuxConfigStore()) - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 340), - styleMask: [.titled, .closable, .resizable], - backing: .buffered, - defer: false - ) - window.contentView = MainWindowHostingView(rootView: root) - return window - } - - private func fileDropOverlays(in root: NSView?) -> [FileDropOverlayView] { - guard let root else { return [] } - - var overlays: [FileDropOverlayView] = [] - if let overlay = root as? FileDropOverlayView { - overlays.append(overlay) - } - for subview in root.subviews { - overlays.append(contentsOf: fileDropOverlays(in: subview)) - } - return overlays - } - private final class DragSpyWebView: WKWebView { var dragCalls: [String] = [] @@ -1150,34 +1106,6 @@ final class FileDropOverlayViewTests: XCTestCase { window.contentView?.layoutSubtreeIfNeeded() } - func testContentViewInstallsSingleFileDropOverlayAcrossRepeatedLayouts() { - let window = makeContentViewWindow() - defer { - NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) - window.orderOut(nil) - } - - realizeWindowLayout(window) - realizeWindowLayout(window) - realizeWindowLayout(window) - - guard let themeFrame = window.contentView?.superview else { - XCTFail("Expected theme frame") - return - } - - let overlays = fileDropOverlays(in: themeFrame) - XCTAssertEqual( - overlays.count, - 1, - "ContentView should install exactly one FileDropOverlayView even after repeated layout passes" - ) - XCTAssertTrue( - (objc_getAssociatedObject(window, &fileDropOverlayKey) as? FileDropOverlayView) === overlays.first, - "The window-associated file-drop overlay should match the single installed view" - ) - } - func testOverlayResolvesPortalHostedBrowserWebViewForFileDrops() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 280), @@ -1446,118 +1374,4 @@ final class TmuxWorkspacePaneOverlayTests: XCTestCase { ) } } - -@MainActor -final class ApplicationAccessibilityHierarchyCacheTests: XCTestCase { - private func makeWindow() -> NSWindow { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 200), - styleMask: [.titled, .closable, .resizable], - backing: .buffered, - defer: false - ) - return window - } - - private func assertWindowsEqual(_ actual: Any?, _ expected: [NSWindow], file: StaticString = #filePath, line: UInt = #line) { - guard let actualWindows = actual as? [NSWindow] else { - XCTFail("Expected NSWindow array", file: file, line: line) - return - } - guard actualWindows.count == expected.count else { - XCTFail("Expected \(expected.count) windows, got \(actualWindows.count)", file: file, line: line) - return - } - for (lhs, rhs) in zip(actualWindows, expected) { - XCTAssertTrue(lhs === rhs, file: file, line: line) - } - } - - func testRepeatedWindowsQueriesReuseSingleHierarchyBuildUntilStateChanges() { - let firstWindow = makeWindow() - let secondWindow = makeWindow() - defer { - firstWindow.orderOut(nil) - secondWindow.orderOut(nil) - } - - let cache = CmuxApplicationAccessibilityHierarchyCache() - let state = CmuxApplicationAccessibilityHierarchyCache.StateToken(windows: [firstWindow, secondWindow]) - var buildCount = 0 - - let firstValue = cache.value(for: .windows, stateToken: state) { - buildCount += 1 - return .init(windows: [firstWindow, secondWindow]) - } - let secondValue = cache.value(for: .windows, stateToken: state) { - XCTFail("Expected cached snapshot for repeated state") - return .init(windows: []) - } - - assertWindowsEqual(firstValue, [firstWindow, secondWindow]) - assertWindowsEqual(secondValue, [firstWindow, secondWindow]) - XCTAssertEqual(buildCount, 1, "Expected a single hierarchy build for repeated AX queries with no invalidation") - } - - func testChangedStateTokenInvalidatesCachedHierarchySnapshot() { - let window = makeWindow() - let otherWindow = makeWindow() - defer { - window.orderOut(nil) - otherWindow.orderOut(nil) - } - - let cache = CmuxApplicationAccessibilityHierarchyCache() - let initialState = CmuxApplicationAccessibilityHierarchyCache.StateToken(windows: [window]) - let updatedState = CmuxApplicationAccessibilityHierarchyCache.StateToken(windows: [window, otherWindow]) - var buildCount = 0 - - _ = cache.value(for: .windows, stateToken: initialState) { - buildCount += 1 - return .init(windows: [window]) - } - let updatedWindowsValue = cache.value(for: .windows, stateToken: updatedState) { - buildCount += 1 - return .init(windows: [window, otherWindow]) - } - - assertWindowsEqual(updatedWindowsValue, [window, otherWindow]) - XCTAssertEqual(buildCount, 2, "Expected the cache to rebuild once after the hierarchy token changes") - } - - func testNonWindowsAttributesStayPassthrough() { - let cache = CmuxApplicationAccessibilityHierarchyCache() - - for attribute: NSAccessibility.Attribute in [.children, .visibleChildren, .mainWindow, .focusedWindow] { - switch cache.resolve(attribute: attribute, application: NSApp) { - case .passthrough: - break - case .handled: - XCTFail("Expected \(attribute.rawValue) to fall back to AppKit") - } - } - } - - func testWindowCloseNotificationInvalidatesCache() { - let window = makeWindow() - defer { window.orderOut(nil) } - - let center = NotificationCenter() - let cache = CmuxApplicationAccessibilityHierarchyCache(notificationCenter: center) - let state = CmuxApplicationAccessibilityHierarchyCache.StateToken(windows: [window]) - var buildCount = 0 - - _ = cache.value(for: .windows, stateToken: state) { - buildCount += 1 - return .init(windows: [window]) - } - center.post(name: NSWindow.willCloseNotification, object: window) - _ = cache.value(for: .windows, stateToken: state) { - buildCount += 1 - return .init(windows: [window]) - } - - XCTAssertEqual(buildCount, 2, "Expected NSWindow.willCloseNotification to invalidate the cache") - } -} #endif diff --git a/cmuxTests/WorkspaceContentViewVisibilityTests.swift b/cmuxTests/WorkspaceContentViewVisibilityTests.swift index 2d6e8fa5fa08..04f6a0d76710 100644 --- a/cmuxTests/WorkspaceContentViewVisibilityTests.swift +++ b/cmuxTests/WorkspaceContentViewVisibilityTests.swift @@ -1,6 +1,7 @@ import XCTest import CoreGraphics -import Bonsplit +import SwiftUI +import AppKit #if canImport(cmux_DEV) @testable import cmux_DEV @@ -9,34 +10,162 @@ import Bonsplit #endif final class WorkspaceContentViewVisibilityTests: XCTestCase { - func testBackgroundPrimedWorkspaceStaysMountedButNotPanelVisible() { - XCTAssertEqual( - MountedWorkspacePresentationPolicy.resolve( - isSelectedWorkspace: false, - isRetiringWorkspace: false, - shouldPrimeInBackground: true - ), - MountedWorkspacePresentation( - isRenderedVisible: false, - isPanelVisible: false, - renderOpacity: 0.001 + private func terminalLifecycleFacts( + isVisibleInUI: Bool, + isWindowed: Bool, + hasUsableGeometry: Bool, + hasRuntime: Bool, + hasPresentedFrame: Bool, + isActive: Bool + ) -> WorkspaceSurfaceLifecycleFacts { + WorkspaceSurfaceLifecycleFacts( + TerminalViewportLifecycleFacts( + isVisibleInUI: isVisibleInUI, + isWindowed: isWindowed, + hasUsableGeometry: hasUsableGeometry, + hasRuntime: hasRuntime, + hasPresentedFrame: hasPresentedFrame, + isActive: isActive ) ) } - func testRetiringWorkspaceStaysPanelVisibleDuringHandoff() { - XCTAssertEqual( - MountedWorkspacePresentationPolicy.resolve( - isSelectedWorkspace: false, - isRetiringWorkspace: true, - shouldPrimeInBackground: false - ), - MountedWorkspacePresentation( - isRenderedVisible: true, - isPanelVisible: true, - renderOpacity: 1 + @MainActor + private func terminalViewport( + paneId: PaneID, + surfaceId: UUID + ) -> WorkspaceLayoutViewportSnapshot { + let content = WorkspacePaneContent.terminal( + WorkspaceTerminalPaneContent( + surfaceId: surfaceId, + isFocused: true, + isVisibleInUI: true, + isSplit: false, + appearance: WorkspaceTerminalPaneAppearance(PanelAppearance( + dividerColor: .clear, + unfocusedOverlayNSColor: .clear, + unfocusedOverlayOpacity: 0 + )), + hasUnreadNotification: false, + onFocus: {}, + onTriggerFlash: {} + ) + ) + return WorkspaceLayoutViewportSnapshot( + paneId: paneId, + contentId: surfaceId, + mountIdentity: content.mountIdentity(contentId: surfaceId), + content: content, + frame: CGRect(x: 0, y: 0, width: 320, height: 240) + ) + } + + @MainActor + private func browserViewport( + paneId: PaneID, + surfaceId: UUID + ) -> WorkspaceLayoutViewportSnapshot { + let content = WorkspacePaneContent.browser( + WorkspaceBrowserPaneContent( + surfaceId: surfaceId, + paneId: paneId, + isFocused: true, + isVisibleInUI: true, + prefersLocalInlineHosting: true, + portalPriority: 0, + onRequestPanelFocus: {} ) ) + return WorkspaceLayoutViewportSnapshot( + paneId: paneId, + contentId: surfaceId, + mountIdentity: content.mountIdentity(contentId: surfaceId), + content: content, + frame: CGRect(x: 0, y: 0, width: 320, height: 240) + ) + } + + @MainActor + func testDismantlingWorkspaceLayoutRootHostHidesBrowserPortal() throws { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let paneId = workspace.focusedPaneId, + let browserPanel = workspace.createBrowserPanel(inPane: paneId, focus: true) else { + XCTFail("Expected focused workspace and browser panel") + return + } + + let renderContext = WorkspaceLayoutRenderContext( + notificationStore: nil, + isWorkspaceVisible: true, + isWorkspaceInputActive: true, + isMinimalMode: false, + appearance: PanelAppearance( + dividerColor: .clear, + unfocusedOverlayNSColor: .clear, + unfocusedOverlayOpacity: 0 + ), + workspacePortalPriority: 1, + usesWorkspacePaneOverlay: false, + showSplitButtons: workspace.showsSplitButtons + ) + let renderSnapshot = workspace.makeLayoutRenderSnapshot(context: renderContext) + let rootHost = WorkspaceLayoutRootHostView( + hostBridge: workspace.layoutInteractionHandlers, + renderSnapshot: renderSnapshot, + surfaceRegistry: workspace.surfaceRegistry + ) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 280), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let anchor = NSView(frame: NSRect(x: 40, y: 36, width: 220, height: 150)) + window.contentView?.addSubview(anchor) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + window.contentView?.layoutSubtreeIfNeeded() + + BrowserWindowPortalRegistry.bind( + webView: browserPanel.webView, + to: anchor, + visibleInUI: true, + zPriority: 1 + ) + BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) + XCTAssertEqual(BrowserWindowPortalRegistry.debugSnapshot(for: browserPanel.webView)?.visibleInUI, true) + + WorkspaceLayoutNativeHost.dismantleNSView(rootHost, coordinator: ()) + + XCTAssertEqual(BrowserWindowPortalRegistry.debugSnapshot(for: browserPanel.webView)?.visibleInUI, false) + let staleHostId = ObjectIdentifier(NSView()) + XCTAssertFalse( + browserPanel.claimPortalHost( + hostId: staleHostId, + paneId: paneId, + inWindow: true, + bounds: CGRect(x: 0, y: 0, width: 220, height: 150), + reason: "unitTest.staleAfterWorkspaceRemoval" + ), + "Workspace host removal should suspend stale portal-host claims" + ) + browserPanel.resumePortalHosting(reason: "unitTest.visibleReattach") + XCTAssertTrue( + browserPanel.claimPortalHost( + hostId: staleHostId, + paneId: paneId, + inWindow: true, + bounds: CGRect(x: 0, y: 0, width: 220, height: 150), + reason: "unitTest.visibleReattach" + ), + "Visible reattach should resume portal-host claims" + ) + + BrowserWindowPortalRegistry.detach(webView: browserPanel.webView) + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + window.orderOut(nil) } func testPanelVisibleInUIReturnsFalseWhenWorkspaceHidden() { @@ -79,6 +208,85 @@ final class WorkspaceContentViewVisibilityTests: XCTestCase { ) } + func testPanelPresentationFactsDeriveVisibilityAndResponderIntent() { + let paneId = PaneID(id: UUID()) + let panelId = UUID() + + let visibleFocused = WorkspacePanelPresentationFacts( + paneId: paneId, + panelId: panelId, + isWorkspaceVisible: true, + isWorkspaceInputActive: true, + isSelectedInPane: true, + isFocused: true + ) + XCTAssertTrue(visibleFocused.isVisibleInUI) + XCTAssertTrue(visibleFocused.wantsFirstResponder) + + let hidden = WorkspacePanelPresentationFacts( + paneId: paneId, + panelId: panelId, + isWorkspaceVisible: false, + isWorkspaceInputActive: true, + isSelectedInPane: true, + isFocused: true + ) + XCTAssertFalse(hidden.isVisibleInUI) + XCTAssertFalse(hidden.wantsFirstResponder) + } + + func testSelectedIndicatorUsesAccentOnlyForFocusedPane() { + let appearance = WorkspaceLayoutConfiguration.Appearance( + chromeColors: .init(backgroundHex: "#272822") + ) + + let focused = TabBarColors.nsColorSelectedIndicator(for: appearance, focused: true).usingColorSpace(.sRGB)! + let unfocused = TabBarColors.nsColorSelectedIndicator(for: appearance, focused: false).usingColorSpace(.sRGB)! + let inactive = TabBarColors.nsColorInactiveText(for: appearance).usingColorSpace(.sRGB)! + let accent = NSColor.controlAccentColor.usingColorSpace(.sRGB)! + let expectedInactiveIndicator = NSColor.white.withAlphaComponent(0.35).usingColorSpace(.sRGB)! + + XCTAssertEqual(focused.redComponent, accent.redComponent, accuracy: 0.01) + XCTAssertEqual(focused.greenComponent, accent.greenComponent, accuracy: 0.01) + XCTAssertEqual(focused.blueComponent, accent.blueComponent, accuracy: 0.01) + + XCTAssertEqual(unfocused.redComponent, expectedInactiveIndicator.redComponent, accuracy: 0.01) + XCTAssertEqual(unfocused.greenComponent, expectedInactiveIndicator.greenComponent, accuracy: 0.01) + XCTAssertEqual(unfocused.blueComponent, expectedInactiveIndicator.blueComponent, accuracy: 0.01) + XCTAssertEqual(unfocused.alphaComponent, expectedInactiveIndicator.alphaComponent, accuracy: 0.01) + XCTAssertNotEqual(unfocused.redComponent, accent.redComponent, accuracy: 0.05) + XCTAssertNotEqual(unfocused.alphaComponent, inactive.alphaComponent, accuracy: 0.05) + } + + func testTerminalPresentationTransitionResolverEmitsOnlyEdgeOperations() { + let hidden = WorkspaceTerminalPresentationState(isVisibleInUI: false, isActive: false) + let visibleFocused = WorkspaceTerminalPresentationState(isVisibleInUI: true, isActive: true) + + XCTAssertEqual( + WorkspaceTerminalPresentationTransitionResolver.operations( + previous: hidden, + next: visibleFocused + ), + [.setVisibleInUI(true), .setActive(true), .requestFirstResponderReconcile] + ) + + XCTAssertEqual( + WorkspaceTerminalPresentationTransitionResolver.operations( + previous: visibleFocused, + next: visibleFocused + ), + [] + ) + + XCTAssertEqual( + WorkspaceTerminalPresentationTransitionResolver.operations( + previous: visibleFocused, + next: hidden + ), + [.setVisibleInUI(false), .setActive(false)] + ) + } + func testTmuxWorkspacePaneOverlayRectReturnsMatchingPaneFrame() { let paneID = PaneID(id: UUID()) let snapshot = LayoutSnapshot( @@ -158,4 +366,342 @@ final class WorkspaceContentViewVisibilityTests: XCTestCase { [CGRect(x: 677.5, y: 30, width: 500, height: 290)] ) } + + func testTerminalRevealPhaseWaitsForFirstPresentedFrame() { + XCTAssertEqual( + WorkspaceSurfaceRevealPhase( + lifecycleFacts: terminalLifecycleFacts( + isVisibleInUI: true, + isWindowed: false, + hasUsableGeometry: false, + hasRuntime: false, + hasPresentedFrame: false, + isActive: false + ) + ), + .waitingForWindow + ) + + XCTAssertEqual( + WorkspaceSurfaceRevealPhase( + lifecycleFacts: terminalLifecycleFacts( + isVisibleInUI: true, + isWindowed: true, + hasUsableGeometry: false, + hasRuntime: false, + hasPresentedFrame: false, + isActive: false + ) + ), + .waitingForGeometry + ) + + XCTAssertEqual( + WorkspaceSurfaceRevealPhase( + lifecycleFacts: terminalLifecycleFacts( + isVisibleInUI: true, + isWindowed: true, + hasUsableGeometry: true, + hasRuntime: false, + hasPresentedFrame: false, + isActive: false + ) + ), + .waitingForRuntime + ) + + XCTAssertEqual( + WorkspaceSurfaceRevealPhase( + lifecycleFacts: terminalLifecycleFacts( + isVisibleInUI: true, + isWindowed: true, + hasUsableGeometry: true, + hasRuntime: true, + hasPresentedFrame: false, + isActive: false + ) + ), + .waitingForFirstFrame + ) + + XCTAssertEqual( + WorkspaceSurfaceRevealPhase( + lifecycleFacts: terminalLifecycleFacts( + isVisibleInUI: true, + isWindowed: true, + hasUsableGeometry: true, + hasRuntime: true, + hasPresentedFrame: true, + isActive: true + ) + ), + .visible + ) + } + + @MainActor + func testPaneContentSlotMoveKeepsViewMountedWhenNewSlotInstallsFirst() { + let firstSlot = WorkspaceLayoutPaneContentSlotView(frame: CGRect(x: 0, y: 0, width: 320, height: 240)) + let secondSlot = WorkspaceLayoutPaneContentSlotView(frame: CGRect(x: 0, y: 0, width: 320, height: 240)) + let hostedView = NSView(frame: CGRect(x: 0, y: 0, width: 320, height: 240)) + + firstSlot.installContentView(hostedView) + XCTAssertTrue(hostedView.superview === firstSlot) + + secondSlot.installContentView(hostedView) + XCTAssertTrue(hostedView.superview === secondSlot) + + firstSlot.clearContentView() + XCTAssertTrue(hostedView.superview === secondSlot) + } + + @MainActor + func testViewportHostApplySynchronizesSlotGeometryBeforeMount() { + _ = NSApplication.shared + + let workspace = Workspace() + guard let panelId = workspace.focusedPanelId, + let panel = workspace.terminalPanel(for: panelId), + let paneId = workspace.paneId(forPanelId: panelId) else { + XCTFail("Expected focused terminal panel") + return + } + + let viewport = terminalViewport(paneId: paneId, surfaceId: panel.id) + let host = WorkspaceLayoutSurfaceViewportHostView( + mountIdentity: viewport.mountIdentity, + surfaceRegistry: workspace.surfaceRegistry, + debugCanvasName: "test" + ) + + host.apply( + snapshot: viewport, + surfaceRegistry: workspace.surfaceRegistry, + activeDropZone: nil + ) + + XCTAssertEqual(host.debugSlotBounds.size.width, viewport.frame.width, accuracy: 0.001) + XCTAssertEqual(host.debugSlotBounds.size.height, viewport.frame.height, accuracy: 0.001) + XCTAssertEqual(panel.hostedView.frame.width, viewport.frame.width, accuracy: 0.001) + XCTAssertEqual(panel.hostedView.frame.height, viewport.frame.height, accuracy: 0.001) + XCTAssertTrue(host.debugShowsRevealCover) + XCTAssertEqual(host.debugRevealPhase, .waitingForWindow) + } + +} + +@MainActor +final class TerminalViewportLifecycleControllerTests: XCTestCase { + private func facts( + visible: Bool, + windowed: Bool, + geometry: Bool, + runtime: Bool, + presentedFrame: Bool, + active: Bool + ) -> TerminalViewportLifecycleFacts { + TerminalViewportLifecycleFacts( + isVisibleInUI: visible, + isWindowed: windowed, + hasUsableGeometry: geometry, + hasRuntime: runtime, + hasPresentedFrame: presentedFrame, + isActive: active + ) + } + + func testVisibleLifecycleWaitsForWindowAndGeometryBeforeRuntimeCreation() { + let controller = TerminalViewportLifecycleController() + + XCTAssertEqual( + controller.reconcile( + facts: facts( + visible: true, + windowed: false, + geometry: false, + runtime: false, + presentedFrame: false, + active: false + ), + force: false + ), + TerminalViewportLifecycleUpdate( + phase: .mountedAwaitingWindow, + demand: .visible, + commands: [] + ) + ) + + XCTAssertEqual( + controller.reconcile( + facts: facts( + visible: true, + windowed: true, + geometry: false, + runtime: false, + presentedFrame: false, + active: false + ), + force: false + ), + TerminalViewportLifecycleUpdate( + phase: .mountedAwaitingGeometry, + demand: .visible, + commands: [] + ) + ) + + XCTAssertEqual( + controller.reconcile( + facts: facts( + visible: true, + windowed: true, + geometry: true, + runtime: false, + presentedFrame: false, + active: false + ), + force: false + ), + TerminalViewportLifecycleUpdate( + phase: .runtimeRealized, + demand: .visible, + commands: [.realizeRuntime, .synchronizeVisibleGeometry] + ) + ) + } + + func testAwaitingFirstFrameRequestsRedrawOnlyOnEntry() { + let controller = TerminalViewportLifecycleController() + let awaitingFirstFrameFacts = facts( + visible: true, + windowed: true, + geometry: true, + runtime: true, + presentedFrame: false, + active: false + ) + + XCTAssertEqual( + controller.reconcile(facts: awaitingFirstFrameFacts, force: false), + TerminalViewportLifecycleUpdate( + phase: .awaitingFirstFrame, + demand: .visible, + commands: [.synchronizeVisibleGeometry, .requestFirstFrame] + ) + ) + + XCTAssertEqual( + controller.reconcile(facts: awaitingFirstFrameFacts, force: false), + TerminalViewportLifecycleUpdate( + phase: .awaitingFirstFrame, + demand: .visible, + commands: [.synchronizeVisibleGeometry] + ) + ) + } + + func testVisibleFocusedPhaseResumesFocusOnlyAfterFramePresentation() { + let controller = TerminalViewportLifecycleController() + + XCTAssertEqual( + controller.reconcile( + facts: facts( + visible: true, + windowed: true, + geometry: true, + runtime: true, + presentedFrame: true, + active: false + ), + force: false + ), + TerminalViewportLifecycleUpdate( + phase: .visible, + demand: .visible, + commands: [.synchronizeVisibleGeometry] + ) + ) + + XCTAssertEqual( + controller.reconcile( + facts: facts( + visible: true, + windowed: true, + geometry: true, + runtime: true, + presentedFrame: true, + active: true + ), + force: false + ), + TerminalViewportLifecycleUpdate( + phase: .visibleFocused, + demand: .visible, + commands: [.synchronizeVisibleGeometry, .resumeFocus] + ) + ) + } + + func testBackgroundDemandPersistsUntilRuntimeCreationBecomesPossible() { + let controller = TerminalViewportLifecycleController() + controller.requestBackgroundRuntime() + + XCTAssertEqual( + controller.reconcile( + facts: facts( + visible: false, + windowed: false, + geometry: false, + runtime: false, + presentedFrame: false, + active: false + ), + force: false + ), + TerminalViewportLifecycleUpdate( + phase: .mountedAwaitingWindow, + demand: .background, + commands: [] + ) + ) + + XCTAssertEqual( + controller.reconcile( + facts: facts( + visible: false, + windowed: true, + geometry: false, + runtime: false, + presentedFrame: false, + active: false + ), + force: false + ), + TerminalViewportLifecycleUpdate( + phase: .mountedHidden, + demand: .background, + commands: [] + ) + ) + + XCTAssertEqual( + controller.reconcile( + facts: facts( + visible: false, + windowed: true, + geometry: true, + runtime: false, + presentedFrame: false, + active: false + ), + force: false + ), + TerminalViewportLifecycleUpdate( + phase: .runtimeRealized, + demand: .background, + commands: [.realizeRuntime] + ) + ) + } } diff --git a/cmuxTests/WorkspaceLayoutRootHostTests.swift b/cmuxTests/WorkspaceLayoutRootHostTests.swift new file mode 100644 index 000000000000..f9ee85e7600e --- /dev/null +++ b/cmuxTests/WorkspaceLayoutRootHostTests.swift @@ -0,0 +1,164 @@ +import XCTest +import AppKit + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class WorkspaceLayoutRootHostTests: XCTestCase { + private func placeholderPaneSnapshot(_ paneId: PaneID) -> WorkspaceLayoutPaneRenderSnapshot { + let content = WorkspacePaneContent.placeholder( + WorkspacePlaceholderPaneContent( + paneId: paneId, + onCreateTerminal: {}, + onCreateBrowser: {} + ) + ) + return WorkspaceLayoutPaneRenderSnapshot( + paneId: paneId, + chrome: WorkspaceLayoutPaneChromeSnapshot( + paneId: paneId, + tabs: [], + selectedTabId: nil, + isFocused: false, + showSplitButtons: false, + chromeRevision: 0 + ), + contentId: paneId.id, + content: content + ) + } + + private func renderSnapshot( + root: WorkspaceLayoutRenderNodeSnapshot, + localTabDrag: WorkspaceLayoutLocalDragSnapshot? = nil + ) -> WorkspaceLayoutRenderSnapshot { + WorkspaceLayoutRenderSnapshot( + presentation: WorkspaceLayoutPresentationSnapshot( + appearance: .default, + isInteractive: true, + isMinimalMode: false, + tabShortcutHintsEnabled: false, + localTabDrag: localTabDrag + ), + root: root, + viewports: [] + ) + } + + func testRootHostStaysVisibleWhenWorkspaceIsNotInputActive() throws { + let manager = TabManager() + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let visibleInactiveContext = WorkspaceLayoutRenderContext( + notificationStore: nil, + isWorkspaceVisible: true, + isWorkspaceInputActive: false, + isMinimalMode: false, + appearance: PanelAppearance( + dividerColor: .clear, + unfocusedOverlayNSColor: .clear, + unfocusedOverlayOpacity: 0 + ), + workspacePortalPriority: 0, + usesWorkspacePaneOverlay: false, + showSplitButtons: workspace.showsSplitButtons + ) + let renderSnapshot = workspace.makeLayoutRenderSnapshot(context: visibleInactiveContext) + let rootHost = WorkspaceLayoutRootHostView( + hostBridge: workspace.layoutInteractionHandlers, + renderSnapshot: renderSnapshot, + surfaceRegistry: workspace.surfaceRegistry + ) + + rootHost.isHidden = true + rootHost.update( + hostBridge: workspace.layoutInteractionHandlers, + renderSnapshot: renderSnapshot, + surfaceRegistry: workspace.surfaceRegistry + ) + + XCTAssertFalse(rootHost.isHidden) + WorkspaceLayoutNativeHost.dismantleNSView(rootHost, coordinator: ()) + } + + func testSnapshotLocalDragClearPropagatesToPaneHosts() throws { + let workspace = try XCTUnwrap(TabManager().selectedWorkspace) + let paneId = PaneID() + let activeDrag = WorkspaceLayoutLocalDragSnapshot( + tabId: TabID(id: UUID()), + sourcePaneId: paneId + ) + let root = WorkspaceLayoutRenderNodeSnapshot.pane(placeholderPaneSnapshot(paneId)) + let activeSnapshot = renderSnapshot(root: root, localTabDrag: activeDrag) + let rootHost = WorkspaceLayoutRootHostView( + hostBridge: workspace.layoutInteractionHandlers, + renderSnapshot: activeSnapshot, + surfaceRegistry: workspace.surfaceRegistry + ) + let paneHost = try XCTUnwrap(rootHost.paneHosts[paneId.id]) + XCTAssertEqual(rootHost.activeLocalTabDrag, activeDrag) + XCTAssertEqual(paneHost.localTabDrag, activeDrag) + + rootHost.update( + hostBridge: workspace.layoutInteractionHandlers, + renderSnapshot: renderSnapshot(root: root), + surfaceRegistry: workspace.surfaceRegistry + ) + + XCTAssertNil(rootHost.activeLocalTabDrag) + XCTAssertNil(paneHost.localTabDrag) + WorkspaceLayoutNativeHost.dismantleNSView(rootHost, coordinator: ()) + } + + func testStaleSplitHostDoesNotReparentCurrentChildren() throws { + let workspace = try XCTUnwrap(TabManager().selectedWorkspace) + let leftPaneId = PaneID() + let rightPaneId = PaneID() + let splitSnapshot = WorkspaceLayoutSplitRenderSnapshot( + splitId: UUID(), + orientation: .horizontal, + dividerPosition: 0.5, + animationOrigin: nil, + first: .pane(placeholderPaneSnapshot(leftPaneId)), + second: .pane(placeholderPaneSnapshot(rightPaneId)) + ) + let rootSnapshot = renderSnapshot(root: .split(splitSnapshot)) + let rootHost = WorkspaceLayoutRootHostView( + hostBridge: workspace.layoutInteractionHandlers, + renderSnapshot: rootSnapshot, + surfaceRegistry: workspace.surfaceRegistry + ) + XCTAssertNotNil(rootHost.splitHosts[splitSnapshot.splitId]) + + let staleHost = WorkspaceLayoutNativeSplitView( + snapshot: splitSnapshot, + hostBridge: workspace.layoutInteractionHandlers, + rootHost: rootHost, + firstChild: NSView(), + secondChild: NSView(), + appearance: rootSnapshot.presentation.appearance + ) + let currentOwner = NSView() + let firstChild = NSView() + let secondChild = NSView() + currentOwner.addSubview(firstChild) + currentOwner.addSubview(secondChild) + + staleHost.update( + snapshot: splitSnapshot, + hostBridge: workspace.layoutInteractionHandlers, + rootHost: rootHost, + firstChild: firstChild, + secondChild: secondChild, + appearance: rootSnapshot.presentation.appearance + ) + + XCTAssertTrue(firstChild.superview === currentOwner) + XCTAssertTrue(secondChild.superview === currentOwner) + staleHost.removeAllChildren() + WorkspaceLayoutNativeHost.dismantleNSView(rootHost, coordinator: ()) + } +} diff --git a/cmuxTests/WorkspacePullRequestSidebarTests.swift b/cmuxTests/WorkspacePullRequestSidebarTests.swift index ca2fa03e7229..eb1ea78e09bb 100644 --- a/cmuxTests/WorkspacePullRequestSidebarTests.swift +++ b/cmuxTests/WorkspacePullRequestSidebarTests.swift @@ -6,23 +6,6 @@ import XCTest @testable import cmux #endif -private final class CommandRunnerInvocationCounter: @unchecked Sendable { - private let lock = NSLock() - private var storedValue = 0 - - func increment() { - lock.lock() - storedValue += 1 - lock.unlock() - } - - var value: Int { - lock.lock() - defer { lock.unlock() } - return storedValue - } -} - @MainActor final class WorkspacePullRequestSidebarTests: XCTestCase { func testSidebarPullRequestsIgnoreStaleWorkspaceLevelCacheWithoutPanelState() throws { @@ -47,8 +30,9 @@ final class WorkspacePullRequestSidebarTests: XCTestCase { let panelId = UUID() let staleURL = try XCTUnwrap(URL(string: "https://github.com/manaflow-ai/cmux/pull/1640")) - workspace.panelGitBranches[panelId] = SidebarGitBranchState(branch: "main", isDirty: false) - workspace.panelPullRequests[panelId] = SidebarPullRequestState( + workspace.updatePanelGitBranch(panelId: panelId, branch: "main", isDirty: false) + workspace.updatePanelPullRequest( + panelId: panelId, number: 1640, label: "PR", url: staleURL, @@ -65,9 +49,10 @@ final class WorkspacePullRequestSidebarTests: XCTestCase { let secondPanelId = UUID() let url = try XCTUnwrap(URL(string: "https://github.com/manaflow-ai/cmux/pull/1640")) - workspace.panelGitBranches[firstPanelId] = SidebarGitBranchState(branch: "feature/work", isDirty: false) - workspace.panelGitBranches[secondPanelId] = SidebarGitBranchState(branch: "feature/work", isDirty: false) - workspace.panelPullRequests[firstPanelId] = SidebarPullRequestState( + workspace.updatePanelGitBranch(panelId: firstPanelId, branch: "feature/work", isDirty: false) + workspace.updatePanelGitBranch(panelId: secondPanelId, branch: "feature/work", isDirty: false) + workspace.updatePanelPullRequest( + panelId: firstPanelId, number: 1640, label: "PR", url: url, @@ -75,7 +60,8 @@ final class WorkspacePullRequestSidebarTests: XCTestCase { branch: "feature/work", isStale: true ) - workspace.panelPullRequests[secondPanelId] = SidebarPullRequestState( + workspace.updatePanelPullRequest( + panelId: secondPanelId, number: 1640, label: "PR", url: url, @@ -96,87 +82,4 @@ final class WorkspacePullRequestSidebarTests: XCTestCase { ] ) } - - func testPullRequestRefreshRepositoryDiscoveryDoesNotBlockMainRunLoop() throws { - let invocationCounter = CommandRunnerInvocationCounter() - let commandDelay: TimeInterval = 0.03 - TabManager.commandRunnerForTesting = { _, executable, arguments, _ in - if executable == "git", arguments == ["remote", "-v"] { - invocationCounter.increment() - Thread.sleep(forTimeInterval: commandDelay) - return TabManager.CommandResult( - stdout: "origin\tssh://example.invalid/not-github.git (fetch)\n", - stderr: "", - exitStatus: 0, - timedOut: false, - executionError: nil - ) - } - return TabManager.CommandResult( - stdout: "", - stderr: "", - exitStatus: 0, - timedOut: false, - executionError: nil - ) - } - defer { - TabManager.commandRunnerForTesting = nil - } - - let manager = TabManager() - var seededPanels: [(workspaceId: UUID, panelId: UUID)] = [] - let workspaceCount = 45 - var workspaces = manager.tabs - while workspaces.count < workspaceCount { - workspaces.append(manager.addWorkspace(select: false, eagerLoadTerminal: false)) - } - - for (index, workspace) in workspaces.enumerated() { - let panelId = try XCTUnwrap(workspace.focusedPanelId) - workspace.updatePanelDirectory( - panelId: panelId, - directory: "/tmp/cmux-pr-refresh-main-thread-\(index)" - ) - workspace.updatePanelGitBranch( - panelId: panelId, - branch: "issue-3033-\(index)", - isDirty: false - ) - seededPanels.append((workspace.id, panelId)) - } - - let monitorDuration: TimeInterval = 0.7 - let allowedMainThreadGap: TimeInterval = 0.25 - let finishedMonitoring = expectation(description: "main run loop remained responsive") - let monitorStartedAt = Date() - var lastTickAt = monitorStartedAt - var maxTickGap: TimeInterval = 0 - let timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in - let now = Date() - maxTickGap = max(maxTickGap, now.timeIntervalSince(lastTickAt)) - lastTickAt = now - if now.timeIntervalSince(monitorStartedAt) >= monitorDuration { - timer.invalidate() - finishedMonitoring.fulfill() - } - } - - let triggerPanel = try XCTUnwrap(seededPanels.first) - manager.updateSurfaceShellActivity( - tabId: triggerPanel.workspaceId, - surfaceId: triggerPanel.panelId, - state: .promptIdle - ) - - let result = XCTWaiter().wait(for: [finishedMonitoring], timeout: monitorDuration + 1.5) - timer.invalidate() - XCTAssertEqual(result, .completed) - XCTAssertGreaterThan(invocationCounter.value, 0) - XCTAssertLessThan( - maxTickGap, - allowedMainThreadGap, - "Pull request refresh blocked the main run loop for \(maxTickGap) seconds" - ) - } } diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 67981d85b358..f1bf8400d9fd 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -852,7 +852,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { workspace.configureRemoteConnection(config, autoConnect: false) - let paneID = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) + let paneID = try XCTUnwrap(workspace.paneIds.first) let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) let detached = try XCTUnwrap(workspace.detachSurface(panelId: panelID)) @@ -903,7 +903,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { let panelID = try XCTUnwrap(sourceWorkspace.focusedTerminalPanel?.id) let detached = try XCTUnwrap(sourceWorkspace.detachSurface(panelId: panelID)) - let destinationPaneID = try XCTUnwrap(destinationWorkspace.bonsplitController.allPaneIds.first) + let destinationPaneID = try XCTUnwrap(destinationWorkspace.paneIds.first) let restoredPanelID = destinationWorkspace.attachDetachedSurface( detached, @@ -928,7 +928,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { let manager = TabManager() let sourceWorkspace = try XCTUnwrap(manager.selectedWorkspace) let destinationWorkspace = manager.addWorkspace() - let sourcePaneID = try XCTUnwrap(sourceWorkspace.bonsplitController.allPaneIds.first) + let sourcePaneID = try XCTUnwrap(sourceWorkspace.paneIds.first) let config = WorkspaceRemoteConfiguration( destination: "cmux-macmini", port: nil, @@ -954,11 +954,11 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } sourceWorkspace.configureRemoteConnection(config, autoConnect: false) - _ = sourceWorkspace.newBrowserSurface(inPane: sourcePaneID, url: URL(string: "https://example.com"), focus: false) + _ = sourceWorkspace.createBrowserPanel(inPane: sourcePaneID, url: URL(string: "https://example.com"), focus: false) let panelID = try XCTUnwrap(sourceWorkspace.focusedTerminalPanel?.id) let detached = try XCTUnwrap(sourceWorkspace.detachSurface(panelId: panelID)) - let destinationPaneID = try XCTUnwrap(destinationWorkspace.bonsplitController.allPaneIds.first) + let destinationPaneID = try XCTUnwrap(destinationWorkspace.paneIds.first) let restoredPanelID = destinationWorkspace.attachDetachedSurface( detached, @@ -1012,7 +1012,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { let panelID = try XCTUnwrap(sourceWorkspace.focusedTerminalPanel?.id) let detached = try XCTUnwrap(sourceWorkspace.detachSurface(panelId: panelID)) - let destinationPaneID = try XCTUnwrap(destinationWorkspace.bonsplitController.allPaneIds.first) + let destinationPaneID = try XCTUnwrap(destinationWorkspace.paneIds.first) let restoredPanelID = destinationWorkspace.attachDetachedSurface( detached, @@ -1036,7 +1036,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { @MainActor func testRemoteTerminalSessionEndSkipsControlMasterCleanupWhenBrowserPanelsKeepWorkspaceRemote() throws { let workspace = Workspace() - let paneID = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) + let paneID = try XCTUnwrap(workspace.paneIds.first) let initialTerminalID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) let config = WorkspaceRemoteConfiguration( destination: "cmux-macmini", @@ -1063,7 +1063,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } workspace.configureRemoteConnection(config, autoConnect: false) - _ = workspace.newBrowserSurface(inPane: paneID, url: URL(string: "https://example.com"), focus: false) + _ = workspace.createBrowserPanel(inPane: paneID, url: URL(string: "https://example.com"), focus: false) workspace.markRemoteTerminalSessionEnded(surfaceId: initialTerminalID, relayPort: 64013) @@ -1103,7 +1103,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { let originalPanelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) let originalPaneID = try XCTUnwrap(workspace.paneId(forPanelId: originalPanelID)) let movedPanel = try XCTUnwrap( - workspace.newTerminalSplit(from: originalPanelID, orientation: .horizontal) + workspace.splitTerminalPanel(fromPanelId: originalPanelID, orientation: .horizontal) ) XCTAssertTrue(workspace.isRemoteTerminalSurface(originalPanelID)) @@ -1130,11 +1130,11 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { let panelID = try XCTUnwrap(source.focusedTerminalPanel?.id) let sourcePaneID = try XCTUnwrap(source.paneId(forPanelId: panelID)) - let destinationPaneID = try XCTUnwrap(destination.bonsplitController.allPaneIds.first) - source.surfaceTTYNames[panelID] = "/dev/ttys004" + let destinationPaneID = try XCTUnwrap(destination.paneIds.first) + source.setSurfaceTTYName(panelId: panelID, ttyName: "/dev/ttys004") let detached = try XCTUnwrap(source.detachSurface(panelId: panelID)) - XCTAssertEqual(source.surfaceTTYNames[panelID], nil) + XCTAssertEqual(source.surfaceTTYName(panelId: panelID), nil) let restoredPanelID = destination.attachDetachedSurface( detached, @@ -1143,8 +1143,8 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { ) XCTAssertEqual(restoredPanelID, panelID) - XCTAssertEqual(destination.surfaceTTYNames[panelID], "/dev/ttys004") - XCTAssertEqual(source.bonsplitController.tabs(inPane: sourcePaneID).count, 0) + XCTAssertEqual(destination.surfaceTTYName(panelId: panelID), "/dev/ttys004") + XCTAssertEqual(source.surfaceIds(inPane: sourcePaneID).count, 0) } func testDetectedSSHUploadFailureCleansUpEarlierRemoteUploads() throws { @@ -1639,133 +1639,6 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } - func testOpenCodeInstallHooksRegistersSessionPlugin() throws { - let cliPath = try bundledCLIPath() - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-opencode-hooks-\(UUID().uuidString)", isDirectory: true) - let configDir = root.appendingPathComponent("opencode", isDirectory: true) - try FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let configURL = configDir.appendingPathComponent("opencode.json", isDirectory: false) - try """ - { - "plugin": [ - "other-plugin", - "./plugins/cmux-session.js" - ] - } - """.write(to: configURL, atomically: true, encoding: .utf8) - - var environment = ProcessInfo.processInfo.environment - environment["OPENCODE_CONFIG_DIR"] = configDir.path - environment["CMUX_CLI_SENTRY_DISABLED"] = "1" - let result = runProcess( - executablePath: cliPath, - arguments: ["opencode", "install-hooks", "--yes"], - environment: environment, - timeout: 5 - ) - - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - let pluginURL = configDir - .appendingPathComponent("plugins", isDirectory: true) - .appendingPathComponent("cmux-session.js", isDirectory: false) - let pluginSource = try String(contentsOf: pluginURL, encoding: .utf8) - XCTAssertTrue(pluginSource.contains("cmux-opencode-session-plugin-marker")) - XCTAssertTrue(pluginSource.contains("\"opencode-hook\"")) - - let data = try Data(contentsOf: configURL) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]) - let plugins = try XCTUnwrap(json["plugin"] as? [String]) - XCTAssertEqual(plugins, ["other-plugin", "cmux-session"]) - } - - func testAgentHookLaunchEnvironmentDoesNotPersistPathOrShell() throws { - let cliPath = try bundledCLIPath() - let socketPath = makeSocketPath("hook") - let listenerFD = try bindUnixSocket(at: socketPath) - let state = MockSocketServerState() - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-agent-hook-\(UUID().uuidString)", isDirectory: true) - let workspaceId = "11111111-1111-1111-1111-111111111111" - let surfaceId = "22222222-2222-2222-2222-222222222222" - - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { - Darwin.close(listenerFD) - unlink(socketPath) - try? FileManager.default.removeItem(at: root) - } - - let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in - return self.v2Response( - id: line, - ok: false, - error: ["code": "unexpected", "message": "Unexpected command \(line)"] - ) - } - - var environment = ProcessInfo.processInfo.environment - for key in [ - "ANTHROPIC_MODEL", - "CLAUDE_CONFIG_DIR", - "CMUX_CUSTOM_CLAUDE_PATH", - "NODE_OPTIONS", - "OPENCODE_CONFIG_DIR" - ] { - environment.removeValue(forKey: key) - } - environment["CMUX_SOCKET_PATH"] = socketPath - environment["CMUX_WORKSPACE_ID"] = workspaceId - environment["CMUX_SURFACE_ID"] = surfaceId - environment["CMUX_AGENT_HOOK_STATE_DIR"] = root.path - environment["CMUX_AGENT_LAUNCH_KIND"] = "codex" - environment["CMUX_AGENT_LAUNCH_EXECUTABLE"] = "/usr/local/bin/codex" - environment["CMUX_AGENT_LAUNCH_ARGV_B64"] = base64NULSeparated([ - "/usr/local/bin/codex", - "--model", - "gpt-5.4", - "old prompt" - ]) - environment["CMUX_AGENT_LAUNCH_CWD"] = "/tmp/repo" - environment["CODEX_HOME"] = "/tmp/codex home" - environment["PATH"] = "/tmp/custom-bin:/usr/bin" - environment["SHELL"] = "/bin/zsh" - environment["CMUX_CLI_SENTRY_DISABLED"] = "1" - - let result = runProcess( - executablePath: cliPath, - arguments: ["codex-hook", "session-start"], - environment: environment, - timeout: 5 - ) - - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "{}\n") - - let storeURL = root.appendingPathComponent("codex-hook-sessions.json", isDirectory: false) - let data = try Data(contentsOf: storeURL) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]) - let sessions = try XCTUnwrap(json["sessions"] as? [String: Any]) - let session = try XCTUnwrap(sessions[surfaceId] as? [String: Any]) - let launchCommand = try XCTUnwrap(session["launchCommand"] as? [String: Any]) - let persistedEnvironment = try XCTUnwrap(launchCommand["environment"] as? [String: String]) - XCTAssertEqual(persistedEnvironment, ["CODEX_HOME": "/tmp/codex home"]) - } - - private func base64NULSeparated(_ values: [String]) -> String { - var data = Data() - for value in values { - data.append(contentsOf: value.utf8) - data.append(0) - } - return data.base64EncodedString() - } - private func bindUnixSocket(at path: String) throws -> Int32 { unlink(path) diff --git a/cmuxTests/WorkspaceStressProfileTests.swift b/cmuxTests/WorkspaceStressProfileTests.swift index bebb48d9ae56..55c37e94015b 100644 --- a/cmuxTests/WorkspaceStressProfileTests.swift +++ b/cmuxTests/WorkspaceStressProfileTests.swift @@ -84,7 +84,6 @@ final class WorkspaceStressProfileTests: XCTestCase { var switchSamples: [TimedSample] = [] var switchDispatchSamples: [TimedSample] = [] var switchFirstDrainSamples: [TimedSample] = [] - var switchUnfocusSamples: [TimedSample] = [] var switchSecondDrainSamples: [TimedSample] = [] let manager = timed("workspace-000-create", collectInto: &creationSamples) { @@ -130,9 +129,6 @@ final class WorkspaceStressProfileTests: XCTestCase { timed("pass-\(label(for: pass))-next-drain1-\(label(for: switchIndex))", collectInto: &switchFirstDrainSamples) { drainMainQueue() } - timed("pass-\(label(for: pass))-next-unfocus-\(label(for: switchIndex))", collectInto: &switchUnfocusSamples) { - manager.completePendingWorkspaceUnfocus(reason: "workspace_stress_profile") - } timed("pass-\(label(for: pass))-next-drain2-\(label(for: switchIndex))", collectInto: &switchSecondDrainSamples) { drainMainQueue() } @@ -147,9 +143,6 @@ final class WorkspaceStressProfileTests: XCTestCase { timed("pass-\(label(for: pass))-prev-drain1-\(label(for: switchIndex))", collectInto: &switchFirstDrainSamples) { drainMainQueue() } - timed("pass-\(label(for: pass))-prev-unfocus-\(label(for: switchIndex))", collectInto: &switchUnfocusSamples) { - manager.completePendingWorkspaceUnfocus(reason: "workspace_stress_profile") - } timed("pass-\(label(for: pass))-prev-drain2-\(label(for: switchIndex))", collectInto: &switchSecondDrainSamples) { drainMainQueue() } @@ -164,7 +157,6 @@ final class WorkspaceStressProfileTests: XCTestCase { let switchSummary = TimingSummary(samples: switchSamples) let switchDispatchSummary = TimingSummary(samples: switchDispatchSamples) let switchFirstDrainSummary = TimingSummary(samples: switchFirstDrainSamples) - let switchUnfocusSummary = TimingSummary(samples: switchUnfocusSamples) let switchSecondDrainSummary = TimingSummary(samples: switchSecondDrainSamples) let report = [ @@ -174,7 +166,6 @@ final class WorkspaceStressProfileTests: XCTestCase { reportLine(title: "switch", summary: switchSummary, slowest: slowest(switchSamples)), reportLine(title: "switch.dispatch", summary: switchDispatchSummary, slowest: slowest(switchDispatchSamples)), reportLine(title: "switch.drain1", summary: switchFirstDrainSummary, slowest: slowest(switchFirstDrainSamples)), - reportLine(title: "switch.unfocus", summary: switchUnfocusSummary, slowest: slowest(switchUnfocusSamples)), reportLine(title: "switch.drain2", summary: switchSecondDrainSummary, slowest: slowest(switchSecondDrainSamples)) ].joined(separator: "\n") @@ -213,7 +204,6 @@ final class WorkspaceStressProfileTests: XCTestCase { private func settleWorkspaceSelection(_ manager: TabManager) { drainMainQueue() - manager.completePendingWorkspaceUnfocus(reason: "workspace_stress_profile") drainMainQueue() } diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 322fea81d988..42eee41f422e 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -4,7 +4,6 @@ import SwiftUI import UniformTypeIdentifiers import WebKit import ObjectiveC.runtime -import Bonsplit import UserNotifications import Combine @@ -59,141 +58,95 @@ final class SidebarSelectedWorkspaceColorTests: XCTestCase { XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001) XCTAssertEqual(color.alphaComponent, 0.65, accuracy: 0.001) } +} - @MainActor - func testSolidFillKeepsSelectedBackgroundForActiveCustomColoredWorkspaceRow() { - let manager = TabManager() - guard let workspace = manager.tabs.first else { - XCTFail("Expected TabManager to initialise with a workspace") - return - } - - var observedSidebarInvalidation = false - let cancellable = workspace.sidebarImmediateObservationPublisher.sink { - observedSidebarInvalidation = true - } - - manager.setTabColor(tabId: workspace.id, color: "#C0392B") - - XCTAssertEqual(workspace.customColor, "#C0392B") - XCTAssertTrue(observedSidebarInvalidation) - let background = sidebarWorkspaceRowBackgroundStyle( - activeTabIndicatorStyle: .solidFill, - isActive: true, - isMultiSelected: false, - customColorHex: workspace.customColor, - colorScheme: .light, - sidebarSelectionColorHex: nil +@MainActor +final class WorkspaceSplitPolicyTests: XCTestCase { + func testCloseTabHonorsDisabledClosePolicy() throws { + let controller = WorkspaceLayoutController( + configuration: WorkspaceLayoutConfiguration(allowCloseTabs: false) ) + let tab = try XCTUnwrap(controller.createTab(title: "One")) - XCTAssertEqual( - background.color?.hexString(), - sidebarSelectedWorkspaceBackgroundNSColor(for: .light).hexString() - ) - XCTAssertEqual(background.opacity, 1.0, accuracy: 0.001) - withExtendedLifetime(cancellable) {} + XCTAssertFalse(controller.closeTab(tab)) + XCTAssertNotNil(controller.rootNode.findTab(tab)) } - @MainActor - func testLeftRailKeepsSelectedBackgroundForActiveCustomColoredWorkspaceRow() { - let manager = TabManager() - guard let workspace = manager.tabs.first else { - XCTFail("Expected TabManager to initialise with a workspace") - return - } - - var observedSidebarInvalidation = false - let cancellable = workspace.sidebarImmediateObservationPublisher.sink { - observedSidebarInvalidation = true - } - - manager.setTabColor(tabId: workspace.id, color: "#C0392B") - - XCTAssertEqual(workspace.customColor, "#C0392B") - XCTAssertTrue(observedSidebarInvalidation) - - let background = sidebarWorkspaceRowBackgroundStyle( - activeTabIndicatorStyle: .leftRail, - isActive: true, - isMultiSelected: false, - customColorHex: workspace.customColor, - colorScheme: .light, - sidebarSelectionColorHex: nil + func testSamePaneMoveHonorsDisabledReorderPolicy() throws { + let controller = WorkspaceLayoutController( + configuration: WorkspaceLayoutConfiguration(allowTabReordering: false) ) + let first = try XCTUnwrap(controller.createTab(title: "One")) + let second = try XCTUnwrap(controller.createTab(title: "Two")) + let paneId = try XCTUnwrap(controller.focusedPaneId) - XCTAssertEqual( - background.color?.hexString(), - sidebarSelectedWorkspaceBackgroundNSColor(for: .light).hexString() - ) - XCTAssertEqual(background.opacity, 1.0, accuracy: 0.001) - withExtendedLifetime(cancellable) {} + XCTAssertEqual(controller.rootNode.findPane(paneId)?.tabIds, [first.id, second.id]) + XCTAssertFalse(controller.reorderTab(first, toIndex: 2)) + XCTAssertFalse(controller.moveTab(first, toPane: paneId, atIndex: 2)) + XCTAssertEqual(controller.rootNode.findPane(paneId)?.tabIds, [first.id, second.id]) } - @MainActor - func testLeftRailLeavesInactiveCustomColoredWorkspaceRowTransparent() { - let manager = TabManager() - guard let workspace = manager.tabs.first else { - XCTFail("Expected TabManager to initialise with a workspace") - return - } - - manager.setTabColor(tabId: workspace.id, color: "#C0392B") - - let background = sidebarWorkspaceRowBackgroundStyle( - activeTabIndicatorStyle: .leftRail, - isActive: false, - isMultiSelected: false, - customColorHex: workspace.customColor, - colorScheme: .light, - sidebarSelectionColorHex: nil + func testCrossPaneMoveHonorsDisabledCrossPanePolicy() throws { + let controller = WorkspaceLayoutController( + configuration: WorkspaceLayoutConfiguration(allowCrossPaneTabMove: false) + ) + let tab = try XCTUnwrap(controller.createTab(title: "One")) + let sourcePaneId = try XCTUnwrap(controller.focusedPaneId) + let targetPaneId = try XCTUnwrap( + controller.splitPane(sourcePaneId, orientation: .horizontal) ) - XCTAssertNil(background.color) - XCTAssertEqual(background.opacity, 0, accuracy: 0.001) + XCTAssertFalse(controller.moveTab(tab, toPane: targetPaneId)) + XCTAssertEqual(controller.rootNode.findPane(sourcePaneId)?.tabIds, [tab.id]) + XCTAssertEqual(controller.rootNode.findPane(targetPaneId)?.tabIds, []) } - @MainActor - func testLeftRailResolvesExplicitRailColorForCustomColoredWorkspaceRow() { - let manager = TabManager() - guard let workspace = manager.tabs.first else { - XCTFail("Expected TabManager to initialise with a workspace") - return - } - - manager.setTabColor(tabId: workspace.id, color: "#C0392B") - - let railColor = sidebarWorkspaceRowExplicitRailNSColor( - activeTabIndicatorStyle: .leftRail, - customColorHex: workspace.customColor, - colorScheme: .light + func testCrossPaneMovePreservesEmptySourcePaneWhenAutoCloseDisabled() throws { + let controller = WorkspaceLayoutController( + configuration: WorkspaceLayoutConfiguration(autoCloseEmptyPanes: false) + ) + let tab = try XCTUnwrap(controller.createTab(title: "One")) + let sourcePaneId = try XCTUnwrap(controller.focusedPaneId) + let targetPaneId = try XCTUnwrap( + controller.splitPane(sourcePaneId, orientation: .horizontal) ) - XCTAssertNotNil(railColor) - XCTAssertEqual(railColor?.hexString(), "#C0392B") + XCTAssertTrue(controller.moveTab(tab, toPane: targetPaneId)) + XCTAssertEqual(controller.rootNode.findPane(sourcePaneId)?.tabIds, []) + XCTAssertEqual(controller.rootNode.findPane(targetPaneId)?.tabIds, [tab.id]) + XCTAssertEqual(Set(controller.allPaneIds), Set([sourcePaneId, targetPaneId])) } - @MainActor - func testSolidFillUsesInactiveCustomWorkspaceColorAsBackground() { - let manager = TabManager() - guard let workspace = manager.tabs.first else { - XCTFail("Expected TabManager to initialise with a workspace") - return - } + func testCloseTabPreservesEmptyPaneWhenAutoCloseDisabled() throws { + let controller = WorkspaceLayoutController( + configuration: WorkspaceLayoutConfiguration(autoCloseEmptyPanes: false) + ) + let tab = try XCTUnwrap(controller.createTab(title: "One")) + let sourcePaneId = try XCTUnwrap(controller.focusedPaneId) + let targetPaneId = try XCTUnwrap( + controller.splitPane(sourcePaneId, orientation: .horizontal) + ) - manager.setTabColor(tabId: workspace.id, color: "#C0392B") + XCTAssertTrue(controller.closeTab(tab, inPane: sourcePaneId)) + XCTAssertEqual(controller.rootNode.findPane(sourcePaneId)?.tabIds, []) + XCTAssertNotNil(controller.rootNode.findPane(targetPaneId)) + XCTAssertEqual(Set(controller.allPaneIds), Set([sourcePaneId, targetPaneId])) + } - let background = sidebarWorkspaceRowBackgroundStyle( - activeTabIndicatorStyle: .solidFill, - isActive: false, - isMultiSelected: false, - customColorHex: workspace.customColor, - colorScheme: .light, - sidebarSelectionColorHex: nil + func testCloseLastPaneReplacesItWithEmptyPaneWhenAllowed() throws { + let controller = WorkspaceLayoutController( + configuration: WorkspaceLayoutConfiguration(allowCloseLastPane: true) ) + let originalPaneId = try XCTUnwrap(controller.focusedPaneId) + + XCTAssertTrue(controller.closePane(originalPaneId)) + XCTAssertNil(controller.rootNode.findPane(originalPaneId)) + XCTAssertEqual(controller.allPaneIds.count, 1) - XCTAssertEqual(background.color?.hexString(), "#C0392B") - XCTAssertEqual(background.opacity, 0.7, accuracy: 0.001) + let replacementPaneId = try XCTUnwrap(controller.focusedPaneId) + XCTAssertNotEqual(replacementPaneId, originalPaneId) + XCTAssertEqual(controller.rootNode.findPane(replacementPaneId)?.tabIds, []) } } @@ -437,7 +390,7 @@ final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { func testShortcutRecorderStopsRecordingWhenFirstStrokeConfirmationIsRejected() { #if DEBUG let button = ShortcutRecorderNSButton(frame: .zero) - button.transformRecordedShortcut = { _ in .rejected(.reservedBySystem) } + button.transformRecordedShortcut = { _ in nil } button.debugSetPendingChordStart( ShortcutStroke( key: "x", @@ -455,204 +408,12 @@ final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { XCTFail("Shortcut recorder debug hooks are only available in DEBUG") #endif } - - func testShortcutRecorderReportsFirstStrokeConflictImmediately() { -#if DEBUG - KeyboardShortcutSettings.resetAll() - defer { KeyboardShortcutSettings.resetAll() } - - let button = ShortcutRecorderNSButton(frame: .zero) - let conflictingShortcut = StoredShortcut( - key: "t", - command: true, - shift: false, - option: false, - control: false, - keyCode: 17 - ) - var rejectedAttempt: ShortcutRecorderRejectedAttempt? - - button.transformRecordedShortcut = { shortcut in - XCTAssertEqual(shortcut, conflictingShortcut) - return .rejected(.conflictsWithAction(.newSurface)) - } - button.onRecorderFeedbackChanged = { rejectedAttempt = $0 } - button.performClick(nil) - - guard let event = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.command], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: 0, - context: nil, - characters: "t", - charactersIgnoringModifiers: "t", - isARepeat: false, - keyCode: 17 - ) else { - XCTFail("Failed to construct Command-T event") - return - } - - XCTAssertNil(button.debugHandleRecordingEvent(event)) - XCTAssertEqual( - rejectedAttempt, - ShortcutRecorderRejectedAttempt( - reason: .conflictsWithAction(.newSurface), - proposedShortcut: conflictingShortcut - ) - ) - XCTAssertFalse(button.debugIsRecording) -#else - XCTFail("Shortcut recorder debug hooks are only available in DEBUG") -#endif - } - - func testShortcutRecorderCommitsAcceptedFirstStrokeImmediately() { -#if DEBUG - KeyboardShortcutSettings.resetAll() - defer { KeyboardShortcutSettings.resetAll() } - - let button = ShortcutRecorderNSButton(frame: .zero) - let recordedShortcut = StoredShortcut( - key: "l", - command: true, - shift: true, - option: false, - control: false, - keyCode: 37 - ) - var committedShortcut: StoredShortcut? - var feedbackEvents: [ShortcutRecorderRejectedAttempt?] = [] - - button.transformRecordedShortcut = { shortcut in - XCTAssertEqual(shortcut, recordedShortcut) - return .accepted(shortcut) - } - button.onShortcutRecorded = { committedShortcut = $0 } - button.onRecorderFeedbackChanged = { feedbackEvents.append($0) } - button.performClick(nil) - - guard let event = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.command, .shift], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: 0, - context: nil, - characters: "L", - charactersIgnoringModifiers: "l", - isARepeat: false, - keyCode: 37 - ) else { - XCTFail("Failed to construct Command-Shift-L event") - return - } - - XCTAssertNil(button.debugHandleRecordingEvent(event)) - XCTAssertEqual(committedShortcut, recordedShortcut) - XCTAssertEqual(button.shortcut, recordedShortcut) - XCTAssertFalse(button.debugIsRecording) - XCTAssertTrue(feedbackEvents.contains { $0 == nil }) -#else - XCTFail("Shortcut recorder debug hooks are only available in DEBUG") -#endif - } - - func testShortcutRecorderCapturesKeyEquivalentWhileRecording() { -#if DEBUG - KeyboardShortcutSettings.resetAll() - defer { KeyboardShortcutSettings.resetAll() } - - let button = ShortcutRecorderNSButton(frame: .zero) - let recordedShortcut = StoredShortcut( - key: "t", - command: true, - shift: false, - option: false, - control: false, - keyCode: 17 - ) - var committedShortcut: StoredShortcut? - - button.transformRecordedShortcut = { shortcut in - XCTAssertEqual(shortcut, recordedShortcut) - return .accepted(shortcut) - } - button.onShortcutRecorded = { committedShortcut = $0 } - button.performClick(nil) - - guard let event = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.command], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: 0, - context: nil, - characters: "t", - charactersIgnoringModifiers: "t", - isARepeat: false, - keyCode: 17 - ) else { - XCTFail("Failed to construct Command-T event") - return - } - - XCTAssertTrue(button.performKeyEquivalent(with: event)) - XCTAssertEqual(committedShortcut, recordedShortcut) - XCTAssertFalse(button.debugIsRecording) -#else - XCTFail("Shortcut recorder debug hooks are only available in DEBUG") -#endif - } - - func testShortcutRecorderStopAllNotificationStopsActiveRecorder() { -#if DEBUG - let button = ShortcutRecorderNSButton(frame: .zero) - button.debugSetPendingChordStart( - ShortcutStroke( - key: "l", - command: true, - shift: false, - option: false, - control: false - ) - ) - - KeyboardShortcutRecorderActivity.stopAllRecording() - - XCTAssertFalse(button.debugIsRecording) -#else - XCTFail("Shortcut recorder debug hooks are only available in DEBUG") -#endif - } } final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { private var originalSettingsFileStore: KeyboardShortcutSettingsFileStore! private let settingsFileBackupsDefaultsKey = "cmux.settingsFile.backups.v1" - func testShortcutConfigStringCanonicalizesNumberedDigitsWhenRequested() { - let stroke = ShortcutStroke( - key: "7", - command: true, - shift: false, - option: false, - control: false - ) - - XCTAssertEqual(stroke.configString(), "cmd+7") - XCTAssertEqual(stroke.configString(preserveDigit: false), "cmd+1") - } - - func testShortcutConfigParsingRoundTripsFunctionAndMediaKeys() { - XCTAssertEqual(ShortcutStroke.parseConfig("cmd+f5")?.key, "f5") - XCTAssertEqual(ShortcutStroke.parseConfig("cmd+media.playPause")?.key, "media.playPause") - XCTAssertEqual(ShortcutStroke.parseConfig("cmd+playPause")?.key, "media.playPause") - XCTAssertNil(ShortcutStroke.parseConfig("cmd+f21")) - } - override func setUp() { super.setUp() originalSettingsFileStore = KeyboardShortcutSettings.settingsFileStore @@ -705,54 +466,6 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { XCTAssertEqual(store.activeSourcePath, settingsFileURL.path) } - func testSettingsFileStoreParsesRightSidebarShortcutBindings() throws { - let directoryURL = try makeTemporaryDirectory() - defer { try? FileManager.default.removeItem(at: directoryURL) } - - let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) - try writeSettingsFile( - """ - { - "shortcuts": { - "focusRightSidebar": "cmd+opt+shift+e", - "switchRightSidebarToFiles": "ctrl+4", - "switchRightSidebarToFind": "ctrl+5", - "switchRightSidebarToSessions": "ctrl+6", - "switchRightSidebarToFeed": "ctrl+7" - } - } - """, - to: settingsFileURL - ) - - let store = KeyboardShortcutSettingsFileStore( - primaryPath: settingsFileURL.path, - fallbackPath: nil, - startWatching: false - ) - - XCTAssertEqual( - store.override(for: .focusRightSidebar), - StoredShortcut(key: "e", command: true, shift: true, option: true, control: false) - ) - XCTAssertEqual( - store.override(for: .switchRightSidebarToFiles), - StoredShortcut(key: "4", command: false, shift: false, option: false, control: true) - ) - XCTAssertEqual( - store.override(for: .switchRightSidebarToFind), - StoredShortcut(key: "5", command: false, shift: false, option: false, control: true) - ) - XCTAssertEqual( - store.override(for: .switchRightSidebarToSessions), - StoredShortcut(key: "6", command: false, shift: false, option: false, control: true) - ) - XCTAssertEqual( - store.override(for: .switchRightSidebarToFeed), - StoredShortcut(key: "7", command: false, shift: false, option: false, control: true) - ) - } - func testSettingsFileStoreDoesNotApplyAutomaticAppIconDuringStartupReplay() throws { let defaults = UserDefaults.standard let previousMode = defaults.object(forKey: AppIconSettings.modeKey) @@ -1683,25 +1396,6 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { } final class StoredShortcutMatchingTests: XCTestCase { - private func makeMediaKeyEvent( - keyCode: UInt16, - modifierFlags: NSEvent.ModifierFlags = [], - keyState: UInt8 = 0x0A - ) -> NSEvent? { - let data1 = Int((UInt32(keyCode) << 16) | (UInt32(keyState) << 8)) - return NSEvent.otherEvent( - with: .systemDefined, - location: .zero, - modifierFlags: modifierFlags, - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: 0, - context: nil, - subtype: Int16(8), - data1: data1, - data2: -1 - ) - } - func testMatchingIgnoresCapsLock() { let shortcut = StoredShortcut(key: "q", command: true, shift: false, option: false, control: false) @@ -1799,312 +1493,29 @@ final class StoredShortcutMatchingTests: XCTestCase { XCTAssertEqual(stroke.carbonHotKeyRegistration?.keyCode, 13) } - func testShortcutRecordingResultRejectsBareLetterWithoutModifier() { - guard let event = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: 0, - context: nil, - characters: "a", - charactersIgnoringModifiers: "a", - isARepeat: false, - keyCode: 0 - ) else { - XCTFail("Failed to construct bare letter event") - return - } + func testSystemWideHotkeyNormalizationRejectsReservedShortcutByRecordedPhysicalKey() { + KeyboardShortcutSettings.resetAll() + defer { KeyboardShortcutSettings.resetAll() } - XCTAssertEqual( - ShortcutStroke.recordingResult(from: event, requireModifier: true), - .rejected(.bareKeyNotAllowed) + let shortcut = StoredShortcut( + key: "q", + command: true, + shift: false, + option: false, + control: false, + keyCode: 13 ) - } - func testShortcutRecordingResultAcceptsBareFunctionKeyWithoutModifier() { - let f1Characters = String(UnicodeScalar(NSF1FunctionKey)!) + XCTAssertNil(KeyboardShortcutSettings.Action.showHideAllWindows.normalizedRecordedShortcut(shortcut)) + } +} - guard let event = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: 0, - context: nil, - characters: f1Characters, - charactersIgnoringModifiers: f1Characters, - isARepeat: false, - keyCode: 122 - ) else { - XCTFail("Failed to construct F1 event") - return - } - XCTAssertEqual( - ShortcutStroke.recordingResult(from: event, requireModifier: true), - .accepted(ShortcutStroke(key: "f1", command: false, shift: false, option: false, control: false, keyCode: 122)) - ) - } - - func testShortcutRecordingResultSafelyIgnoresNonMediaSystemDefinedEvent() { - guard let event = NSEvent.otherEvent( - with: .systemDefined, - location: .zero, - modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: 0, - context: nil, - subtype: 0, - data1: 0, - data2: 0 - ) else { - XCTFail("Failed to construct non-media system-defined event") - return - } - - XCTAssertFalse(ShortcutStroke.isEscapeCancelEvent(event)) - XCTAssertEqual( - ShortcutStroke.recordingResult(from: event, requireModifier: true), - .unsupportedKey - ) - } - - func testMediaShortcutDoesNotMatchOrdinaryKeyDownWithSameKeyCode() { - let shortcut = ShortcutStroke( - key: "media.volumeUp", - command: false, - shift: false, - option: false, - control: false, - keyCode: 0 - ) - - guard let event = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: 0, - context: nil, - characters: "a", - charactersIgnoringModifiers: "a", - isARepeat: false, - keyCode: 0 - ) else { - XCTFail("Failed to construct A key event") - return - } - - XCTAssertFalse(shortcut.matches(event: event)) - } - - func testMediaShortcutMatchesSystemDefinedMediaEvent() { - let shortcut = ShortcutStroke( - key: "media.volumeUp", - command: false, - shift: false, - option: false, - control: false, - keyCode: 0 - ) - - guard let event = makeMediaKeyEvent(keyCode: 0) else { - XCTFail("Failed to construct media key event") - return - } - - XCTAssertTrue(shortcut.matches(event: event)) - } - - func testShortcutRecorderResolutionReportsConflictingAction() { - KeyboardShortcutSettings.resetAll() - defer { KeyboardShortcutSettings.resetAll() } - - let shortcut = StoredShortcut(key: "t", command: true, shift: false, option: false, control: false) - - XCTAssertEqual( - KeyboardShortcutSettings.Action.openBrowser.normalizedRecordedShortcutResult(shortcut), - .rejected(.conflictsWithAction(.newSurface)) - ) - } - - func testShortcutRecorderResolutionRejectsNumberedShortcutAgainstReservedDigitFamily() { - KeyboardShortcutSettings.resetAll() - defer { KeyboardShortcutSettings.resetAll() } - - KeyboardShortcutSettings.setShortcut( - StoredShortcut(key: "3", command: true, shift: false, option: false, control: false), - for: .openBrowser - ) - - let shortcut = StoredShortcut(key: "2", command: true, shift: false, option: false, control: false) - - XCTAssertEqual( - KeyboardShortcutSettings.Action.selectWorkspaceByNumber.normalizedRecordedShortcutResult(shortcut), - .rejected(.conflictsWithAction(.openBrowser)) - ) - } - - func testShortcutRecorderResolutionRejectsSingleStrokeThatMatchesChordPrefix() { - KeyboardShortcutSettings.resetAll() - defer { KeyboardShortcutSettings.resetAll() } - - KeyboardShortcutSettings.setShortcut( - StoredShortcut( - key: "k", - command: true, - shift: false, - option: false, - control: false, - chordKey: "c", - chordCommand: true, - chordShift: false, - chordOption: false, - chordControl: false - ), - for: .openBrowser - ) - - let shortcut = StoredShortcut(key: "k", command: true, shift: false, option: false, control: false) - - XCTAssertEqual( - KeyboardShortcutSettings.Action.newTab.normalizedRecordedShortcutResult(shortcut), - .rejected(.conflictsWithAction(.openBrowser)) - ) - } - - func testShortcutRecorderResolutionRejectsChordThatMatchesExistingSingleStrokePrefix() { - KeyboardShortcutSettings.resetAll() - defer { KeyboardShortcutSettings.resetAll() } - - KeyboardShortcutSettings.setShortcut( - StoredShortcut(key: "k", command: true, shift: false, option: false, control: false), - for: .openBrowser - ) - - let shortcut = StoredShortcut( - key: "k", - command: true, - shift: false, - option: false, - control: false, - chordKey: "c", - chordCommand: true, - chordShift: false, - chordOption: false, - chordControl: false - ) - - XCTAssertEqual( - KeyboardShortcutSettings.Action.newTab.normalizedRecordedShortcutResult(shortcut), - .rejected(.conflictsWithAction(.openBrowser)) - ) - } - - func testSystemWideHotkeyNormalizationReportsCmuxActionConflictByRecordedPhysicalKey() { - KeyboardShortcutSettings.resetAll() - defer { KeyboardShortcutSettings.resetAll() } - - let shortcut = StoredShortcut( - key: "q", - command: true, - shift: false, - option: false, - control: false, - keyCode: 13 - ) - - XCTAssertEqual( - KeyboardShortcutSettings.Action.showHideAllWindows.normalizedRecordedShortcutResult(shortcut), - .rejected(.conflictsWithAction(.quit)) - ) - } - - func testSystemWideHotkeyNormalizationReportsReservedHotkeyReason() { - KeyboardShortcutSettings.resetAll() - defer { KeyboardShortcutSettings.resetAll() } - - let shortcut = StoredShortcut(key: ".", command: true, shift: false, option: false, control: false) - - XCTAssertEqual( - KeyboardShortcutSettings.Action.showHideAllWindows.normalizedRecordedShortcutResult(shortcut), - .rejected(.reservedBySystem) - ) - } - - func testShortcutRecorderValidationPresentationSurfacesBareKeyMessage() { - let presentation = ShortcutRecorderValidationPresentation( - attempt: ShortcutRecorderRejectedAttempt(reason: .bareKeyNotAllowed, proposedShortcut: nil), - action: .openBrowser, - currentShortcut: KeyboardShortcutSettings.Action.openBrowser.defaultShortcut - ) - - XCTAssertEqual(presentation?.message, "Shortcuts must include ⌘ ⌥ ⌃ or ⇧") - XCTAssertNil(presentation?.swapButtonTitle) - XCTAssertFalse(presentation?.canSwap ?? true) - XCTAssertEqual(presentation?.undoButtonTitle, "Undo") - } - - func testShortcutRecorderValidationPresentationSurfacesConflictActionAndSwapAffordance() { - let presentation = ShortcutRecorderValidationPresentation( - attempt: ShortcutRecorderRejectedAttempt( - reason: .conflictsWithAction(.newSurface), - proposedShortcut: StoredShortcut(key: "t", command: true, shift: false, option: false, control: false) - ), - action: .openBrowser, - currentShortcut: KeyboardShortcutSettings.Action.openBrowser.defaultShortcut, - isManagedBySettingsFile: { _ in false }, - shortcutForAction: { $0.defaultShortcut } - ) - - XCTAssertEqual(presentation?.message, "This shortcut conflicts with New Surface (⌘T). Swap shortcuts?") - XCTAssertEqual(presentation?.swapButtonTitle, "Swap") - XCTAssertTrue(presentation?.canSwap ?? false) - XCTAssertEqual(presentation?.undoButtonTitle, "Undo") - } - - func testShortcutRecorderValidationPresentationUsesNumberedDisplayOnlyForNumberedConflicts() { - let presentation = ShortcutRecorderValidationPresentation( - attempt: ShortcutRecorderRejectedAttempt( - reason: .conflictsWithAction(.selectWorkspaceByNumber), - proposedShortcut: StoredShortcut(key: "2", command: true, shift: false, option: false, control: false) - ), - action: .openBrowser, - currentShortcut: KeyboardShortcutSettings.Action.openBrowser.defaultShortcut, - isManagedBySettingsFile: { _ in false }, - shortcutForAction: { $0.defaultShortcut } - ) - - XCTAssertEqual( - presentation?.message, - "This shortcut conflicts with Select Workspace 1…9 (⌘1…9)." - ) - XCTAssertNil(presentation?.swapButtonTitle) - XCTAssertFalse(presentation?.canSwap ?? true) - XCTAssertEqual(presentation?.undoButtonTitle, "Undo") - } - - func testShortcutRecorderValidationPresentationSurfacesReservedSystemMessage() { - let presentation = ShortcutRecorderValidationPresentation( - attempt: ShortcutRecorderRejectedAttempt(reason: .reservedBySystem, proposedShortcut: nil), - action: .showHideAllWindows, - currentShortcut: KeyboardShortcutSettings.Action.showHideAllWindows.defaultShortcut - ) - - XCTAssertEqual(presentation?.message, "This keystroke is reserved by macOS.") - XCTAssertNil(presentation?.swapButtonTitle) - XCTAssertFalse(presentation?.canSwap ?? true) - XCTAssertEqual(presentation?.undoButtonTitle, "Undo") - } -} - - -final class WorkspaceShortcutMapperTests: XCTestCase { - func testCommandNineMapsToLastWorkspaceIndex() { - XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forDigit: 9, workspaceCount: 1), 0) - XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forDigit: 9, workspaceCount: 4), 3) - XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forDigit: 9, workspaceCount: 12), 11) +final class WorkspaceShortcutMapperTests: XCTestCase { + func testCommandNineMapsToLastWorkspaceIndex() { + XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forDigit: 9, workspaceCount: 1), 0) + XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forDigit: 9, workspaceCount: 4), 3) + XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forDigit: 9, workspaceCount: 12), 11) } func testCommandDigitBadgesUseNineForLastWorkspaceWhenNeeded() { @@ -2231,7 +1642,6 @@ final class WorkspaceCreationPlacementTests: XCTestCase { portOrdinal: Int, configTemplate: CmuxSurfaceConfigTemplate?, initialTerminalCommand: String?, - initialTerminalInput: String?, initialTerminalEnvironment: [String: String] ) -> Workspace { beforeCreateWorkspace?() @@ -2241,7 +1651,6 @@ final class WorkspaceCreationPlacementTests: XCTestCase { portOrdinal: portOrdinal, configTemplate: configTemplate, initialTerminalCommand: initialTerminalCommand, - initialTerminalInput: initialTerminalInput, initialTerminalEnvironment: initialTerminalEnvironment ) } @@ -2492,7 +1901,6 @@ final class WorkspaceCreationConfigSanitizationTests: XCTestCase { portOrdinal: Int, configTemplate: CmuxSurfaceConfigTemplate?, initialTerminalCommand: String?, - initialTerminalInput: String?, initialTerminalEnvironment: [String: String] ) -> Workspace { capturedConfigTemplate = configTemplate @@ -2502,7 +1910,6 @@ final class WorkspaceCreationConfigSanitizationTests: XCTestCase { portOrdinal: portOrdinal, configTemplate: configTemplate, initialTerminalCommand: initialTerminalCommand, - initialTerminalInput: initialTerminalInput, initialTerminalEnvironment: initialTerminalEnvironment ) } @@ -2985,7 +2392,7 @@ final class WorkspaceTeardownTests: XCTestCase { workspace.setPanelCustomTitle(panelId: initialPanelId, title: "Initial custom title") workspace.setPanelPinned(panelId: initialPanelId, pinned: true) - guard let splitPanel = workspace.newTerminalSplit(from: initialPanelId, orientation: .horizontal) else { + guard let splitPanel = workspace.splitTerminalPanel(fromPanelId: initialPanelId, orientation: .horizontal) else { XCTFail("Expected split panel to be created") return } @@ -2995,183 +2402,2593 @@ final class WorkspaceTeardownTests: XCTestCase { workspace.markPanelUnread(initialPanelId) XCTAssertFalse(workspace.panels.isEmpty) - XCTAssertFalse(workspace.panelTitles.isEmpty) - XCTAssertFalse(workspace.panelCustomTitles.isEmpty) - XCTAssertFalse(workspace.pinnedPanelIds.isEmpty) - XCTAssertFalse(workspace.manualUnreadPanelIds.isEmpty) + XCTAssertTrue(workspace.surfaceStatesSnapshot().values.contains { $0.title != nil }) + XCTAssertTrue(workspace.surfaceStatesSnapshot().values.contains { $0.customTitle != nil }) + XCTAssertTrue(workspace.surfaceStatesSnapshot().values.contains(where: \.isPinned)) + XCTAssertTrue(workspace.surfaceStatesSnapshot().values.contains(where: \.isManuallyUnread)) workspace.teardownAllPanels() XCTAssertTrue(workspace.panels.isEmpty) - XCTAssertTrue(workspace.panelTitles.isEmpty) - XCTAssertTrue(workspace.panelCustomTitles.isEmpty) - XCTAssertTrue(workspace.pinnedPanelIds.isEmpty) - XCTAssertTrue(workspace.manualUnreadPanelIds.isEmpty) + XCTAssertTrue(workspace.surfaceStatesSnapshot().values.allSatisfy { $0.title == nil }) + XCTAssertTrue(workspace.surfaceStatesSnapshot().values.allSatisfy { $0.customTitle == nil }) + XCTAssertTrue(workspace.surfaceStatesSnapshot().values.allSatisfy { !$0.isPinned }) + XCTAssertTrue(workspace.surfaceStatesSnapshot().values.allSatisfy { !$0.isManuallyUnread }) } } - @MainActor -final class WorkspaceSplitWorkingDirectoryTests: XCTestCase { - private func waitForCondition( - timeout: TimeInterval = 2, - pollInterval: TimeInterval = 0.01, - _ condition: () -> Bool - ) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if condition() { - return true +final class WorkspaceLayoutSimplificationTests: XCTestCase { + private func temporaryMarkdownFile() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-layout-simplification-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let fileURL = root.appendingPathComponent("note.md") + try "# workspace\n".write(to: fileURL, atomically: true, encoding: .utf8) + return fileURL + } + + private func firstPaneSnapshot( + from snapshot: WorkspaceLayoutRenderSnapshot + ) -> WorkspaceLayoutPaneRenderSnapshot? { + switch snapshot.root { + case .pane(let pane): + return pane + case .split(let split): + if case .pane(let first) = split.first { + return first } - RunLoop.current.run(until: Date().addingTimeInterval(pollInterval)) + if case .pane(let second) = split.second { + return second + } + return nil } - return condition() } - private func hostTerminalPanelInWindow(_ panel: TerminalPanel) throws -> NSWindow { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 280), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false + private func firstSplitSnapshot( + from snapshot: WorkspaceLayoutRenderSnapshot + ) -> WorkspaceLayoutSplitRenderSnapshot? { + guard case .split(let split) = snapshot.root else { return nil } + return split + } + + private func viewport( + in snapshot: WorkspaceLayoutRenderSnapshot, + for paneId: PaneID + ) -> WorkspaceLayoutViewportSnapshot? { + snapshot.viewports.first { $0.paneId == paneId } + } + + func testSurfaceAndPanelIdentityRoundTripUsesCanonicalID() { + let workspace = Workspace() + guard let panelId = workspace.focusedPanelId else { + XCTFail("Expected focused panel in new workspace") + return + } + + guard let surfaceId = workspace.surfaceIdFromPanelId(panelId) else { + XCTFail("Expected surface ID for focused panel") + return + } + + XCTAssertEqual(surfaceId.id, panelId) + XCTAssertEqual(workspace.panel(for: surfaceId)?.id, panelId) + } + + func testRenderTabChromeProjectsWorkspaceOwnedChromeState() { + let workspace = Workspace() + guard let panelId = workspace.focusedPanelId else { + XCTFail("Expected focused panel in new workspace") + return + } + + XCTAssertTrue(workspace.updatePanelTitle(panelId: panelId, title: "runtime title")) + workspace.setPanelCustomTitle(panelId: panelId, title: "custom title") + workspace.setPanelPinned(panelId: panelId, pinned: true) + workspace.markPanelUnread(panelId) + let projectionState = workspace.makeTabChromeProjectionState(notificationStore: nil) + + let rendered = workspace.renderTabChrome( + for: WorkspaceLayout.Tab( + id: TabID(id: panelId), + title: "stale title", + hasCustomTitle: false, + icon: "wrong.icon", + iconImageData: Data([0x01]), + kind: .browser, + isDirty: true, + showsNotificationBadge: false, + isLoading: true, + isPinned: false + ), + using: projectionState ) - let contentView = try XCTUnwrap(window.contentView, "Expected content view") + XCTAssertEqual(rendered.id.id, panelId) + XCTAssertEqual(rendered.title, "custom title") + XCTAssertTrue(rendered.hasCustomTitle) + XCTAssertEqual(rendered.icon, "terminal.fill") + XCTAssertNil(rendered.iconImageData) + XCTAssertEqual(rendered.kind, .terminal) + XCTAssertFalse(rendered.isDirty) + XCTAssertTrue(rendered.showsNotificationBadge) + XCTAssertFalse(rendered.isLoading) + XCTAssertTrue(rendered.isPinned) + } - let hostedView = panel.hostedView - hostedView.frame = contentView.bounds - hostedView.autoresizingMask = [.width, .height] - contentView.addSubview(hostedView) + func testRenderTabChromeLeavesUnknownSurfaceUnchanged() { + let workspace = Workspace() + let tab = WorkspaceLayout.Tab( + id: TabID(), + title: "Placeholder", + hasCustomTitle: true, + icon: "questionmark.circle", + iconImageData: Data([0x02]), + kind: .markdown, + isDirty: true, + showsNotificationBadge: true, + isLoading: true, + isPinned: true + ) - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - contentView.layoutSubtreeIfNeeded() - XCTAssertTrue( - waitForCondition { - panel.surface.surface != nil - }, - "Expected runtime surface to materialize after hosting panel in a window" + XCTAssertEqual( + workspace.renderTabChrome( + for: tab, + using: workspace.makeTabChromeProjectionState(notificationStore: nil) + ), + tab ) - return window } - func testNewTerminalSplitFallsBackToRequestedWorkingDirectoryWhenReportedDirectoryIsStale() { + func testTabChromeProjectionStateReflectsUpdatedPanelTitle() { let workspace = Workspace() - guard let sourcePaneId = workspace.bonsplitController.focusedPaneId else { - XCTFail("Expected focused pane in new workspace") + guard let panelId = workspace.focusedPanelId else { + XCTFail("Expected focused panel in new workspace") return } - let staleCurrentDirectory = workspace.currentDirectory - let requestedDirectory = "/tmp/cmux-requested-split-cwd-\(UUID().uuidString)" - guard let sourcePanel = workspace.newTerminalSurface( - inPane: sourcePaneId, - focus: false, - workingDirectory: requestedDirectory - ) else { - XCTFail("Expected source terminal panel to be created") + XCTAssertTrue(workspace.updatePanelTitle(panelId: panelId, title: "projected title")) + let projectionState = workspace.makeTabChromeProjectionState(notificationStore: nil) + + let rendered = workspace.renderTabChrome( + for: WorkspaceLayout.Tab(id: TabID(id: panelId), title: "stale title"), + using: projectionState + ) + + XCTAssertEqual(rendered.title, "projected title") + } + + func testWorkspaceLayoutTabStoresSerializedFallbackFields() { + let tab = WorkspaceLayout.Tab( + title: "Fallback title", + hasCustomTitle: true, + icon: "globe", + iconImageData: Data([0xAA]), + kind: .browser, + isDirty: true, + showsNotificationBadge: true, + isLoading: true, + isPinned: true + ) + + XCTAssertEqual(tab.title, "Fallback title") + XCTAssertTrue(tab.hasCustomTitle) + XCTAssertEqual(tab.icon, "globe") + XCTAssertEqual(tab.iconImageData, Data([0xAA])) + XCTAssertEqual(tab.kind, .browser) + XCTAssertTrue(tab.isDirty) + XCTAssertTrue(tab.showsNotificationBadge) + XCTAssertTrue(tab.isLoading) + XCTAssertTrue(tab.isPinned) + } + + func testRenderSnapshotProjectsWorkspaceOwnedChromeState() { + let workspace = Workspace() + guard let panelId = workspace.focusedPanelId else { + XCTFail("Expected focused panel in new workspace") return } - XCTAssertEqual(sourcePanel.requestedWorkingDirectory, requestedDirectory) - XCTAssertNil( - workspace.panelDirectories[sourcePanel.id], - "Expected requested cwd to exist before shell integration reports a live cwd" - ) - XCTAssertEqual( - workspace.currentDirectory, - staleCurrentDirectory, - "Expected focused workspace cwd to remain stale before panel directory updates" + workspace.setPanelCustomTitle(panelId: panelId, title: "Snapshot custom title") + workspace.setPanelPinned(panelId: panelId, pinned: true) + workspace.markPanelUnread(panelId) + let snapshot = workspace.makeLayoutRenderSnapshot( + context: WorkspaceLayoutRenderContext( + notificationStore: nil, + isWorkspaceVisible: true, + isWorkspaceInputActive: true, + isMinimalMode: false, + appearance: PanelAppearance( + dividerColor: .clear, + unfocusedOverlayNSColor: .clear, + unfocusedOverlayOpacity: 0 + ), + workspacePortalPriority: 0, + usesWorkspacePaneOverlay: false, + showSplitButtons: true + ) ) - guard let splitPanel = workspace.newTerminalSplit( - from: sourcePanel.id, - orientation: .horizontal, - focus: false - ) else { - XCTFail("Expected split terminal panel to be created") + guard let pane = firstPaneSnapshot(from: snapshot), + let renderedTab = pane.chrome.tabs.first?.tab else { + XCTFail("Expected pane snapshot with a rendered tab") return } - XCTAssertEqual( - splitPanel.requestedWorkingDirectory, - requestedDirectory, - "Expected split to inherit the source terminal's requested cwd when no reported cwd exists yet" + XCTAssertEqual(renderedTab.id.id, panelId) + XCTAssertEqual(renderedTab.title, "Snapshot custom title") + XCTAssertTrue(renderedTab.hasCustomTitle) + XCTAssertTrue(renderedTab.showsNotificationBadge) + XCTAssertTrue(renderedTab.isPinned) + XCTAssertEqual(pane.chrome.selectedTabId, panelId) + } + + func testTreeSnapshotProjectsWorkspaceOwnedTabTitles() { + let workspace = Workspace() + guard let panelId = workspace.focusedPanelId else { + XCTFail("Expected focused panel in new workspace") + return + } + + XCTAssertTrue(workspace.updatePanelTitle(panelId: panelId, title: "runtime title")) + workspace.setPanelCustomTitle(panelId: panelId, title: "custom title") + + guard case .pane(let pane) = workspace.treeSnapshot(), + let tab = pane.tabs.first else { + XCTFail("Expected pane tree snapshot with one tab") + return + } + + XCTAssertEqual(tab.id, panelId.uuidString) + XCTAssertEqual(tab.title, "custom title") + } + + func testRenderSnapshotUsesWorkspaceSelectedPaneContent() throws { + let workspace = Workspace() + let fileURL = try temporaryMarkdownFile() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + guard let paneId = workspace.paneIds.first, + let terminalPanelId = workspace.focusedPanelId else { + XCTFail("Expected initial pane and focused panel") + return + } + + let result = workspace.performLayoutCommand( + .createMarkdown(inPane: paneId, filePath: fileURL.path, focus: false) + ) + guard let markdownPanel = result.markdownPanel else { + XCTFail("Expected markdown panel") + return + } + + let context = WorkspaceLayoutRenderContext( + notificationStore: nil, + isWorkspaceVisible: true, + isWorkspaceInputActive: true, + isMinimalMode: false, + appearance: PanelAppearance( + dividerColor: .clear, + unfocusedOverlayNSColor: .clear, + unfocusedOverlayOpacity: 0 + ), + workspacePortalPriority: 0, + usesWorkspacePaneOverlay: false, + showSplitButtons: true + ) + + let initialSnapshot = workspace.makeLayoutRenderSnapshot(context: context) + guard let initialPane = firstPaneSnapshot(from: initialSnapshot) else { + XCTFail("Expected pane snapshot") + return + } + XCTAssertEqual(initialPane.chrome.tabs.count, 2) + XCTAssertEqual(initialPane.contentId, terminalPanelId) + if case .terminal = initialPane.content { + } else { + XCTFail("Expected selected terminal content to be emitted in the pane snapshot") + } + XCTAssertNil(viewport(in: initialSnapshot, for: paneId)) + + XCTAssertTrue(workspace.selectSurface(markdownPanel.id)) + + let markdownSnapshot = workspace.makeLayoutRenderSnapshot(context: context) + guard let markdownPane = firstPaneSnapshot(from: markdownSnapshot) else { + XCTFail("Expected pane snapshot after selecting markdown") + return + } + guard let markdownViewport = viewport(in: markdownSnapshot, for: paneId) else { + XCTFail("Expected viewport snapshot after selecting markdown") + return + } + XCTAssertEqual(markdownPane.chrome.tabs.count, 2) + XCTAssertEqual(markdownPane.contentId, markdownPanel.id) + if case .markdown = markdownPane.content { + } else { + XCTFail("Expected selected markdown content to be emitted in the pane snapshot") + } + XCTAssertEqual(markdownViewport.contentId, markdownPanel.id) + if case .markdown = markdownViewport.content { + } else { + XCTFail("Expected selected markdown content to be emitted") + } + } + + func testHiddenWorkspaceRenderSnapshotKeepsMarkdownViewport() throws { + let workspace = Workspace() + let fileURL = try temporaryMarkdownFile() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + guard let paneId = workspace.paneIds.first else { + XCTFail("Expected initial pane") + return + } + + let result = workspace.performLayoutCommand( + .createMarkdown(inPane: paneId, filePath: fileURL.path, focus: true) + ) + guard let markdownPanel = result.markdownPanel else { + XCTFail("Expected markdown panel") + return + } + + let context = WorkspaceLayoutRenderContext( + notificationStore: nil, + isWorkspaceVisible: false, + isWorkspaceInputActive: false, + isMinimalMode: false, + appearance: PanelAppearance( + dividerColor: .clear, + unfocusedOverlayNSColor: .clear, + unfocusedOverlayOpacity: 0 + ), + workspacePortalPriority: 0, + usesWorkspacePaneOverlay: false, + showSplitButtons: true + ) + + let snapshot = workspace.makeLayoutRenderSnapshot(context: context) + guard let markdownViewport = viewport(in: snapshot, for: paneId) else { + XCTFail("Expected hidden workspace to keep the selected markdown viewport mounted") + return + } + + XCTAssertEqual(markdownViewport.contentId, markdownPanel.id) + if case .markdown = markdownViewport.content { + } else { + XCTFail("Expected selected markdown viewport content") + } + } + + func testUnreadPaneRingSettingGatesTerminalContentDescriptor() { + let defaults = UserDefaults.standard + let previous = defaults.object(forKey: NotificationPaneRingSettings.enabledKey) + defer { + if let previous { + defaults.set(previous, forKey: NotificationPaneRingSettings.enabledKey) + } else { + defaults.removeObject(forKey: NotificationPaneRingSettings.enabledKey) + } + } + + let workspace = Workspace() + guard let panelId = workspace.focusedPanelId else { + XCTFail("Expected focused terminal") + return + } + + let notificationStore = TerminalNotificationStore.shared + notificationStore.replaceNotificationsForTesting([]) + notificationStore.configureNotificationDeliveryHandlerForTesting { _, _ in } + defer { + notificationStore.replaceNotificationsForTesting([]) + notificationStore.resetNotificationDeliveryHandlerForTesting() + } + notificationStore.addNotification( + tabId: workspace.id, + surfaceId: panelId, + title: "Unread", + subtitle: "", + body: "Unread terminal" + ) + + func terminalContentWhenRingSetting(enabled: Bool) -> WorkspaceTerminalPaneContent? { + defaults.set(enabled, forKey: NotificationPaneRingSettings.enabledKey) + let context = WorkspaceLayoutRenderContext( + notificationStore: notificationStore, + isWorkspaceVisible: true, + isWorkspaceInputActive: true, + isMinimalMode: false, + appearance: PanelAppearance( + dividerColor: .clear, + unfocusedOverlayNSColor: .clear, + unfocusedOverlayOpacity: 0 + ), + workspacePortalPriority: 0, + usesWorkspacePaneOverlay: false, + showSplitButtons: true + ) + guard let pane = firstPaneSnapshot(from: workspace.makeLayoutRenderSnapshot(context: context)), + case .terminal(let content) = pane.content else { + return nil + } + return content + } + + XCTAssertTrue(terminalContentWhenRingSetting(enabled: true)?.hasUnreadNotification == true) + XCTAssertTrue(terminalContentWhenRingSetting(enabled: false)?.hasUnreadNotification == false) + } + + func testSelectedBrowserRenderSnapshotOmitsViewportAndUsesDirectPaneContent() { + let workspace = Workspace() + guard let paneId = workspace.paneIds.first, + let browserPanel = workspace.createBrowserPanel( + inPane: paneId, + url: URL(string: "about:blank"), + focus: false + ) else { + XCTFail("Expected browser panel") + return + } + + XCTAssertTrue(workspace.selectSurface(browserPanel.id)) + + let snapshot = workspace.makeLayoutRenderSnapshot( + context: WorkspaceLayoutRenderContext( + notificationStore: nil, + isWorkspaceVisible: true, + isWorkspaceInputActive: true, + isMinimalMode: false, + appearance: PanelAppearance( + dividerColor: .clear, + unfocusedOverlayNSColor: .clear, + unfocusedOverlayOpacity: 0 + ), + workspacePortalPriority: 0, + usesWorkspacePaneOverlay: false, + showSplitButtons: true + ) + ) + + guard let pane = firstPaneSnapshot(from: snapshot) else { + XCTFail("Expected pane snapshot") + return + } + XCTAssertEqual(pane.contentId, browserPanel.id) + if case .browser = pane.content { + } else { + XCTFail("Expected selected browser content in the pane snapshot") + } + XCTAssertNil(viewport(in: snapshot, for: paneId)) + } + + func testRootHostMountsSelectedTerminalDirectlyInPaneHost() throws { + _ = NSApplication.shared + + let workspace = Workspace() + guard let paneId = workspace.focusedPaneId, + let panelId = workspace.focusedPanelId, + let panel = workspace.terminalPanel(for: panelId) else { + XCTFail("Expected focused terminal pane") + return + } + + let renderContext = WorkspaceLayoutRenderContext( + notificationStore: nil, + isWorkspaceVisible: true, + isWorkspaceInputActive: true, + isMinimalMode: false, + appearance: PanelAppearance( + dividerColor: .clear, + unfocusedOverlayNSColor: .clear, + unfocusedOverlayOpacity: 0 + ), + workspacePortalPriority: 0, + usesWorkspacePaneOverlay: false, + showSplitButtons: true + ) + let rootHost = WorkspaceLayoutRootHostView( + hostBridge: workspace.layoutInteractionHandlers, + renderSnapshot: workspace.makeLayoutRenderSnapshot(context: renderContext), + surfaceRegistry: workspace.surfaceRegistry + ) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 960, height: 640), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + let contentView = try XCTUnwrap(window.contentView) + rootHost.frame = contentView.bounds + rootHost.autoresizingMask = [.width, .height] + contentView.addSubview(rootHost) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + rootHost.layoutSubtreeIfNeeded() + + let mountedExpectation = expectation(description: "terminal mounted directly") + func pollMounted() { + if panel.hostedView.window === window && + panel.hostedView.superview != nil && + !panel.hostedView.isHidden { + mountedExpectation.fulfill() + } else { + DispatchQueue.main.async { + pollMounted() + } + } + } + pollMounted() + wait(for: [mountedExpectation], timeout: 2.0) + + XCTAssertTrue(rootHost.debugPaneUsesDirectTerminalHost(paneId)) + XCTAssertFalse(rootHost.debugViewportMountIdentities.contains(WorkspacePaneMountIdentity.terminal(panel.id))) + } + + func testRootHostMountsSelectedBrowserDirectlyInPaneHostBelowTabBar() throws { + _ = NSApplication.shared + + let workspace = Workspace() + guard let paneId = workspace.focusedPaneId, + let browserPanel = workspace.createBrowserPanel( + inPane: paneId, + url: URL(string: "about:blank"), + focus: true + ) else { + XCTFail("Expected focused browser pane") + return + } + + let renderContext = WorkspaceLayoutRenderContext( + notificationStore: nil, + isWorkspaceVisible: true, + isWorkspaceInputActive: true, + isMinimalMode: false, + appearance: PanelAppearance( + dividerColor: .clear, + unfocusedOverlayNSColor: .clear, + unfocusedOverlayOpacity: 0 + ), + workspacePortalPriority: 0, + usesWorkspacePaneOverlay: false, + showSplitButtons: true + ) + let rootHost = WorkspaceLayoutRootHostView( + hostBridge: workspace.layoutInteractionHandlers, + renderSnapshot: workspace.makeLayoutRenderSnapshot(context: renderContext), + surfaceRegistry: workspace.surfaceRegistry + ) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 960, height: 640), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + let contentView = try XCTUnwrap(window.contentView) + rootHost.frame = contentView.bounds + rootHost.autoresizingMask = [.width, .height] + contentView.addSubview(rootHost) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + rootHost.layoutSubtreeIfNeeded() + + let mountedExpectation = expectation(description: "browser pane mounted directly") + func pollMounted() { + if rootHost.debugPaneMountedDirectContentIdentity(paneId) == .browser(browserPanel.id) && + rootHost.debugPaneDirectContentBounds(paneId).height > 0 { + mountedExpectation.fulfill() + } else { + DispatchQueue.main.async { + pollMounted() + } + } + } + pollMounted() + wait(for: [mountedExpectation], timeout: 2.0) + + XCTAssertEqual(rootHost.debugPaneMountedDirectContentIdentity(paneId), .browser(browserPanel.id)) + XCTAssertFalse(rootHost.debugViewportMountIdentities.contains(WorkspacePaneMountIdentity.browser(browserPanel.id))) + XCTAssertNotEqual( + BrowserWindowPortalRegistry.debugSnapshot(for: browserPanel.webView)?.visibleInUI, + true, + "Visible browser pane should not still be presented through the portal path" + ) + + let contentBounds = rootHost.debugPaneDirectContentBounds(paneId) + let layoutSnapshot = workspace.splitController.layoutSnapshot() + guard let paneGeometry = layoutSnapshot.panes.first(where: { $0.paneId == paneId.id.uuidString }) else { + XCTFail("Expected pane geometry") + return + } + + let paneFrame = CGRect( + x: CGFloat(paneGeometry.frame.x) - CGFloat(layoutSnapshot.containerFrame.x), + y: CGFloat(paneGeometry.frame.y) - CGFloat(layoutSnapshot.containerFrame.y), + width: CGFloat(paneGeometry.frame.width), + height: CGFloat(paneGeometry.frame.height) + ) + let expectedHeight = max( + 0, + paneFrame.height - min( + workspace.splitController.configuration.appearance.tabBarHeight, + max(0, paneFrame.height - 1) + ) + ) + + XCTAssertEqual(contentBounds.width, paneFrame.width, accuracy: 0.001) + XCTAssertEqual(contentBounds.height, expectedHeight, accuracy: 0.001) + } + + func testHiddenWorkspaceRenderSnapshotOmitsViewportMounts() { + let workspace = Workspace() + + let snapshot = workspace.makeLayoutRenderSnapshot( + context: WorkspaceLayoutRenderContext( + notificationStore: nil, + isWorkspaceVisible: false, + isWorkspaceInputActive: false, + isMinimalMode: false, + appearance: PanelAppearance( + dividerColor: .clear, + unfocusedOverlayNSColor: .clear, + unfocusedOverlayOpacity: 0 + ), + workspacePortalPriority: 0, + usesWorkspacePaneOverlay: false, + showSplitButtons: true + ) + ) + + XCTAssertTrue(snapshot.viewports.isEmpty) + XCTAssertNotNil(firstPaneSnapshot(from: snapshot)) + } + + func testRenderSnapshotCarriesSplitGeometryFromControllerState() throws { + let workspace = Workspace() + guard let panelId = workspace.focusedPanelId else { + XCTFail("Expected focused panel in new workspace") + return + } + + let context = WorkspaceLayoutRenderContext( + notificationStore: nil, + isWorkspaceVisible: true, + isWorkspaceInputActive: true, + isMinimalMode: false, + appearance: PanelAppearance( + dividerColor: .clear, + unfocusedOverlayNSColor: .clear, + unfocusedOverlayOpacity: 0 + ), + workspacePortalPriority: 0, + usesWorkspacePaneOverlay: false, + showSplitButtons: true + ) + + let result = workspace.performLayoutCommand( + .splitTerminal(fromPanelId: panelId, orientation: .horizontal) + ) + XCTAssertNotNil(result.terminalPanel, "Expected split terminal panel") + + let initialSnapshot = workspace.makeLayoutRenderSnapshot(context: context) + guard let split = firstSplitSnapshot(from: initialSnapshot) else { + XCTFail("Expected split root snapshot after splitting") + return + } + + XCTAssertEqual(split.orientation, .horizontal) + XCTAssertEqual(split.dividerPosition, 0.5, accuracy: 0.001) + XCTAssertEqual(split.animationOrigin, .fromSecond) + + workspace.consumeSplitEntryAnimation(split.splitId) + let consumedSnapshot = workspace.makeLayoutRenderSnapshot(context: context) + XCTAssertNil( + firstSplitSnapshot(from: consumedSnapshot)?.animationOrigin, + "Expected the render snapshot to stop carrying consumed split-entry animation state" + ) + + XCTAssertTrue(workspace.setDividerPosition(0.33, forSplit: split.splitId)) + let resizedSnapshot = workspace.makeLayoutRenderSnapshot(context: context) + let resizedDividerPosition = try XCTUnwrap( + firstSplitSnapshot(from: resizedSnapshot)?.dividerPosition, + "Expected divider position in resized split snapshot" + ) + XCTAssertEqual(Double(resizedDividerPosition), 0.33, accuracy: 0.001) + } + + func testRenderSnapshotCarriesPresentationStateFromWorkspace() throws { + let workspace = Workspace() + guard let paneId = workspace.paneIds.first, + let panelId = workspace.focusedPanelId else { + XCTFail("Expected initial pane and focused panel") + return + } + + workspace.tabBarLeadingInset = 19 + workspace.layoutInteractionHandlers.beginTabDrag(tabId: TabID(id: panelId), sourcePaneId: paneId) + + let snapshot = workspace.makeLayoutRenderSnapshot( + context: WorkspaceLayoutRenderContext( + notificationStore: nil, + isWorkspaceVisible: true, + isWorkspaceInputActive: false, + isMinimalMode: true, + appearance: PanelAppearance( + dividerColor: .clear, + unfocusedOverlayNSColor: .clear, + unfocusedOverlayOpacity: 0 + ), + workspacePortalPriority: 0, + usesWorkspacePaneOverlay: false, + showSplitButtons: true + ) + ) + + XCTAssertFalse(snapshot.presentation.isInteractive) + XCTAssertTrue(snapshot.presentation.isMinimalMode) + XCTAssertEqual(snapshot.presentation.appearance.tabBarLeadingInset, 19, accuracy: 0.001) + XCTAssertEqual(snapshot.presentation.localTabDrag?.tabId, TabID(id: panelId)) + XCTAssertEqual(snapshot.presentation.localTabDrag?.sourcePaneId, paneId) + + workspace.layoutInteractionHandlers.clearDragState() + let clearedSnapshot = workspace.makeLayoutRenderSnapshot( + context: WorkspaceLayoutRenderContext( + notificationStore: nil, + isWorkspaceVisible: true, + isWorkspaceInputActive: true, + isMinimalMode: false, + appearance: PanelAppearance( + dividerColor: .clear, + unfocusedOverlayNSColor: .clear, + unfocusedOverlayOpacity: 0 + ), + workspacePortalPriority: 0, + usesWorkspacePaneOverlay: false, + showSplitButtons: true + ) + ) + XCTAssertNil(clearedSnapshot.presentation.localTabDrag) + } + + func testRootHostPropagatesLocalTabDragImmediatelyWithoutSnapshotRefresh() throws { + let workspace = Workspace() + guard let paneId = workspace.focusedPaneId, + let panelId = workspace.focusedPanelId else { + XCTFail("Expected focused pane and panel") + return + } + + let renderContext = WorkspaceLayoutRenderContext( + notificationStore: nil, + isWorkspaceVisible: true, + isWorkspaceInputActive: true, + isMinimalMode: false, + appearance: PanelAppearance( + dividerColor: .clear, + unfocusedOverlayNSColor: .clear, + unfocusedOverlayOpacity: 0 + ), + workspacePortalPriority: 0, + usesWorkspacePaneOverlay: false, + showSplitButtons: true + ) + let renderSnapshot = workspace.makeLayoutRenderSnapshot( + context: renderContext + ) + let rootHost = WorkspaceLayoutRootHostView( + hostBridge: workspace.layoutInteractionHandlers, + renderSnapshot: renderSnapshot, + surfaceRegistry: workspace.surfaceRegistry + ) + + XCTAssertNil(rootHost.debugPaneLocalTabDrag(paneId)) + + let draggedTabId = TabID(id: panelId) + let expectedDrag = WorkspaceLayoutLocalDragSnapshot( + tabId: draggedTabId, + sourcePaneId: paneId + ) + rootHost.debugBeginLocalTabDrag(tabId: draggedTabId, sourcePaneId: paneId) + + XCTAssertEqual(rootHost.debugPaneLocalTabDrag(paneId), expectedDrag) + XCTAssertEqual(workspace.makeLayoutRenderSnapshot(context: renderContext).presentation.localTabDrag, expectedDrag) + + rootHost.debugClearLocalTabDrag() + + XCTAssertNil(rootHost.debugPaneLocalTabDrag(paneId)) + XCTAssertNil(workspace.makeLayoutRenderSnapshot(context: renderContext).presentation.localTabDrag) + } + + func testEffectiveTabDropTargetIndexRejectsSamePaneNoOpReorderTargets() { + let paneId = PaneID(id: UUID()) + let firstTabId = TabID(id: UUID()) + let secondTabId = TabID(id: UUID()) + let thirdTabId = TabID(id: UUID()) + let localDrag = WorkspaceLayoutLocalDragSnapshot( + tabId: secondTabId, + sourcePaneId: paneId + ) + + XCTAssertNil( + workspaceLayoutEffectiveTabDropTargetIndex( + rawTargetIndex: 1, + tabIds: [firstTabId, secondTabId, thirdTabId], + paneId: paneId, + localTabDrag: localDrag + ) + ) + XCTAssertNil( + workspaceLayoutEffectiveTabDropTargetIndex( + rawTargetIndex: 2, + tabIds: [firstTabId, secondTabId, thirdTabId], + paneId: paneId, + localTabDrag: localDrag + ) + ) + XCTAssertEqual( + workspaceLayoutEffectiveTabDropTargetIndex( + rawTargetIndex: 0, + tabIds: [firstTabId, secondTabId, thirdTabId], + paneId: paneId, + localTabDrag: localDrag + ), + 0 + ) + XCTAssertEqual( + workspaceLayoutEffectiveTabDropTargetIndex( + rawTargetIndex: 3, + tabIds: [firstTabId, secondTabId, thirdTabId], + paneId: paneId, + localTabDrag: localDrag + ), + 3 + ) + } + + func testPaneDropOverlayCoordinatorTransitionsVisibleToHidingOnlyOnceOnClear() { + let paneId = PaneID(id: UUID()) + var coordinator = WorkspacePaneDropOverlayCoordinator() + + XCTAssertTrue(coordinator.setZone(.left, for: paneId)) + let visiblePresentation = coordinator.overlayPresentation(for: paneId) + XCTAssertEqual(visiblePresentation.phase, .visible) + XCTAssertEqual(visiblePresentation.zone, .left) + XCTAssertEqual(coordinator.activeDropZone(for: paneId), .left) + + XCTAssertTrue(coordinator.clearZone(for: paneId)) + let hidingPresentation = coordinator.overlayPresentation(for: paneId) + XCTAssertEqual(hidingPresentation.phase, .hiding) + XCTAssertEqual(hidingPresentation.zone, .left) + XCTAssertNil(coordinator.activeDropZone(for: paneId)) + + XCTAssertFalse(coordinator.clearZone(for: paneId)) + XCTAssertEqual(coordinator.overlayPresentation(for: paneId), hidingPresentation) + } + + func testPaneDropOverlayCoordinatorIgnoresStaleHideCompletion() { + let paneId = PaneID(id: UUID()) + var coordinator = WorkspacePaneDropOverlayCoordinator() + + XCTAssertTrue(coordinator.setZone(.right, for: paneId)) + XCTAssertTrue(coordinator.clearZone(for: paneId)) + let hidingPresentation = coordinator.overlayPresentation(for: paneId) + + XCTAssertFalse(coordinator.completeHide(for: paneId, generation: hidingPresentation.generation + 1)) + XCTAssertEqual(coordinator.overlayPresentation(for: paneId), hidingPresentation) + + XCTAssertTrue(coordinator.completeHide(for: paneId, generation: hidingPresentation.generation)) + XCTAssertEqual(coordinator.overlayPresentation(for: paneId), .hidden) + } + + func testPaneDropOverlayCoordinatorClearsImmediatelyOnSuccessfulDrop() { + let paneId = PaneID(id: UUID()) + var coordinator = WorkspacePaneDropOverlayCoordinator() + + XCTAssertTrue(coordinator.setZone(.right, for: paneId)) + let visiblePresentation = coordinator.overlayPresentation(for: paneId) + XCTAssertEqual(visiblePresentation.phase, .visible) + XCTAssertEqual(visiblePresentation.zone, .right) + + XCTAssertTrue(coordinator.clearZoneImmediately(for: paneId)) + XCTAssertEqual(coordinator.overlayPresentation(for: paneId), .hidden) + XCTAssertNil(coordinator.activeDropZone(for: paneId)) + } + + func testRenderSnapshotProjectsContextMenuStateFromWorkspaceFacts() throws { + let workspace = Workspace() + guard let panelId = workspace.focusedPanelId else { + XCTFail("Expected focused panel in new workspace") + return + } + + let result = workspace.performLayoutCommand( + .splitTerminal(fromPanelId: panelId, orientation: .horizontal) + ) + XCTAssertNotNil(result.terminalPanel, "Expected split terminal panel") + + let snapshot = workspace.makeLayoutRenderSnapshot( + context: WorkspaceLayoutRenderContext( + notificationStore: nil, + isWorkspaceVisible: true, + isWorkspaceInputActive: true, + isMinimalMode: false, + appearance: PanelAppearance( + dividerColor: .clear, + unfocusedOverlayNSColor: .clear, + unfocusedOverlayOpacity: 0 + ), + workspacePortalPriority: 0, + usesWorkspacePaneOverlay: false, + showSplitButtons: true + ) + ) + + guard let split = firstSplitSnapshot(from: snapshot), + case .pane(let firstPane) = split.first, + case .pane(let secondPane) = split.second, + let firstContext = firstPane.chrome.tabs.first?.contextMenuState, + let secondContext = secondPane.chrome.tabs.first?.contextMenuState else { + XCTFail("Expected split snapshot with pane-level context menu state") + return + } + + XCTAssertTrue(firstContext.hasSplits) + XCTAssertTrue(secondContext.hasSplits) + XCTAssertFalse(firstContext.canMoveToLeftPane) + XCTAssertTrue(firstContext.canMoveToRightPane) + XCTAssertTrue(secondContext.canMoveToLeftPane) + XCTAssertFalse(secondContext.canMoveToRightPane) + XCTAssertFalse(firstContext.isZoomed) + XCTAssertFalse(secondContext.isZoomed) + } + + func testProgrammaticDividerUpdatePublishesCachedGeometrySnapshot() throws { + let workspace = Workspace() + guard let panelId = workspace.focusedPanelId else { + XCTFail("Expected focused panel in new workspace") + return + } + + let result = workspace.performLayoutCommand( + .splitTerminal(fromPanelId: panelId, orientation: .horizontal) + ) + XCTAssertNotNil(result.terminalPanel, "Expected split terminal panel") + + let renderSnapshot = workspace.makeLayoutRenderSnapshot( + context: WorkspaceLayoutRenderContext( + notificationStore: nil, + isWorkspaceVisible: true, + isWorkspaceInputActive: true, + isMinimalMode: false, + appearance: PanelAppearance( + dividerColor: .clear, + unfocusedOverlayNSColor: .clear, + unfocusedOverlayOpacity: 0 + ), + workspacePortalPriority: 0, + usesWorkspacePaneOverlay: false, + showSplitButtons: true + ) + ) + let splitId = try XCTUnwrap( + firstSplitSnapshot(from: renderSnapshot)?.splitId, + "Expected split snapshot" + ) + + workspace.layoutInteractionHandlers.setContainerFrame(CGRect(x: 0, y: 0, width: 1200, height: 800)) + workspace.layoutInteractionHandlers.notifyGeometryChange(isDragging: false) + + let baselineSnapshot = try XCTUnwrap( + workspace.tmuxLayoutSnapshot, + "Expected cached geometry snapshot after initial publish" + ) + let baselineFrames = Dictionary( + baselineSnapshot.panes.map { ($0.paneId, $0.frame) }, + uniquingKeysWith: { first, _ in first } + ) + + XCTAssertTrue(workspace.setDividerPosition(0.33, forSplit: splitId)) + + let updatedSnapshot = try XCTUnwrap( + workspace.tmuxLayoutSnapshot, + "Expected cached geometry snapshot after divider update" + ) + XCTAssertEqual(updatedSnapshot.panes.count, 2) + XCTAssertTrue( + updatedSnapshot.panes.contains { pane in + guard let baselineFrame = baselineFrames[pane.paneId] else { return false } + return pane.frame != baselineFrame + }, + "Expected divider update to publish new pane geometry" + ) + } + + func testPerformLayoutCommandSplitTerminalCreatesSecondPane() { + let workspace = Workspace() + guard let panelId = workspace.focusedPanelId else { + XCTFail("Expected focused panel in new workspace") + return + } + + let result = workspace.performLayoutCommand( + .splitTerminal(fromPanelId: panelId, orientation: .horizontal) + ) + + guard let newPanel = result.terminalPanel else { + XCTFail("Expected split terminal panel") + return + } + + XCTAssertNotEqual(newPanel.id, panelId) + XCTAssertNotNil(workspace.panels[newPanel.id]) + XCTAssertEqual(workspace.paneCount, 2) + } + + func testPerformLayoutCommandCreateMarkdownCreatesPanelInPane() throws { + let workspace = Workspace() + let fileURL = try temporaryMarkdownFile() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + guard let paneId = workspace.paneIds.first else { + XCTFail("Expected pane in new workspace") + return + } + + let result = workspace.performLayoutCommand( + .createMarkdown(inPane: paneId, filePath: fileURL.path, focus: false) + ) + + guard let panel = result.markdownPanel else { + XCTFail("Expected markdown panel") + return + } + + XCTAssertEqual(panel.filePath, fileURL.path) + XCTAssertNotNil(workspace.panels[panel.id]) + XCTAssertEqual(workspace.paneId(forPanelId: panel.id), paneId) + } + + func testPerformLayoutCommandSplitMarkdownCreatesSecondPane() throws { + let workspace = Workspace() + let fileURL = try temporaryMarkdownFile() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + guard let panelId = workspace.focusedPanelId else { + XCTFail("Expected focused panel in new workspace") + return + } + + let result = workspace.performLayoutCommand( + .splitMarkdown(fromPanelId: panelId, orientation: .horizontal, filePath: fileURL.path) + ) + + guard let panel = result.markdownPanel else { + XCTFail("Expected split markdown panel") + return + } + + XCTAssertEqual(panel.filePath, fileURL.path) + XCTAssertNotEqual(panel.id, panelId) + XCTAssertNotNil(workspace.panels[panel.id]) + XCTAssertEqual(workspace.paneCount, 2) + } + + func testLayoutControllerPaneReadsAreValueCopies() throws { + let initialPane = PaneState(tabIds: [UUID()]) + let controller = WorkspaceLayoutController(rootNode: .pane(initialPane)) + let paneId = try XCTUnwrap(controller.allPaneIds.first, "Expected initial pane") + let stalePane = try XCTUnwrap(controller.rootNode.findPane(paneId), "Expected pane snapshot") + + _ = controller.createTab(title: "two", inPane: paneId, select: false) + + XCTAssertEqual(stalePane.tabIds.count, 1) + XCTAssertEqual(controller.tabIds(inPane: paneId).count, 2) + } + + func testLayoutControllerSplitReadsAreValueCopies() throws { + let initialPane = PaneState(tabIds: [UUID()]) + let controller = WorkspaceLayoutController(rootNode: .pane(initialPane)) + let paneId = try XCTUnwrap(controller.allPaneIds.first, "Expected initial pane") + let newPaneId = controller.splitPane(paneId, orientation: .horizontal) + + XCTAssertNotNil(newPaneId, "Expected split to succeed") + + guard case .split(let liveSplit) = controller.rootNode else { + XCTFail("Expected split root after splitting") + return + } + + let splitId = liveSplit.id + let staleSplit = try XCTUnwrap(controller.rootNode.findSplit(splitId), "Expected split snapshot") + + XCTAssertTrue(controller.setDividerPosition(0.33, forSplit: splitId)) + + XCTAssertEqual(staleSplit.dividerPosition, 0.5, accuracy: 0.001) + let liveDividerPosition = try XCTUnwrap( + controller.rootNode.findSplit(splitId)?.dividerPosition, + "Expected updated divider position" + ) + XCTAssertEqual(Double(liveDividerPosition), 0.33, accuracy: 0.001) + } + + func testLayoutControllerSplitPaneReturnsNilForUnknownPaneId() throws { + let initialPane = PaneState(tabIds: [UUID()]) + let controller = WorkspaceLayoutController(rootNode: .pane(initialPane)) + let stalePaneId = PaneID(id: UUID()) + + XCTAssertNil(controller.splitPane(stalePaneId, orientation: .horizontal)) + XCTAssertEqual(controller.allPaneIds.count, 1) + } + + func testLayoutControllerSplitPaneWithTabReturnsNilForUnknownPaneId() throws { + let existingTabId = UUID() + let initialPane = PaneState(tabIds: [existingTabId], selectedTabId: existingTabId) + let controller = WorkspaceLayoutController(rootNode: .pane(initialPane)) + let stalePaneId = PaneID(id: UUID()) + let newTabId = TabID(id: UUID()) + + XCTAssertNil( + controller.splitPane( + stalePaneId, + orientation: .horizontal, + withTabId: newTabId, + insertFirst: false + ) + ) + let paneId = try XCTUnwrap(controller.allPaneIds.first) + XCTAssertEqual(controller.tabIds(inPane: paneId), [TabID(id: existingTabId)]) + } + + func testLayoutControllerClosePaneReturnsFalseForUnknownPaneId() throws { + let leftPane = PaneState(tabIds: [UUID()]) + let rightPane = PaneState(tabIds: [UUID()]) + let controller = WorkspaceLayoutController( + rootNode: .split( + SplitState( + orientation: .horizontal, + first: .pane(leftPane), + second: .pane(rightPane) + ) + ) + ) + + XCTAssertFalse(controller.closePane(PaneID(id: UUID()))) + XCTAssertEqual(Set(controller.allPaneIds), Set([leftPane.id, rightPane.id])) + } + + func testLayoutControllerStartsWithGenuinelyEmptyPane() throws { + let controller = WorkspaceLayoutController() + let paneId = try XCTUnwrap(controller.allPaneIds.first, "Expected initial pane") + + XCTAssertEqual(controller.allPaneIds.count, 1) + XCTAssertTrue(controller.tabIds(inPane: paneId).isEmpty) + XCTAssertNil(controller.selectedTabId(inPane: paneId)) + XCTAssertTrue(controller.allTabIds.isEmpty) + } + + func testMovingOnlyTabIntoSplitLeavesSourcePaneEmpty() throws { + let surfaceId = UUID() + let paneId = PaneID() + let controller = WorkspaceLayoutController( + rootNode: .pane( + PaneState( + id: paneId, + tabIds: [surfaceId], + selectedTabId: surfaceId + ) + ) + ) + + let newPaneId = try XCTUnwrap( + controller.splitPane( + paneId, + orientation: .horizontal, + movingTab: TabID(id: surfaceId), + insertFirst: false + ), + "Expected split to succeed" + ) + + XCTAssertTrue(controller.tabIds(inPane: paneId).isEmpty) + XCTAssertNil(controller.selectedTabId(inPane: paneId)) + XCTAssertEqual(controller.tabIds(inPane: newPaneId), [TabID(id: surfaceId)]) + XCTAssertEqual(controller.allTabIds, [TabID(id: surfaceId)]) + } + + func testMovingTabSplitRejectsUnknownTargetAndPreservesSourcePane() throws { + let surfaceId = UUID() + let sourcePaneId = PaneID() + let controller = WorkspaceLayoutController( + rootNode: .pane( + PaneState( + id: sourcePaneId, + tabIds: [surfaceId], + selectedTabId: surfaceId + ) + ) + ) + let staleTargetPaneId = PaneID(id: UUID()) + + XCTAssertNil( + controller.splitPane( + staleTargetPaneId, + orientation: .horizontal, + movingTab: TabID(id: surfaceId), + insertFirst: false + ) + ) + + XCTAssertEqual(controller.allPaneIds, [sourcePaneId]) + XCTAssertEqual(controller.tabIds(inPane: sourcePaneId), [TabID(id: surfaceId)]) + XCTAssertEqual(controller.selectedTabId(inPane: sourcePaneId), TabID(id: surfaceId)) + } + + func testWorkspaceLayoutRemoveSubviewIfOwnedPreservesReparentedChild() { + let oldContainer = NSView(frame: .zero) + let newContainer = NSView(frame: .zero) + let child = NSView(frame: .zero) + newContainer.addSubview(child) + + workspaceLayoutRemoveSubviewIfOwned(child, from: oldContainer) + + XCTAssertTrue(child.superview === newContainer) + + workspaceLayoutRemoveSubviewIfOwned(child, from: newContainer) + + XCTAssertNil(child.superview) + } +} + + +@MainActor +final class WorkspaceSplitWorkingDirectoryTests: XCTestCase { + private func waitForCondition( + timeout: TimeInterval = 2, + pollInterval: TimeInterval = 0.01, + _ condition: () -> Bool + ) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(pollInterval)) + } + return condition() + } + + private func hostTerminalPanelInWindow(_ panel: TerminalPanel) throws -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 280), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + + let contentView = try XCTUnwrap(window.contentView, "Expected content view") + + let hostedView = panel.hostedView + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + XCTAssertTrue( + waitForCondition { + panel.surface.surface != nil + }, + "Expected runtime surface to materialize after hosting panel in a window" + ) + return window + } + + func testNewTerminalSplitFallsBackToRequestedWorkingDirectoryWhenReportedDirectoryIsStale() { + let workspace = Workspace() + guard let sourcePaneId = workspace.focusedPaneId else { + XCTFail("Expected focused pane in new workspace") + return + } + + let staleCurrentDirectory = workspace.currentDirectory + let requestedDirectory = "/tmp/cmux-requested-split-cwd-\(UUID().uuidString)" + guard let sourcePanel = workspace.createTerminalPanel( + inPane: sourcePaneId, + focus: false, + workingDirectory: requestedDirectory + ) else { + XCTFail("Expected source terminal panel to be created") + return + } + + XCTAssertEqual(sourcePanel.requestedWorkingDirectory, requestedDirectory) + XCTAssertNil( + workspace.panelDirectory(panelId: sourcePanel.id), + "Expected requested cwd to exist before shell integration reports a live cwd" + ) + XCTAssertEqual( + workspace.currentDirectory, + staleCurrentDirectory, + "Expected focused workspace cwd to remain stale before panel directory updates" + ) + + guard let splitPanel = workspace.splitTerminalPanel(fromPanelId: sourcePanel.id, + orientation: .horizontal, + focus: false + ) else { + XCTFail("Expected split terminal panel to be created") + return + } + + XCTAssertEqual( + splitPanel.requestedWorkingDirectory, + requestedDirectory, + "Expected split to inherit the source terminal's requested cwd when no reported cwd exists yet" + ) + } + + func testNewTerminalSplitSkipsFreedInheritedSurfacePointer() throws { +#if DEBUG + let workspace = Workspace() + guard let sourcePanelId = workspace.focusedPanelId, + let sourcePanel = workspace.terminalPanel(for: sourcePanelId) else { + XCTFail("Expected focused terminal panel") + return + } + + let window = try hostTerminalPanelInWindow(sourcePanel) + defer { window.orderOut(nil) } + + XCTAssertNotNil(sourcePanel.surface.surface, "Expected runtime surface before forcing stale pointer") + + sourcePanel.surface.replaceSurfaceWithFreedPointerForTesting() + XCTAssertNotNil( + sourcePanel.surface.surface, + "Expected Swift wrapper to remain non-nil while simulating a stale native surface" + ) + + let splitPanel = workspace.splitTerminalPanel(fromPanelId: sourcePanelId, + orientation: .horizontal, + focus: false + ) + + XCTAssertNotNil(splitPanel, "Expected split creation to survive a stale inherited surface pointer") + XCTAssertNil(sourcePanel.surface.surface, "Expected stale surface pointer to be quarantined") +#else + throw XCTSkip("Debug-only regression test") +#endif + } + + func testNewTerminalSurfaceSkipsFreedInheritedSurfacePointer() throws { +#if DEBUG + let workspace = Workspace() + guard let sourcePanelId = workspace.focusedPanelId, + let sourcePanel = workspace.terminalPanel(for: sourcePanelId), + let sourcePaneId = workspace.paneId(forPanelId: sourcePanelId) else { + XCTFail("Expected focused terminal panel and pane") + return + } + + let window = try hostTerminalPanelInWindow(sourcePanel) + defer { window.orderOut(nil) } + + XCTAssertNotNil(sourcePanel.surface.surface, "Expected runtime surface before forcing stale pointer") + + sourcePanel.surface.replaceSurfaceWithFreedPointerForTesting() + XCTAssertNotNil( + sourcePanel.surface.surface, + "Expected Swift wrapper to remain non-nil while simulating a stale native surface" + ) + + let createdPanel = workspace.createTerminalPanel( + inPane: sourcePaneId, + focus: false + ) + + XCTAssertNotNil(createdPanel, "Expected terminal creation to survive a stale inherited surface pointer") + XCTAssertNil(sourcePanel.surface.surface, "Expected stale surface pointer to be quarantined") +#else + throw XCTSkip("Debug-only regression test") +#endif + } +} + +@MainActor +final class WorkspaceSurfaceRegistryTests: XCTestCase { + private func waitForCondition( + timeout: TimeInterval = 2, + pollInterval: TimeInterval = 0.01, + _ condition: () -> Bool + ) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(pollInterval)) + } + return condition() + } + + private func makeWindow() -> NSWindow { + NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 280), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + } + + private func makeRenderContext() -> WorkspaceLayoutRenderContext { + WorkspaceLayoutRenderContext( + notificationStore: nil, + isWorkspaceVisible: true, + isWorkspaceInputActive: true, + isMinimalMode: false, + appearance: PanelAppearance( + dividerColor: .clear, + unfocusedOverlayNSColor: .clear, + unfocusedOverlayOpacity: 0 + ), + workspacePortalPriority: 0, + usesWorkspacePaneOverlay: false, + showSplitButtons: true + ) + } + + private func terminalDescriptor(for panelId: UUID) -> WorkspaceTerminalPaneContent { + WorkspaceTerminalPaneContent( + surfaceId: panelId, + isFocused: true, + isVisibleInUI: true, + isSplit: false, + appearance: WorkspaceTerminalPaneAppearance(PanelAppearance( + dividerColor: .clear, + unfocusedOverlayNSColor: .clear, + unfocusedOverlayOpacity: 0 + )), + hasUnreadNotification: false, + onFocus: {}, + onTriggerFlash: {} + ) + } + + private func browserDescriptor( + for panelId: UUID, + paneId: PaneID + ) -> WorkspaceBrowserPaneContent { + WorkspaceBrowserPaneContent( + surfaceId: panelId, + paneId: paneId, + isFocused: true, + isVisibleInUI: true, + prefersLocalInlineHosting: true, + portalPriority: 0, + onRequestPanelFocus: {} + ) + } + + private func markdownDescriptor(for panelId: UUID) -> WorkspaceMarkdownPaneContent { + WorkspaceMarkdownPaneContent( + surfaceId: panelId, + isVisibleInUI: true, + onRequestPanelFocus: {} + ) + } + + private func placeholderDescriptor(paneId: PaneID = PaneID()) -> WorkspacePlaceholderPaneContent { + WorkspacePlaceholderPaneContent( + paneId: paneId, + onCreateTerminal: {}, + onCreateBrowser: {} + ) + } + + private func temporaryMarkdownFile() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-surface-registry-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let fileURL = root.appendingPathComponent("note.md") + try "# retained markdown host\n".write(to: fileURL, atomically: true, encoding: .utf8) + return fileURL + } + + func testOffWindowTerminalMountKeepsHostedViewInstalled() { + let workspace = Workspace() + guard let panelId = workspace.focusedPanelId, + let panel = workspace.terminalPanel(for: panelId) else { + XCTFail("Expected focused terminal panel") + return + } + + let slotView = WorkspaceLayoutPaneContentSlotView( + frame: NSRect(x: 0, y: 0, width: 320, height: 220) + ) + + workspace.surfaceRegistry.mountContent( + .terminal(terminalDescriptor(for: panel.id)), + contentId: panel.id, + in: slotView, + activeDropZone: nil + ) + + XCTAssertTrue( + panel.hostedView.superview === slotView, + "Expected off-window mount to keep the terminal hosted view installed instead of leaving the pane empty" + ) + XCTAssertFalse(slotView.isHidden) + XCTAssertNil( + panel.surface.surface, + "Expected runtime surface attachment to remain deferred until the host enters a window" + ) + } + + func testTerminalMountAttachesAfterWindowEntryRefresh() throws { + _ = NSApplication.shared + + let workspace = Workspace() + guard let panelId = workspace.focusedPanelId, + let panel = workspace.terminalPanel(for: panelId) else { + XCTFail("Expected focused terminal panel") + return + } + + let slotView = WorkspaceLayoutPaneContentSlotView( + frame: NSRect(x: 0, y: 0, width: 320, height: 220) + ) + let descriptor = terminalDescriptor(for: panel.id) + + workspace.surfaceRegistry.mountContent( + .terminal(descriptor), + contentId: panel.id, + in: slotView, + activeDropZone: nil + ) + XCTAssertNil(panel.surface.surface) + + let window = makeWindow() + defer { window.orderOut(nil) } + let contentView = try XCTUnwrap(window.contentView) + slotView.frame = contentView.bounds + slotView.autoresizingMask = [.width, .height] + contentView.addSubview(slotView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + slotView.layoutSubtreeIfNeeded() + + workspace.surfaceRegistry.mountContent( + .terminal(descriptor), + contentId: panel.id, + in: slotView, + activeDropZone: nil + ) + + XCTAssertTrue( + waitForCondition { + panel.hostedView.window === window && + panel.hostedView.superview === slotView && + !panel.hostedView.isHidden + }, + "Expected the retained terminal host to stay attached and visible after entering a window" + ) + XCTAssertTrue(panel.hostedView.window === window) + XCTAssertTrue(panel.hostedView.superview === slotView) + XCTAssertFalse(panel.hostedView.isHidden) + } + + func testTerminalViewportLifecycleReconcileCreatesRuntimeAfterWindowEntry() throws { + _ = NSApplication.shared + + let workspace = Workspace() + guard let panelId = workspace.focusedPanelId, + let panel = workspace.terminalPanel(for: panelId) else { + XCTFail("Expected focused terminal panel") + return + } + + let slotView = WorkspaceLayoutPaneContentSlotView( + frame: NSRect(x: 0, y: 0, width: 320, height: 220) + ) + let descriptor = terminalDescriptor(for: panel.id) + + workspace.surfaceRegistry.mountContent( + .terminal(descriptor), + contentId: panel.id, + in: slotView, + activeDropZone: nil + ) + + XCTAssertNil(panel.surface.surface) + + let window = makeWindow() + defer { window.orderOut(nil) } + let contentView = try XCTUnwrap(window.contentView) + slotView.frame = contentView.bounds + slotView.autoresizingMask = [.width, .height] + contentView.addSubview(slotView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + slotView.layoutSubtreeIfNeeded() + + workspace.surfaceRegistry.reconcileViewportLifecycle( + .terminal(descriptor), + contentId: panel.id, + in: slotView, + reason: "test.windowEntry" + ) + + XCTAssertTrue( + waitForCondition { panel.surface.surface != nil }, + "Expected viewport lifecycle reconcile to create the runtime surface once the host entered a window" + ) + } + + func testTerminalViewportLifecycleReconcileDefersRuntimeUntilGeometryIsUsable() throws { + _ = NSApplication.shared + + let workspace = Workspace() + guard let panelId = workspace.focusedPanelId, + let panel = workspace.terminalPanel(for: panelId) else { + XCTFail("Expected focused terminal panel") + return + } + + let slotView = WorkspaceLayoutPaneContentSlotView(frame: .zero) + let descriptor = terminalDescriptor(for: panel.id) + + workspace.surfaceRegistry.mountContent( + .terminal(descriptor), + contentId: panel.id, + in: slotView, + activeDropZone: nil + ) + + let window = makeWindow() + defer { window.orderOut(nil) } + let contentView = try XCTUnwrap(window.contentView) + contentView.addSubview(slotView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + slotView.layoutSubtreeIfNeeded() + + workspace.surfaceRegistry.reconcileViewportLifecycle( + .terminal(descriptor), + contentId: panel.id, + in: slotView, + reason: "test.zeroGeometry" + ) + + XCTAssertNil( + panel.surface.surface, + "Expected visible runtime realization to wait for usable geometry instead of creating at zero size" + ) + + slotView.frame = contentView.bounds + slotView.autoresizingMask = [.width, .height] + contentView.layoutSubtreeIfNeeded() + slotView.layoutSubtreeIfNeeded() + + workspace.surfaceRegistry.reconcileViewportLifecycle( + .terminal(descriptor), + contentId: panel.id, + in: slotView, + reason: "test.geometryReady" + ) + + XCTAssertTrue( + waitForCondition { panel.surface.surface != nil }, + "Expected runtime realization once visible geometry became usable" + ) + } + + func testTerminalRuntimeReadyCallbackFiresAfterViewportLifecycleReconcile() throws { + _ = NSApplication.shared + + let workspace = Workspace() + guard let panelId = workspace.focusedPanelId, + let panel = workspace.terminalPanel(for: panelId) else { + XCTFail("Expected focused terminal panel") + return + } + + let slotView = WorkspaceLayoutPaneContentSlotView( + frame: NSRect(x: 0, y: 0, width: 320, height: 220) + ) + let descriptor = terminalDescriptor(for: panel.id) + let readyExpectation = expectation(description: "runtime ready") + + workspace.surfaceRegistry.mountContent( + .terminal(descriptor), + contentId: panel.id, + in: slotView, + activeDropZone: nil + ) + + let token = panel.surface.onRuntimeReady { + readyExpectation.fulfill() + } + XCTAssertNotNil(token) + + let window = makeWindow() + defer { window.orderOut(nil) } + let contentView = try XCTUnwrap(window.contentView) + slotView.frame = contentView.bounds + slotView.autoresizingMask = [.width, .height] + contentView.addSubview(slotView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + slotView.layoutSubtreeIfNeeded() + + workspace.surfaceRegistry.reconcileViewportLifecycle( + .terminal(descriptor), + contentId: panel.id, + in: slotView, + reason: "test.runtimeReady" + ) + + wait(for: [readyExpectation], timeout: 2.0) + XCTAssertNotNil(panel.surface.surface) + } + + func testBackgroundRuntimeRequestBeforeWindowEntryPersistsUntilHostCanRealize() throws { + _ = NSApplication.shared + + let workspace = Workspace() + guard let panelId = workspace.focusedPanelId, + let panel = workspace.terminalPanel(for: panelId) else { + XCTFail("Expected focused terminal panel") + return + } + + let slotView = WorkspaceLayoutPaneContentSlotView( + frame: NSRect(x: 0, y: 0, width: 320, height: 220) + ) + let hiddenDescriptor = WorkspaceTerminalPaneContent( + surfaceId: panel.id, + isFocused: false, + isVisibleInUI: false, + isSplit: false, + appearance: WorkspaceTerminalPaneAppearance(PanelAppearance( + dividerColor: .clear, + unfocusedOverlayNSColor: .clear, + unfocusedOverlayOpacity: 0 + )), + hasUnreadNotification: false, + onFocus: {}, + onTriggerFlash: {} + ) + + workspace.surfaceRegistry.mountContent( + .terminal(hiddenDescriptor), + contentId: panel.id, + in: slotView, + activeDropZone: nil + ) + + XCTAssertNil(panel.surface.surface) + + panel.surface.requestBackgroundSurfaceStartIfNeeded() + XCTAssertNil( + panel.surface.surface, + "Background runtime demand should persist instead of creating a runtime before window entry" + ) + + let window = makeWindow() + defer { window.orderOut(nil) } + let contentView = try XCTUnwrap(window.contentView) + slotView.frame = contentView.bounds + slotView.autoresizingMask = [.width, .height] + contentView.addSubview(slotView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + slotView.layoutSubtreeIfNeeded() + + XCTAssertTrue( + waitForCondition { panel.surface.surface != nil }, + "Expected a background runtime demand made before window entry to be fulfilled once the host entered a window" + ) + } + + func testPlaceholderMountInstallsContentViewAndUnmountClearsIt() { + let workspace = Workspace() + let paneId = PaneID() + let descriptor = placeholderDescriptor(paneId: paneId) + let slotView = WorkspaceLayoutPaneContentSlotView( + frame: NSRect(x: 0, y: 0, width: 320, height: 220) + ) + + workspace.surfaceRegistry.mountContent( + .placeholder(descriptor), + contentId: paneId.id, + in: slotView, + activeDropZone: nil + ) + + XCTAssertEqual(slotView.subviews.count, 1) + XCTAssertFalse(slotView.isHidden) + + workspace.surfaceRegistry.unmountContent( + .placeholder(descriptor), + contentId: paneId.id, + from: slotView + ) + + XCTAssertTrue(slotView.subviews.isEmpty) + } + + func testPlaceholderStaleUnmountDoesNotDiscardMovedHost() { + let workspace = Workspace() + let paneId = PaneID() + let descriptor = placeholderDescriptor(paneId: paneId) + let content = WorkspacePaneContent.placeholder(descriptor) + let contentId = paneId.id + let oldSlotView = WorkspaceLayoutPaneContentSlotView( + frame: NSRect(x: 0, y: 0, width: 320, height: 220) + ) + let newSlotView = WorkspaceLayoutPaneContentSlotView( + frame: NSRect(x: 0, y: 0, width: 320, height: 220) + ) + + workspace.surfaceRegistry.mountContent( + content, + contentId: contentId, + in: oldSlotView, + activeDropZone: nil + ) + let hostedView = oldSlotView.subviews.first + + workspace.surfaceRegistry.mountContent( + content, + contentId: contentId, + in: newSlotView, + activeDropZone: nil + ) + workspace.surfaceRegistry.unmountContent( + content, + contentId: contentId, + from: oldSlotView + ) + + XCTAssertTrue(hostedView?.superview === newSlotView) + XCTAssertEqual(newSlotView.subviews.count, 1) + } + + func testBrowserMountReusesRetainedContentViewAndUnmountClearsIt() throws { + let workspace = Workspace() + guard let paneId = workspace.focusedPaneId, + let panel = workspace.createBrowserPanel(inPane: paneId, focus: false) else { + XCTFail("Expected browser panel") + return + } + + let descriptor = browserDescriptor(for: panel.id, paneId: paneId) + let content = WorkspacePaneContent.browser(descriptor) + let slotView = WorkspaceLayoutPaneContentSlotView( + frame: NSRect(x: 0, y: 0, width: 320, height: 220) + ) + + workspace.surfaceRegistry.mountContent( + content, + contentId: panel.id, + in: slotView, + activeDropZone: nil + ) + + let initialView = try XCTUnwrap( + slotView.subviews.first, + "Expected browser mount to install a retained content view" + ) + XCTAssertTrue(initialView is BrowserPanelWorkspaceContentView) + XCTAssertEqual(slotView.subviews.count, 1) + XCTAssertFalse(slotView.isHidden) + + workspace.surfaceRegistry.mountContent( + content, + contentId: panel.id, + in: slotView, + activeDropZone: nil + ) + + XCTAssertEqual(slotView.subviews.count, 1) + XCTAssertTrue( + slotView.subviews.first === initialView, + "Expected browser remount to reuse the retained workspace content view" + ) + + workspace.surfaceRegistry.unmountContent( + content, + contentId: panel.id, + from: slotView + ) + + XCTAssertTrue(slotView.subviews.isEmpty) + } + + func testBrowserMountPreservesRetainedWebViewHostAcrossSlotMove() throws { + let workspace = Workspace() + guard let paneId = workspace.focusedPaneId, + let panel = workspace.createBrowserPanel(inPane: paneId, focus: false) else { + XCTFail("Expected browser panel") + return + } + + let sourceSlotView = WorkspaceLayoutPaneContentSlotView( + frame: NSRect(x: 0, y: 0, width: 320, height: 220) + ) + let destinationSlotView = WorkspaceLayoutPaneContentSlotView( + frame: NSRect(x: 0, y: 0, width: 280, height: 200) + ) + let sourceContent = WorkspacePaneContent.browser( + browserDescriptor(for: panel.id, paneId: paneId) + ) + let destinationPaneId = PaneID() + let destinationContent = WorkspacePaneContent.browser( + browserDescriptor(for: panel.id, paneId: destinationPaneId) + ) + + workspace.surfaceRegistry.mountContent( + sourceContent, + contentId: panel.id, + in: sourceSlotView, + activeDropZone: nil + ) + + let sourceHost = try XCTUnwrap( + sourceSlotView.subviews.first as? BrowserPanelWorkspaceContentView, + "Expected retained browser content view in source slot" + ) + let retainedWebViewHost = sourceHost.debugRetainedWebViewHost + + workspace.surfaceRegistry.unmountContent( + sourceContent, + contentId: panel.id, + from: sourceSlotView + ) + workspace.surfaceRegistry.mountContent( + destinationContent, + contentId: panel.id, + in: destinationSlotView, + activeDropZone: nil + ) + + let destinationHost = try XCTUnwrap( + destinationSlotView.subviews.first as? BrowserPanelWorkspaceContentView, + "Expected retained browser content view in destination slot" + ) + + XCTAssertTrue( + destinationHost === sourceHost, + "Expected browser pane move to reuse the retained browser workspace content view" + ) + XCTAssertTrue( + destinationHost.debugRetainedWebViewHost === retainedWebViewHost, + "Expected browser pane move to preserve the same retained webview host" + ) + } + + func testMarkdownMountReusesRetainedContentViewAndUnmountClearsIt() throws { + let workspace = Workspace() + let fileURL = try temporaryMarkdownFile() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + guard let paneId = workspace.focusedPaneId, + let panel = workspace.createMarkdownPanel( + inPane: paneId, + filePath: fileURL.path, + focus: false + ) else { + XCTFail("Expected markdown panel") + return + } + + let descriptor = markdownDescriptor(for: panel.id) + let content = WorkspacePaneContent.markdown(descriptor) + let slotView = WorkspaceLayoutPaneContentSlotView( + frame: NSRect(x: 0, y: 0, width: 320, height: 220) + ) + + workspace.surfaceRegistry.mountContent( + content, + contentId: panel.id, + in: slotView, + activeDropZone: nil + ) + + let initialView = try XCTUnwrap( + slotView.subviews.first, + "Expected markdown mount to install a retained hosting view" + ) + XCTAssertEqual(slotView.subviews.count, 1) + XCTAssertFalse(slotView.isHidden) + + workspace.surfaceRegistry.mountContent( + content, + contentId: panel.id, + in: slotView, + activeDropZone: nil + ) + + XCTAssertEqual(slotView.subviews.count, 1) + XCTAssertTrue( + slotView.subviews.first === initialView, + "Expected markdown remount to reuse the retained hosting view" + ) + + workspace.surfaceRegistry.unmountContent( + content, + contentId: panel.id, + from: slotView + ) + + XCTAssertTrue(slotView.subviews.isEmpty) + } + + func testSharedSlotTransitionReplacesTerminalWithPlaceholder() { + let workspace = Workspace() + guard let panelId = workspace.focusedPanelId, + let panel = workspace.terminalPanel(for: panelId) else { + XCTFail("Expected focused terminal panel") + return + } + + let slotView = WorkspaceLayoutPaneContentSlotView( + frame: NSRect(x: 0, y: 0, width: 320, height: 220) + ) + let terminalContent = WorkspacePaneContent.terminal(terminalDescriptor(for: panel.id)) + let paneId = PaneID() + let placeholderContent = WorkspacePaneContent.placeholder( + placeholderDescriptor(paneId: paneId) + ) + + workspace.surfaceRegistry.mountContent( + terminalContent, + contentId: panel.id, + in: slotView, + activeDropZone: nil + ) + XCTAssertTrue(panel.hostedView.superview === slotView) + + workspace.surfaceRegistry.unmountContent( + terminalContent, + contentId: panel.id, + from: slotView + ) + workspace.surfaceRegistry.mountContent( + placeholderContent, + contentId: paneId.id, + in: slotView, + activeDropZone: nil + ) + + XCTAssertNil(panel.hostedView.superview) + XCTAssertEqual(slotView.subviews.count, 1) + } + + func testRemoveSurfaceDetachesTerminalHostedView() { + let workspace = Workspace() + guard let panelId = workspace.focusedPanelId, + let panel = workspace.terminalPanel(for: panelId) else { + XCTFail("Expected focused terminal panel") + return + } + + let slotView = WorkspaceLayoutPaneContentSlotView( + frame: NSRect(x: 0, y: 0, width: 320, height: 220) + ) + + workspace.surfaceRegistry.mountContent( + .terminal(terminalDescriptor(for: panel.id)), + contentId: panel.id, + in: slotView, + activeDropZone: nil + ) + XCTAssertTrue(panel.hostedView.superview === slotView) + + workspace.surfaceRegistry.removeSurface(surfaceId: panel.id) + + XCTAssertNil(panel.hostedView.superview) + XCTAssertTrue(slotView.subviews.isEmpty) + } + + func testSplitThenCloseSelectedPaneKeepsSurvivingTerminalVisible() throws { + _ = NSApplication.shared + + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let sourcePanelId = workspace.focusedPanelId, + let sourcePanel = workspace.terminalPanel(for: sourcePanelId) else { + XCTFail("Expected selected workspace with a focused terminal") + return + } + + let renderContext = makeRenderContext() + let rootHost = WorkspaceLayoutRootHostView( + hostBridge: workspace.layoutInteractionHandlers, + renderSnapshot: workspace.makeLayoutRenderSnapshot(context: renderContext), + surfaceRegistry: workspace.surfaceRegistry + ) + + let window = makeWindow() + defer { window.orderOut(nil) } + let contentView = try XCTUnwrap(window.contentView, "Expected content view") + rootHost.frame = contentView.bounds + rootHost.autoresizingMask = [.width, .height] + contentView.addSubview(rootHost) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + rootHost.layoutSubtreeIfNeeded() + + XCTAssertTrue( + waitForCondition { + sourcePanel.hostedView.window === window && + sourcePanel.hostedView.superview != nil && + !sourcePanel.hostedView.isHidden + }, + "Expected the initial terminal to be mounted and visible" + ) + + guard let splitPanelId = manager.createSplit(direction: .right), + let splitPanel = workspace.terminalPanel(for: splitPanelId) else { + XCTFail("Expected cmd+d path to create a split terminal") + return + } + + rootHost.update( + hostBridge: workspace.layoutInteractionHandlers, + renderSnapshot: workspace.makeLayoutRenderSnapshot(context: renderContext), + surfaceRegistry: workspace.surfaceRegistry + ) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + rootHost.layoutSubtreeIfNeeded() + + XCTAssertTrue( + waitForCondition { + splitPanel.hostedView.window === window && + splitPanel.hostedView.superview != nil && + !splitPanel.hostedView.isHidden + }, + "Expected the split terminal to become the visible selected pane" + ) + + XCTAssertTrue( + workspace.closePanel(splitPanelId, force: true), + "Expected cmd+w close path to succeed for the selected split terminal" + ) + + rootHost.update( + hostBridge: workspace.layoutInteractionHandlers, + renderSnapshot: workspace.makeLayoutRenderSnapshot(context: renderContext), + surfaceRegistry: workspace.surfaceRegistry + ) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + rootHost.layoutSubtreeIfNeeded() + + XCTAssertEqual(workspace.paneCount, 1) + XCTAssertTrue( + waitForCondition { + sourcePanel.hostedView.window === window && + sourcePanel.hostedView.superview != nil && + !sourcePanel.hostedView.isHidden + }, + "Expected the surviving terminal to remain visible after closing the selected split" + ) + XCTAssertNil(splitPanel.hostedView.superview) + } + + func testRepeatedSplitThenCloseSelectedPaneKeepsSurvivingTerminalVisible() throws { + _ = NSApplication.shared + + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let sourcePanelId = workspace.focusedPanelId, + let sourcePanel = workspace.terminalPanel(for: sourcePanelId) else { + XCTFail("Expected selected workspace with a focused terminal") + return + } + + let renderContext = makeRenderContext() + let rootHost = WorkspaceLayoutRootHostView( + hostBridge: workspace.layoutInteractionHandlers, + renderSnapshot: workspace.makeLayoutRenderSnapshot(context: renderContext), + surfaceRegistry: workspace.surfaceRegistry + ) + + let window = makeWindow() + defer { window.orderOut(nil) } + let contentView = try XCTUnwrap(window.contentView, "Expected content view") + rootHost.frame = contentView.bounds + rootHost.autoresizingMask = [.width, .height] + contentView.addSubview(rootHost) + + func refreshHost() { + rootHost.update( + hostBridge: workspace.layoutInteractionHandlers, + renderSnapshot: workspace.makeLayoutRenderSnapshot(context: renderContext), + surfaceRegistry: workspace.surfaceRegistry + ) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + rootHost.layoutSubtreeIfNeeded() + } + + window.makeKeyAndOrderFront(nil) + refreshHost() + + XCTAssertTrue( + waitForCondition { + sourcePanel.hostedView.window === window && + sourcePanel.hostedView.superview != nil && + !sourcePanel.hostedView.isHidden + }, + "Expected the initial terminal to be mounted and visible" + ) + + for cycle in 1...3 { + guard let splitPanelId = manager.createSplit(direction: .right), + let splitPanel = workspace.terminalPanel(for: splitPanelId) else { + XCTFail("Expected cmd+d path to create a split terminal on cycle \(cycle)") + return + } + + refreshHost() + + XCTAssertTrue( + waitForCondition { + splitPanel.hostedView.window === window && + splitPanel.hostedView.superview != nil && + !splitPanel.hostedView.isHidden + }, + "Expected the split terminal to become visible on cycle \(cycle)" + ) + + XCTAssertTrue( + workspace.closePanel(splitPanelId, force: true), + "Expected cmd+w close path to succeed on cycle \(cycle)" + ) + + refreshHost() + + XCTAssertEqual( + workspace.paneCount, + 1, + "Expected one pane after closing split on cycle \(cycle)" + ) + XCTAssertTrue( + waitForCondition { + sourcePanel.hostedView.window === window && + sourcePanel.hostedView.superview != nil && + !sourcePanel.hostedView.isHidden + }, + "Expected the surviving terminal to remain visible on cycle \(cycle)" + ) + XCTAssertNil( + splitPanel.hostedView.superview, + "Expected the closed split terminal to stay detached on cycle \(cycle)" + ) + } + } + + func testSplitThenCloseRefocusedSourcePaneKeepsSplitVisible() throws { + _ = NSApplication.shared + + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let sourcePanelId = workspace.focusedPanelId, + let sourcePanel = workspace.terminalPanel(for: sourcePanelId), + let sourcePaneId = workspace.focusedPaneId else { + XCTFail("Expected selected workspace with a focused terminal") + return + } + + let renderContext = makeRenderContext() + let rootHost = WorkspaceLayoutRootHostView( + hostBridge: workspace.layoutInteractionHandlers, + renderSnapshot: workspace.makeLayoutRenderSnapshot(context: renderContext), + surfaceRegistry: workspace.surfaceRegistry + ) + + let window = makeWindow() + defer { window.orderOut(nil) } + let contentView = try XCTUnwrap(window.contentView, "Expected content view") + rootHost.frame = contentView.bounds + rootHost.autoresizingMask = [.width, .height] + contentView.addSubview(rootHost) + + func refreshHost() { + rootHost.update( + hostBridge: workspace.layoutInteractionHandlers, + renderSnapshot: workspace.makeLayoutRenderSnapshot(context: renderContext), + surfaceRegistry: workspace.surfaceRegistry + ) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + rootHost.layoutSubtreeIfNeeded() + } + + window.makeKeyAndOrderFront(nil) + refreshHost() + + XCTAssertTrue( + waitForCondition { + sourcePanel.hostedView.window === window && + sourcePanel.hostedView.superview != nil && + !sourcePanel.hostedView.isHidden + }, + "Expected the initial terminal to be mounted and visible" + ) + + guard let splitPanelId = manager.createSplit(direction: .right), + let splitPanel = workspace.terminalPanel(for: splitPanelId) else { + XCTFail("Expected cmd+d path to create a split terminal") + return + } + + refreshHost() + + XCTAssertTrue( + waitForCondition { + splitPanel.hostedView.window === window && + splitPanel.hostedView.superview != nil && + !splitPanel.hostedView.isHidden + }, + "Expected the split terminal to become visible after creation" + ) + + XCTAssertTrue(workspace.focusPane(sourcePaneId)) + workspace.focusPanel(sourcePanelId) + refreshHost() + + XCTAssertEqual(workspace.focusedPanelId, sourcePanelId) + XCTAssertTrue( + waitForCondition { + sourcePanel.hostedView.window === window && + sourcePanel.hostedView.superview != nil && + !sourcePanel.hostedView.isHidden + }, + "Expected the source terminal to become visible again after refocus" + ) + + XCTAssertTrue( + workspace.closePanel(sourcePanelId, force: true), + "Expected closing the refocused source pane to succeed" + ) + + refreshHost() + + XCTAssertEqual(workspace.paneCount, 1) + XCTAssertEqual(workspace.focusedPanelId, splitPanelId) + XCTAssertTrue( + waitForCondition { + splitPanel.hostedView.window === window && + splitPanel.hostedView.superview != nil && + !splitPanel.hostedView.isHidden + }, + "Expected the split terminal to remain visible after closing the refocused source pane" ) + XCTAssertNil(sourcePanel.hostedView.superview) } - func testNewTerminalSplitSkipsFreedInheritedSurfacePointer() throws { -#if DEBUG - let workspace = Workspace() - guard let sourcePanelId = workspace.focusedPanelId, + func testVerticalSplitThenCloseSelectedPaneKeepsSurvivingTerminalVisible() throws { + _ = NSApplication.shared + + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let sourcePanelId = workspace.focusedPanelId, let sourcePanel = workspace.terminalPanel(for: sourcePanelId) else { - XCTFail("Expected focused terminal panel") + XCTFail("Expected selected workspace with a focused terminal") return } - let window = try hostTerminalPanelInWindow(sourcePanel) + let renderContext = makeRenderContext() + let rootHost = WorkspaceLayoutRootHostView( + hostBridge: workspace.layoutInteractionHandlers, + renderSnapshot: workspace.makeLayoutRenderSnapshot(context: renderContext), + surfaceRegistry: workspace.surfaceRegistry + ) + + let window = makeWindow() defer { window.orderOut(nil) } + let contentView = try XCTUnwrap(window.contentView, "Expected content view") + rootHost.frame = contentView.bounds + rootHost.autoresizingMask = [.width, .height] + contentView.addSubview(rootHost) + + func refreshHost() { + rootHost.update( + hostBridge: workspace.layoutInteractionHandlers, + renderSnapshot: workspace.makeLayoutRenderSnapshot(context: renderContext), + surfaceRegistry: workspace.surfaceRegistry + ) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + rootHost.layoutSubtreeIfNeeded() + } - XCTAssertNotNil(sourcePanel.surface.surface, "Expected runtime surface before forcing stale pointer") + window.makeKeyAndOrderFront(nil) + refreshHost() - sourcePanel.surface.replaceSurfaceWithFreedPointerForTesting() - XCTAssertNotNil( - sourcePanel.surface.surface, - "Expected Swift wrapper to remain non-nil while simulating a stale native surface" + XCTAssertTrue( + waitForCondition { + sourcePanel.hostedView.window === window && + sourcePanel.hostedView.superview != nil && + !sourcePanel.hostedView.isHidden + }, + "Expected the initial terminal to be mounted and visible" ) - let splitPanel = workspace.newTerminalSplit( - from: sourcePanelId, - orientation: .horizontal, - focus: false + guard let splitPanelId = manager.createSplit(direction: .down), + let splitPanel = workspace.terminalPanel(for: splitPanelId) else { + XCTFail("Expected cmd+shift+d path to create a split terminal") + return + } + + refreshHost() + + XCTAssertTrue( + waitForCondition { + splitPanel.hostedView.window === window && + splitPanel.hostedView.superview != nil && + !splitPanel.hostedView.isHidden + }, + "Expected the vertical split terminal to become the visible selected pane" ) - XCTAssertNotNil(splitPanel, "Expected split creation to survive a stale inherited surface pointer") - XCTAssertNil(sourcePanel.surface.surface, "Expected stale surface pointer to be quarantined") -#else - throw XCTSkip("Debug-only regression test") -#endif + XCTAssertTrue( + workspace.closePanel(splitPanelId, force: true), + "Expected cmd+w close path to succeed for the selected vertical split terminal" + ) + + refreshHost() + + XCTAssertEqual(workspace.paneCount, 1) + XCTAssertTrue( + waitForCondition { + sourcePanel.hostedView.window === window && + sourcePanel.hostedView.superview != nil && + !sourcePanel.hostedView.isHidden + }, + "Expected the surviving terminal to remain visible after closing the selected vertical split" + ) + XCTAssertNil(splitPanel.hostedView.superview) } - func testNewTerminalSurfaceSkipsFreedInheritedSurfacePointer() throws { -#if DEBUG - let workspace = Workspace() - guard let sourcePanelId = workspace.focusedPanelId, + func testVerticalSplitThenCloseRefocusedSourcePaneKeepsSplitVisible() throws { + _ = NSApplication.shared + + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let sourcePanelId = workspace.focusedPanelId, let sourcePanel = workspace.terminalPanel(for: sourcePanelId), - let sourcePaneId = workspace.paneId(forPanelId: sourcePanelId) else { - XCTFail("Expected focused terminal panel and pane") + let sourcePaneId = workspace.focusedPaneId else { + XCTFail("Expected selected workspace with a focused terminal") return } - let window = try hostTerminalPanelInWindow(sourcePanel) + let renderContext = makeRenderContext() + let rootHost = WorkspaceLayoutRootHostView( + hostBridge: workspace.layoutInteractionHandlers, + renderSnapshot: workspace.makeLayoutRenderSnapshot(context: renderContext), + surfaceRegistry: workspace.surfaceRegistry + ) + + let window = makeWindow() defer { window.orderOut(nil) } + let contentView = try XCTUnwrap(window.contentView, "Expected content view") + rootHost.frame = contentView.bounds + rootHost.autoresizingMask = [.width, .height] + contentView.addSubview(rootHost) + + func refreshHost() { + rootHost.update( + hostBridge: workspace.layoutInteractionHandlers, + renderSnapshot: workspace.makeLayoutRenderSnapshot(context: renderContext), + surfaceRegistry: workspace.surfaceRegistry + ) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + rootHost.layoutSubtreeIfNeeded() + } - XCTAssertNotNil(sourcePanel.surface.surface, "Expected runtime surface before forcing stale pointer") + window.makeKeyAndOrderFront(nil) + refreshHost() - sourcePanel.surface.replaceSurfaceWithFreedPointerForTesting() - XCTAssertNotNil( - sourcePanel.surface.surface, - "Expected Swift wrapper to remain non-nil while simulating a stale native surface" + XCTAssertTrue( + waitForCondition { + sourcePanel.hostedView.window === window && + sourcePanel.hostedView.superview != nil && + !sourcePanel.hostedView.isHidden + }, + "Expected the initial terminal to be mounted and visible" ) - let createdPanel = workspace.newTerminalSurface( - inPane: sourcePaneId, - focus: false + guard let splitPanelId = manager.createSplit(direction: .down), + let splitPanel = workspace.terminalPanel(for: splitPanelId) else { + XCTFail("Expected cmd+shift+d path to create a split terminal") + return + } + + refreshHost() + + XCTAssertTrue( + waitForCondition { + splitPanel.hostedView.window === window && + splitPanel.hostedView.superview != nil && + !splitPanel.hostedView.isHidden + }, + "Expected the vertical split terminal to become visible after creation" ) - XCTAssertNotNil(createdPanel, "Expected terminal creation to survive a stale inherited surface pointer") - XCTAssertNil(sourcePanel.surface.surface, "Expected stale surface pointer to be quarantined") -#else - throw XCTSkip("Debug-only regression test") -#endif + XCTAssertTrue(workspace.focusPane(sourcePaneId)) + workspace.focusPanel(sourcePanelId) + refreshHost() + + XCTAssertEqual(workspace.focusedPanelId, sourcePanelId) + XCTAssertTrue( + waitForCondition { + sourcePanel.hostedView.window === window && + sourcePanel.hostedView.superview != nil && + !sourcePanel.hostedView.isHidden + }, + "Expected the source terminal to become visible again after refocus" + ) + + XCTAssertTrue( + workspace.closePanel(sourcePanelId, force: true), + "Expected closing the refocused source pane to succeed for the vertical split" + ) + + refreshHost() + + XCTAssertEqual(workspace.paneCount, 1) + XCTAssertEqual(workspace.focusedPanelId, splitPanelId) + XCTAssertTrue( + waitForCondition { + splitPanel.hostedView.window === window && + splitPanel.hostedView.superview != nil && + !splitPanel.hostedView.isHidden + }, + "Expected the vertical split terminal to remain visible after closing the refocused source pane" + ) + XCTAssertNil(sourcePanel.hostedView.superview) } } @MainActor final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { - private func makeWindow() -> NSWindow { - NSWindow( + private final class FocusHandoffTestWindow: NSWindow { + var nilFirstResponderRequestCount = 0 + + override var isKeyWindow: Bool { true } + + override func makeFirstResponder(_ responder: NSResponder?) -> Bool { + if responder == nil { + nilFirstResponderRequestCount += 1 + } + return super.makeFirstResponder(responder) + } + } + + private func makeWindow() -> FocusHandoffTestWindow { + FocusHandoffTestWindow( contentRect: NSRect(x: 0, y: 0, width: 360, height: 220), styleMask: [.titled, .closable], backing: .buffered, @@ -3211,11 +5028,56 @@ final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { return nil } + func testSwitchingFromBrowserToTerminalPreservesBrowserResponderUntilTerminalFocus() throws { + let workspace = Workspace() + guard let terminalPanelId = workspace.focusedPanelId, + let terminalPanel = workspace.terminalPanel(for: terminalPanelId), + let browserPanel = workspace.splitBrowserPanel( + fromPanelId: terminalPanelId, + orientation: .horizontal, + focus: true + ) else { + XCTFail("Expected terminal/browser split setup to succeed") + return + } + + let window = makeWindow() + defer { window.orderOut(nil) } + let contentView = try XCTUnwrap(window.contentView) + + terminalPanel.hostedView.frame = NSRect(x: 0, y: 0, width: 180, height: 220) + browserPanel.webView.frame = NSRect(x: 180, y: 0, width: 180, height: 220) + contentView.addSubview(terminalPanel.hostedView) + contentView.addSubview(browserPanel.webView) + + terminalPanel.hostedView.setVisibleInUI(true) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + + let cmuxWebView = try XCTUnwrap(browserPanel.webView as? CmuxWebView) + cmuxWebView.allowsFirstResponderAcquisition = true + XCTAssertTrue(window.makeFirstResponder(browserPanel.webView)) + XCTAssertTrue(window.firstResponder === browserPanel.webView) + XCTAssertEqual(workspace.focusedPanelId, browserPanel.id) + + let nilRequestsBeforeSwitch = window.nilFirstResponderRequestCount + workspace.focusPanel(terminalPanelId) + + XCTAssertEqual( + window.nilFirstResponderRequestCount, + nilRequestsBeforeSwitch, + "Expected browser WebView first responder handoff to avoid a nil responder bounce" + ) + XCTAssertFalse(window.firstResponder === browserPanel.webView) + XCTAssertTrue(window.firstResponder === surfaceView(in: terminalPanel.hostedView)) + } + func testTerminalFirstResponderConvergesSplitActiveStateWhenSelectionAlreadyMatches() { let workspace = Workspace() guard let leftPanelId = workspace.focusedPanelId, let leftPanel = workspace.terminalPanel(for: leftPanelId), - let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + let rightPanel = workspace.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .horizontal) else { XCTFail("Expected split terminal panels") return } @@ -3226,7 +5088,7 @@ final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { "Expected the new split panel to be selected before simulating stale focus state" ) - // Simulate the split-pane failure mode: Bonsplit already points at the right panel, + // Simulate the split-pane failure mode: WorkspaceSplit already points at the right panel, // but the active terminal state is still stale on the left panel. leftPanel.surface.setFocus(true) leftPanel.hostedView.setActive(true) @@ -3249,7 +5111,7 @@ final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { let workspace = Workspace() guard let leftPanelId = workspace.focusedPanelId, let leftPanel = workspace.terminalPanel(for: leftPanelId), - let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + let rightPanel = workspace.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .horizontal) else { XCTFail("Expected split terminal panels") return } @@ -3286,7 +5148,7 @@ final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { "Expected the clicked split pane to already be selected before simulating stale focus state" ) - // Simulate the ghost-terminal race: the right pane is selected in Bonsplit, but stale + // Simulate the ghost-terminal race: the right pane is selected in WorkspaceSplit, but stale // active state remains on the left and the right pane's AppKit focus callback never fires // after split reparent/layout churn. leftPanel.surface.setFocus(true) @@ -3324,7 +5186,7 @@ final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { let workspace = Workspace() guard let leftPanelId = workspace.focusedPanelId, let leftPanel = workspace.terminalPanel(for: leftPanelId), - let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + let rightPanel = workspace.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .horizontal) else { XCTFail("Expected split terminal panels") return } @@ -3358,7 +5220,7 @@ final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { leftPanel.hostedView.suppressReparentFocus() XCTAssertTrue(window.makeFirstResponder(leftSurfaceView)) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + XCTAssertTrue(leftPanel.hostedView.isSurfaceViewFirstResponder()) XCTAssertFalse( leftPanel.surface.debugDesiredFocusState(), @@ -3386,7 +5248,7 @@ final class WorkspaceTerminalConfigInheritanceSelectionTests: XCTestCase { let manager = TabManager() guard let workspace = manager.selectedWorkspace, let leftPanelId = workspace.focusedPanelId, - let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal), + let rightPanel = workspace.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .horizontal), let leftPaneId = workspace.paneId(forPanelId: leftPanelId) else { XCTFail("Expected workspace split setup to succeed") return @@ -3408,7 +5270,7 @@ final class WorkspaceTerminalConfigInheritanceSelectionTests: XCTestCase { guard let workspace = manager.selectedWorkspace, let terminalPanelId = workspace.focusedPanelId, let paneId = workspace.paneId(forPanelId: terminalPanelId), - let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else { + let browserPanel = workspace.createBrowserPanel(inPane: paneId, focus: true) else { XCTFail("Expected workspace browser setup to succeed") return } @@ -3439,14 +5301,14 @@ final class WorkspaceTerminalConfigInheritanceSelectionTests: XCTestCase { let manager = TabManager() guard let workspace = manager.selectedWorkspace, let leftTerminalPanelId = workspace.focusedPanelId, - let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal), + let rightTerminalPanel = workspace.splitTerminalPanel(fromPanelId: leftTerminalPanelId, orientation: .horizontal), let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else { XCTFail("Expected split setup to succeed") return } workspace.focusPanel(leftTerminalPanelId) - _ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true) + _ = workspace.createBrowserPanel(inPane: rightPaneId, focus: true) XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId) let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: rightPaneId) @@ -3480,12 +5342,12 @@ final class WorkspaceAttentionFlashTests: XCTestCase { } defaults.set(true, forKey: TmuxOverlayExperimentSettings.enabledKey) - defaults.set(TmuxOverlayExperimentTarget.bonsplitPane.rawValue, forKey: TmuxOverlayExperimentSettings.targetKey) + defaults.set(TmuxOverlayExperimentTarget.workspacePane.rawValue, forKey: TmuxOverlayExperimentSettings.targetKey) let manager = TabManager() guard let workspace = manager.selectedWorkspace, let leftPanelId = workspace.focusedPanelId, - let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + let rightPanel = workspace.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .horizontal) else { XCTFail("Expected split terminal panels") return } @@ -3549,11 +5411,11 @@ final class WorkspaceAttentionFlashTests: XCTestCase { appDelegate.notificationStore = notificationStore AppFocusState.overrideIsFocused = true defaults.set(true, forKey: TmuxOverlayExperimentSettings.enabledKey) - defaults.set(TmuxOverlayExperimentTarget.bonsplitPane.rawValue, forKey: TmuxOverlayExperimentSettings.targetKey) + defaults.set(TmuxOverlayExperimentTarget.workspacePane.rawValue, forKey: TmuxOverlayExperimentSettings.targetKey) guard let workspace = manager.selectedWorkspace, let leftPanelId = workspace.focusedPanelId, - let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + let rightPanel = workspace.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .horizontal) else { XCTFail("Expected split terminal panels") return } @@ -3589,14 +5451,20 @@ final class WorkspaceAttentionFlashTests: XCTestCase { @MainActor final class WorkspaceBrowserProfileSelectionTests: XCTestCase { - private final class RejectingCreateTabDelegate: BonsplitDelegate { - func splitTabBar(_ controller: BonsplitController, shouldCreateTab tab: Bonsplit.Tab, inPane pane: PaneID) -> Bool { + private final class RejectingCreateTabDelegate: WorkspaceLayoutDelegate { + func workspaceSplit( + shouldCreateTab tabId: TabID, + inPane pane: PaneID + ) -> Bool { false } } - private final class RejectingSplitPaneDelegate: BonsplitDelegate { - func splitTabBar(_ controller: BonsplitController, shouldSplitPane pane: PaneID, orientation: SplitOrientation) -> Bool { + private final class RejectingSplitPaneDelegate: WorkspaceLayoutDelegate { + func workspaceSplit( + shouldSplitPane pane: PaneID, + orientation: SplitOrientation + ) -> Bool { false } } @@ -3605,17 +5473,16 @@ final class WorkspaceBrowserProfileSelectionTests: XCTestCase { let workspace = Workspace() let profileA = try makeTemporaryBrowserProfile(named: "Alpha") let profileB = try makeTemporaryBrowserProfile(named: "Beta") - let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let paneId = try XCTUnwrap(workspace.focusedPaneId) let browserA = try XCTUnwrap( - workspace.newBrowserSurface( + workspace.createBrowserPanel( inPane: paneId, focus: true, preferredProfileID: profileA.id ) ) _ = try XCTUnwrap( - workspace.newBrowserSplit( - from: browserA.id, + workspace.splitBrowserPanel(fromPanelId: browserA.id, orientation: .horizontal, preferredProfileID: profileB.id, focus: true @@ -3629,11 +5496,11 @@ final class WorkspaceBrowserProfileSelectionTests: XCTestCase { ) let leftSurfaceId = try XCTUnwrap(workspace.surfaceIdFromPanelId(browserA.id)) - workspace.bonsplitController.focusPane(paneId) - workspace.bonsplitController.selectTab(leftSurfaceId) + XCTAssertTrue(workspace.focusPane(paneId)) + XCTAssertTrue(workspace.selectSurface(leftSurfaceId.id)) let created = try XCTUnwrap( - workspace.newBrowserSurface( + workspace.createBrowserPanel( inPane: paneId, focus: false ) @@ -3651,9 +5518,9 @@ final class WorkspaceBrowserProfileSelectionTests: XCTestCase { let preferredProfile = try makeTemporaryBrowserProfile(named: "Preferred") let unexpectedProfile = try makeTemporaryBrowserProfile(named: "Unexpected") - let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let paneId = try XCTUnwrap(workspace.focusedPaneId) _ = try XCTUnwrap( - workspace.newBrowserSurface( + workspace.createBrowserPanel( inPane: paneId, focus: false, preferredProfileID: preferredProfile.id @@ -3662,8 +5529,8 @@ final class WorkspaceBrowserProfileSelectionTests: XCTestCase { XCTAssertEqual(workspace.preferredBrowserProfileID, preferredProfile.id) let rejectingDelegate = RejectingCreateTabDelegate() - workspace.bonsplitController.delegate = rejectingDelegate - let created = workspace.newBrowserSurface( + workspace.setLayoutDelegateForTesting(rejectingDelegate) + let created = workspace.createBrowserPanel( inPane: paneId, focus: false, preferredProfileID: unexpectedProfile.id @@ -3682,9 +5549,9 @@ final class WorkspaceBrowserProfileSelectionTests: XCTestCase { let preferredProfile = try makeTemporaryBrowserProfile(named: "Preferred") let unexpectedProfile = try makeTemporaryBrowserProfile(named: "Unexpected") - let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let paneId = try XCTUnwrap(workspace.focusedPaneId) let browser = try XCTUnwrap( - workspace.newBrowserSurface( + workspace.createBrowserPanel( inPane: paneId, focus: true, preferredProfileID: preferredProfile.id @@ -3693,9 +5560,8 @@ final class WorkspaceBrowserProfileSelectionTests: XCTestCase { XCTAssertEqual(workspace.preferredBrowserProfileID, preferredProfile.id) let rejectingDelegate = RejectingSplitPaneDelegate() - workspace.bonsplitController.delegate = rejectingDelegate - let created = workspace.newBrowserSplit( - from: browser.id, + workspace.setLayoutDelegateForTesting(rejectingDelegate) + let created = workspace.splitBrowserPanel(fromPanelId: browser.id, orientation: .horizontal, preferredProfileID: unexpectedProfile.id, focus: false @@ -3721,6 +5587,15 @@ final class WorkspacePanelGitBranchTests: XCTestCase { wait(for: [expectation], timeout: 1.0) } + private func temporaryMarkdownFile() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-panel-git-branch-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let fileURL = root.appendingPathComponent("note.md") + try "# workspace\n".write(to: fileURL, atomically: true, encoding: .utf8) + return fileURL + } + func testBrowserSplitWithFocusFalsePreservesOriginalFocusedPanel() { let workspace = Workspace() guard let originalFocusedPanelId = workspace.focusedPanelId else { @@ -3728,8 +5603,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { return } - guard let browserSplitPanel = workspace.newBrowserSplit( - from: originalFocusedPanelId, + guard let browserSplitPanel = workspace.splitBrowserPanel(fromPanelId: originalFocusedPanelId, orientation: .horizontal, focus: false ) else { @@ -3754,8 +5628,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { return } - guard let terminalSplitPanel = workspace.newTerminalSplit( - from: originalFocusedPanelId, + guard let terminalSplitPanel = workspace.splitTerminalPanel(fromPanelId: originalFocusedPanelId, orientation: .horizontal, focus: false ) else { @@ -3797,7 +5670,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { "Detaching the last surface should not auto-create a replacement panel" ) XCTAssertNil(workspace.surfaceIdFromPanelId(panelId)) - XCTAssertEqual(workspace.bonsplitController.tabs(inPane: paneId).count, 0) + XCTAssertEqual(workspace.surfaceIds(inPane: paneId).count, 0) drainMainQueue() drainMainQueue() @@ -3817,7 +5690,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { func testDetachSurfaceWithRemainingPanelsSkipsDelayedFocusReconcile() { let workspace = Workspace() guard let originalPanelId = workspace.focusedPanelId, - let movedPanel = workspace.newTerminalSplit(from: originalPanelId, orientation: .horizontal) else { + let movedPanel = workspace.splitTerminalPanel(fromPanelId: originalPanelId, orientation: .horizontal) else { XCTFail("Expected two panels before detach") return } @@ -3871,7 +5744,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { ) let destination = Workspace() - guard let destinationPane = destination.bonsplitController.allPaneIds.first else { + guard let destinationPane = destination.paneIds.first else { XCTFail("Expected destination pane") return } @@ -3884,13 +5757,12 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertEqual(attachedPanelId, panelId) XCTAssertEqual(destination.panelTitle(panelId: panelId), "detached-runtime-title") - guard let attachedTabId = destination.surfaceIdFromPanelId(panelId), - let attachedTab = destination.bonsplitController.tab(attachedTabId) else { + guard destination.surfaceIdFromPanelId(panelId) != nil else { XCTFail("Expected attached tab mapping") return } - XCTAssertEqual(attachedTab.title, "detached-runtime-title") - XCTAssertFalse(attachedTab.hasCustomTitle) + XCTAssertEqual(destination.panelTitle(panelId: panelId), "detached-runtime-title") + XCTAssertFalse(destination.hasPanelCustomTitle(panelId: panelId)) } func testBrowserSplitWithFocusFalseRecoversFromDelayedStaleSelection() { @@ -3904,8 +5776,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { return } - guard let browserSplitPanel = workspace.newBrowserSplit( - from: originalFocusedPanelId, + guard let browserSplitPanel = workspace.splitBrowserPanel(fromPanelId: originalFocusedPanelId, orientation: .horizontal, focus: false ) else { @@ -3913,17 +5784,14 @@ final class WorkspacePanelGitBranchTests: XCTestCase { return } guard let splitPaneId = workspace.paneId(forPanelId: browserSplitPanel.id), - let splitTabId = workspace.surfaceIdFromPanelId(browserSplitPanel.id), - let splitTab = workspace.bonsplitController - .tabs(inPane: splitPaneId) - .first(where: { $0.id == splitTabId }) else { + let splitTabId = workspace.surfaceIdFromPanelId(browserSplitPanel.id) else { XCTFail("Expected split pane/tab mapping") return } - // Simulate one delayed stale split-selection callback from bonsplit. + // Simulate one delayed stale split-selection callback from WorkspaceSplit. DispatchQueue.main.async { - workspace.splitTabBar(workspace.bonsplitController, didSelectTab: splitTab, inPane: splitPaneId) + workspace.workspaceSplit(didSelectTab: splitTabId, inPane: splitPaneId) } drainMainQueue() @@ -3936,13 +5804,13 @@ final class WorkspacePanelGitBranchTests: XCTestCase { "Expected non-focus split to reassert the pre-split focused panel" ) XCTAssertEqual( - workspace.bonsplitController.focusedPaneId, + workspace.focusedPaneId, originalPaneId, "Expected focused pane to converge back to the pre-split pane" ) XCTAssertEqual( - workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id, - workspace.surfaceIdFromPanelId(originalFocusedPanelId), + workspace.selectedSurfaceId(inPane: originalPaneId), + workspace.surfaceIdFromPanelId(originalFocusedPanelId)?.id, "Expected selected tab to converge back to the pre-split focused panel" ) } @@ -3954,8 +5822,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { return } - guard let browserSplitPanel = workspace.newBrowserSplit( - from: originalFocusedPanelId, + guard let browserSplitPanel = workspace.splitBrowserPanel(fromPanelId: originalFocusedPanelId, orientation: .horizontal, focus: false ) else { @@ -3984,7 +5851,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { return } - guard let newPanel = workspace.newTerminalSurface(inPane: originalPaneId, focus: false) else { + guard let newPanel = workspace.createTerminalPanel(inPane: originalPaneId, focus: false) else { XCTFail("Expected terminal surface to be created") return } @@ -4000,8 +5867,8 @@ final class WorkspacePanelGitBranchTests: XCTestCase { "Expected non-focus terminal surface creation to preserve the existing focused panel" ) XCTAssertEqual( - workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id, - workspace.surfaceIdFromPanelId(originalFocusedPanelId), + workspace.selectedSurfaceId(inPane: originalPaneId), + workspace.surfaceIdFromPanelId(originalFocusedPanelId)?.id, "Expected selected tab to stay on the original focused panel" ) } @@ -4014,7 +5881,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { return } - guard let newPanel = workspace.newBrowserSurface(inPane: originalPaneId, focus: false) else { + guard let newPanel = workspace.createBrowserPanel(inPane: originalPaneId, focus: false) else { XCTFail("Expected browser surface to be created") return } @@ -4030,12 +5897,186 @@ final class WorkspacePanelGitBranchTests: XCTestCase { "Expected non-focus browser surface creation to preserve the existing focused panel" ) XCTAssertEqual( - workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id, - workspace.surfaceIdFromPanelId(originalFocusedPanelId), + workspace.selectedSurfaceId(inPane: originalPaneId), + workspace.surfaceIdFromPanelId(originalFocusedPanelId)?.id, "Expected selected tab to stay on the original focused panel" ) } + func testSplitPaneMovingTabHandlerSelfSplitLastTerminalCreatesReplacementTerminal() { + let workspace = Workspace() + guard let sourcePanelId = workspace.focusedPanelId, + let sourcePaneId = workspace.focusedPaneId else { + XCTFail("Expected initial focused terminal and pane") + return + } + + guard let newPaneId = workspace.layoutInteractionHandlers.splitPaneMovingTabHandler( + sourcePaneId, + .vertical, + TabID(id: sourcePanelId), + false, + true + ) else { + XCTFail("Expected drag self-split to create a new pane") + return + } + + let sourcePaneSurfaces = workspace.surfaceIds(inPane: sourcePaneId) + XCTAssertEqual(sourcePaneSurfaces.count, 1, "Expected source pane to keep a replacement surface") + XCTAssertEqual(workspace.surfaceIds(inPane: newPaneId), [sourcePanelId], "Expected moved terminal in the new pane") + + guard let replacementPanelId = sourcePaneSurfaces.first else { + XCTFail("Expected replacement terminal") + return + } + + XCTAssertNotEqual(replacementPanelId, sourcePanelId) + XCTAssertNotNil(workspace.terminalPanel(for: replacementPanelId)) + XCTAssertEqual(workspace.selectedSurfaceId(inPane: sourcePaneId), replacementPanelId) + } + + func testSplitPaneMovingTabHandlerSelfSplitLastBrowserCreatesReplacementBrowser() { + let workspace = Workspace() + guard let sourcePaneId = workspace.focusedPaneId, + let originalTerminalId = workspace.focusedPanelId, + let browserPanel = workspace.createBrowserPanel(inPane: sourcePaneId, focus: true) else { + XCTFail("Expected initial pane and browser surface") + return + } + + XCTAssertTrue(workspace.closePanel(originalTerminalId, force: true), "Expected bootstrap terminal to close") + drainMainQueue() + drainMainQueue() + + guard let newPaneId = workspace.layoutInteractionHandlers.splitPaneMovingTabHandler( + sourcePaneId, + .vertical, + TabID(id: browserPanel.id), + false, + true + ) else { + XCTFail("Expected browser drag self-split to create a new pane") + return + } + + let sourcePaneSurfaces = workspace.surfaceIds(inPane: sourcePaneId) + XCTAssertEqual(sourcePaneSurfaces.count, 1, "Expected source pane to keep a replacement browser") + XCTAssertEqual(workspace.surfaceIds(inPane: newPaneId), [browserPanel.id], "Expected moved browser in the new pane") + + guard let replacementPanelId = sourcePaneSurfaces.first, + let replacementBrowser = workspace.browserPanel(for: replacementPanelId) else { + XCTFail("Expected replacement browser") + return + } + + XCTAssertNotEqual(replacementPanelId, browserPanel.id) + XCTAssertEqual(replacementBrowser.profileID, browserPanel.profileID) + XCTAssertTrue(replacementBrowser.shouldRenderWebView) + XCTAssertEqual(workspace.selectedSurfaceId(inPane: sourcePaneId), replacementPanelId) + } + + func testSplitPaneMovingTabHandlerSelfSplitLastMarkdownCreatesReplacementMarkdown() throws { + let workspace = Workspace() + let fileURL = try temporaryMarkdownFile() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + guard let sourcePaneId = workspace.focusedPaneId, + let originalTerminalId = workspace.focusedPanelId, + let markdownPanel = workspace.createMarkdownPanel( + inPane: sourcePaneId, + filePath: fileURL.path, + focus: true + ) else { + XCTFail("Expected initial pane and markdown surface") + return + } + + XCTAssertTrue(workspace.closePanel(originalTerminalId, force: true), "Expected bootstrap terminal to close") + drainMainQueue() + drainMainQueue() + + guard let newPaneId = workspace.layoutInteractionHandlers.splitPaneMovingTabHandler( + sourcePaneId, + .vertical, + TabID(id: markdownPanel.id), + false, + true + ) else { + XCTFail("Expected markdown drag self-split to create a new pane") + return + } + + let sourcePaneSurfaces = workspace.surfaceIds(inPane: sourcePaneId) + XCTAssertEqual(sourcePaneSurfaces.count, 1, "Expected source pane to keep a replacement markdown panel") + XCTAssertEqual(workspace.surfaceIds(inPane: newPaneId), [markdownPanel.id], "Expected moved markdown in the new pane") + + guard let replacementPanelId = sourcePaneSurfaces.first, + let replacementMarkdown = workspace.markdownPanel(for: replacementPanelId) else { + XCTFail("Expected replacement markdown panel") + return + } + + XCTAssertNotEqual(replacementPanelId, markdownPanel.id) + XCTAssertEqual(replacementMarkdown.filePath, markdownPanel.filePath) + XCTAssertEqual(workspace.selectedSurfaceId(inPane: sourcePaneId), replacementPanelId) + } + + func testSplitPaneMovingTabHandlerDoesNotCreateReplacementWhenSourcePaneStillHasOtherTabs() { + let workspace = Workspace() + guard let sourcePaneId = workspace.focusedPaneId, + let sourcePanelId = workspace.focusedPanelId, + let remainingPanel = workspace.createTerminalPanel(inPane: sourcePaneId, focus: false) else { + XCTFail("Expected source pane to contain two terminal tabs") + return + } + + guard let newPaneId = workspace.layoutInteractionHandlers.splitPaneMovingTabHandler( + sourcePaneId, + .vertical, + TabID(id: sourcePanelId), + false, + true + ) else { + XCTFail("Expected split to succeed") + return + } + + XCTAssertEqual(workspace.surfaceIds(inPane: sourcePaneId), [remainingPanel.id]) + XCTAssertEqual(workspace.surfaceIds(inPane: newPaneId), [sourcePanelId]) + } + + func testSplitSurfaceDoesNotApplyDragReplacementSemantics() { + let workspace = Workspace() + guard let sourcePaneId = workspace.focusedPaneId, + let originalTerminalId = workspace.focusedPanelId, + let browserPanel = workspace.createBrowserPanel(inPane: sourcePaneId, focus: true) else { + XCTFail("Expected initial pane and browser surface") + return + } + + XCTAssertTrue(workspace.closePanel(originalTerminalId, force: true), "Expected bootstrap terminal to close") + drainMainQueue() + drainMainQueue() + + guard let newPaneId = workspace.splitSurface( + panelId: browserPanel.id, + inPane: sourcePaneId, + orientation: .vertical, + insertFirst: false, + focusNewPane: true + ) else { + XCTFail("Expected direct splitSurface call to succeed") + return + } + + XCTAssertEqual( + workspace.surfaceIds(inPane: sourcePaneId).count, + 0, + "Expected pure splitSurface to avoid drag-only replacement behavior" + ) + XCTAssertEqual(workspace.surfaceIds(inPane: newPaneId), [browserPanel.id]) + } + func testClosingFocusedSplitRestoresBranchForRemainingFocusedPanel() { let workspace = Workspace() guard let firstPanelId = workspace.focusedPanelId else { @@ -4044,7 +6085,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { } workspace.updatePanelGitBranch(panelId: firstPanelId, branch: "main", isDirty: false) - guard let secondPanel = workspace.newTerminalSplit(from: firstPanelId, orientation: .horizontal) else { + guard let secondPanel = workspace.splitTerminalPanel(fromPanelId: firstPanelId, orientation: .horizontal) else { XCTFail("Expected split panel to be created") return } @@ -4068,7 +6109,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { } workspace.updatePanelGitBranch(panelId: leftPanelId, branch: "main", isDirty: false) - guard let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + guard let rightPanel = workspace.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .horizontal) else { XCTFail("Expected split panel to be created") return } @@ -4211,7 +6252,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { let workspace = Workspace() guard let firstPanelId = workspace.focusedPanelId, let paneId = workspace.paneId(forPanelId: firstPanelId), - let secondPanel = workspace.newTerminalSurface(inPane: paneId, focus: false) else { + let secondPanel = workspace.createTerminalPanel(inPane: paneId, focus: false) else { XCTFail("Expected focused panel and a second panel") return } @@ -4244,10 +6285,10 @@ final class WorkspacePanelGitBranchTests: XCTestCase { let workspace = Workspace() guard let leftFirstPanelId = workspace.focusedPanelId, let leftPaneId = workspace.paneId(forPanelId: leftFirstPanelId), - let rightFirstPanel = workspace.newTerminalSplit(from: leftFirstPanelId, orientation: .horizontal), + let rightFirstPanel = workspace.splitTerminalPanel(fromPanelId: 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 { + let leftSecondPanel = workspace.createTerminalPanel(inPane: leftPaneId, focus: false), + let rightSecondPanel = workspace.createTerminalPanel(inPane: rightPaneId, focus: false) else { XCTFail("Expected panes and panels for ordering test") return } @@ -4286,13 +6327,12 @@ final class WorkspacePanelGitBranchTests: XCTestCase { workspace.updatePanelDirectory(panelId: leftPanelId, directory: leftLiveDirectory) - guard let rightSplitPanel = workspace.newTerminalSplit( - from: leftPanelId, + guard let rightSplitPanel = workspace.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .horizontal, focus: false ), let rightPaneId = workspace.paneId(forPanelId: rightSplitPanel.id), - let rightRequestedPanel = workspace.newTerminalSurface( + let rightRequestedPanel = workspace.createTerminalPanel( inPane: rightPaneId, focus: false, workingDirectory: rightRequestedDirectory @@ -4348,7 +6388,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { guard let firstPanelId = workspace.focusedPanelId, let paneId = workspace.paneId(forPanelId: firstPanelId), - let requestedPanel = workspace.newTerminalSurface( + let requestedPanel = workspace.createTerminalPanel( inPane: paneId, focus: false, workingDirectory: requestedDirectory @@ -4376,10 +6416,10 @@ final class WorkspacePanelGitBranchTests: XCTestCase { let workspace = Workspace() guard let leftFirstPanelId = workspace.focusedPanelId, let leftPaneId = workspace.paneId(forPanelId: leftFirstPanelId), - let rightFirstPanel = workspace.newTerminalSplit(from: leftFirstPanelId, orientation: .horizontal), + let rightFirstPanel = workspace.splitTerminalPanel(fromPanelId: 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 { + let leftSecondPanel = workspace.createTerminalPanel(inPane: leftPaneId, focus: false), + let rightSecondPanel = workspace.createTerminalPanel(inPane: rightPaneId, focus: false) else { XCTFail("Expected panes and panels for precomputed ordering test") return } @@ -4428,7 +6468,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { let workspace = Workspace() guard let leftPanelId = workspace.focusedPanelId, let leftPaneId = workspace.paneId(forPanelId: leftPanelId), - let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + let rightPanel = workspace.splitTerminalPanel(fromPanelId: leftPanelId, orientation: .horizontal) else { XCTFail("Expected left/right split panes") return } @@ -4437,172 +6477,12 @@ final class WorkspacePanelGitBranchTests: XCTestCase { workspace.updatePanelGitBranch(panelId: rightPanel.id, branch: "branch2", isDirty: false) XCTAssertEqual(workspace.sidebarGitBranchesInDisplayOrder().map(\.branch), ["branch1", "branch2"]) - XCTAssertTrue(workspace.bonsplitController.closePane(leftPaneId)) + XCTAssertTrue(workspace.closePane(leftPaneId)) XCTAssertEqual(workspace.sidebarGitBranchesInDisplayOrder().map(\.branch), ["branch2"]) } } -final class WorkspaceMountPolicyTests: XCTestCase { - func testDefaultPolicyMountsOnlySelectedWorkspace() { - let a = UUID() - let b = UUID() - let orderedTabIds: [UUID] = [a, b] - - let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( - current: [a], - selected: b, - pinnedIds: [], - orderedTabIds: orderedTabIds, - isCycleHot: false, - maxMounted: WorkspaceMountPolicy.maxMountedWorkspaces - ) - - XCTAssertEqual(next, [b]) - } - - func testSelectedWorkspaceMovesToFrontAndMountCountIsBounded() { - let a = UUID() - let b = UUID() - let c = UUID() - let orderedTabIds: [UUID] = [a, b, c] - - let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( - current: [a, b, c], - selected: c, - pinnedIds: [], - orderedTabIds: orderedTabIds, - isCycleHot: false, - maxMounted: 2 - ) - - XCTAssertEqual(next, [c, a]) - } - - func testMissingWorkspacesArePruned() { - let a = UUID() - let b = UUID() - - let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( - current: [b, a], - selected: nil, - pinnedIds: [], - orderedTabIds: [a], - isCycleHot: false, - maxMounted: 2 - ) - - XCTAssertEqual(next, [a]) - } - - func testSelectedWorkspaceIsInsertedWhenAbsentFromCurrentCache() { - let a = UUID() - let b = UUID() - let orderedTabIds: [UUID] = [a, b] - - let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( - current: [a], - selected: b, - pinnedIds: [], - orderedTabIds: orderedTabIds, - isCycleHot: false, - maxMounted: 2 - ) - - XCTAssertEqual(next, [b, a]) - } - - func testMaxMountedIsClampedToAtLeastOne() { - let a = UUID() - let b = UUID() - let orderedTabIds: [UUID] = [a, b] - - let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( - current: [a, b], - selected: nil, - pinnedIds: [], - orderedTabIds: orderedTabIds, - isCycleHot: false, - maxMounted: 0 - ) - - XCTAssertEqual(next, [a]) - } - - func testCycleHotModeKeepsOnlySelectedWhenNoPinnedHandoff() { - let a = UUID() - let b = UUID() - let c = UUID() - let d = UUID() - let orderedTabIds: [UUID] = [a, b, c, d] - - let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( - current: [a], - selected: c, - pinnedIds: [], - orderedTabIds: orderedTabIds, - isCycleHot: true, - maxMounted: WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle - ) - - XCTAssertEqual(next, [c]) - } - - func testCycleHotModeRespectsMaxMountedLimit() { - let a = UUID() - let b = UUID() - let c = UUID() - let orderedTabIds: [UUID] = [a, b, c] - - let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( - current: [a, b, c], - selected: b, - pinnedIds: [], - orderedTabIds: orderedTabIds, - isCycleHot: true, - maxMounted: 2 - ) - - XCTAssertEqual(next, [b]) - } - - func testPinnedIdsAreRetainedAcrossReconcile() { - let a = UUID() - let b = UUID() - let c = UUID() - let orderedTabIds: [UUID] = [a, b, c] - - let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( - current: [a], - selected: c, - pinnedIds: [a], - orderedTabIds: orderedTabIds, - isCycleHot: false, - maxMounted: 2 - ) - - XCTAssertEqual(next, [c, a]) - } - - func testCycleHotModeKeepsRetiringWorkspaceWhenPinned() { - let a = UUID() - let b = UUID() - let orderedTabIds: [UUID] = [a, b] - - let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( - current: [a], - selected: b, - pinnedIds: [a], - orderedTabIds: orderedTabIds, - isCycleHot: true, - maxMounted: WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle - ) - - XCTAssertEqual(next, [b, a]) - } -} - - @MainActor final class SidebarWorkspaceShortcutHintMetricsTests: XCTestCase { override func setUp() { diff --git a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift index 73e4a5e1fb43..26473f17d53b 100644 --- a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift +++ b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift @@ -13,6 +13,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { override func setUp() { super.setUp() continueAfterFailure = false + XCUIApplication().terminate() dataPath = "/tmp/cmux-ui-test-goto-split-\(UUID().uuidString).json" try? FileManager.default.removeItem(atPath: dataPath) socketPath = "/tmp/cmux-ui-test-socket-\(UUID().uuidString).sock" @@ -183,6 +184,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_INPUT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_BROWSER_URL"] = focusedInputRoundTripPageURL launchAndEnsureForeground(app) XCTAssertTrue( @@ -198,7 +200,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { ], timeout: 12.0 ), - "Expected setup data including focused page input to be written" + "Expected setup data including focused page input to be written. path=\(dataPath) data=\(loadData() ?? [:])" ) guard let setup = loadData() else { @@ -209,14 +211,6 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { XCTAssertEqual(setup["webViewFocused"], "true", "Expected WKWebView to be first responder for this test") XCTAssertEqual(setup["webInputFocusSeeded"], "true", "Expected test page input to be focused before Cmd+L") - guard let expectedInputId = setup["webInputFocusElementId"], !expectedInputId.isEmpty else { - XCTFail("Missing webInputFocusElementId in setup data") - return - } - guard let expectedSecondaryInputId = setup["webInputFocusSecondaryElementId"], !expectedSecondaryInputId.isEmpty else { - XCTFail("Missing webInputFocusSecondaryElementId in setup data") - return - } guard let secondaryClickOffsetXRaw = setup["webInputFocusSecondaryClickOffsetX"], let secondaryClickOffsetYRaw = setup["webInputFocusSecondaryClickOffsetY"], let secondaryClickOffsetX = Double(secondaryClickOffsetXRaw), @@ -240,38 +234,33 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) if !waitForDataMatch(timeout: 2.0, predicate: { data in data["webViewFocusedAfterAddressBarExit"] == "true" && - data["addressBarExitActiveElementId"] == expectedInputId && - data["addressBarExitActiveElementEditable"] == "true" + data["browserPageTitle"] == "cmux-ui-focus-primary:cmux-ui-focus-primary" }) { app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) } let restoredExpectedInput = waitForDataMatch(timeout: 6.0) { data in data["webViewFocusedAfterAddressBarExit"] == "true" && - data["addressBarExitActiveElementId"] == expectedInputId && - data["addressBarExitActiveElementEditable"] == "true" + data["browserPageTitle"] == "cmux-ui-focus-primary:cmux-ui-focus-primary" } if !restoredExpectedInput { let snapshot = loadData() ?? [:] XCTFail( "Expected Escape to restore focus to the previously focused page input. " + - "expectedInputId=\(expectedInputId) " + "webViewFocusedAfterAddressBarExit=\(snapshot["webViewFocusedAfterAddressBarExit"] ?? "nil") " + - "addressBarExitActiveElementId=\(snapshot["addressBarExitActiveElementId"] ?? "nil") " + - "addressBarExitActiveElementTag=\(snapshot["addressBarExitActiveElementTag"] ?? "nil") " + - "addressBarExitActiveElementType=\(snapshot["addressBarExitActiveElementType"] ?? "nil") " + - "addressBarExitActiveElementEditable=\(snapshot["addressBarExitActiveElementEditable"] ?? "nil") " + - "addressBarExitTrackedFocusStateId=\(snapshot["addressBarExitTrackedFocusStateId"] ?? "nil") " + - "addressBarExitFocusTrackerInstalled=\(snapshot["addressBarExitFocusTrackerInstalled"] ?? "nil") " + - "addressBarFocusActiveElementId=\(snapshot["addressBarFocusActiveElementId"] ?? "nil") " + - "addressBarFocusTrackedFocusStateId=\(snapshot["addressBarFocusTrackedFocusStateId"] ?? "nil") " + - "addressBarFocusFocusTrackerInstalled=\(snapshot["addressBarFocusFocusTrackerInstalled"] ?? "nil") " + - "webInputFocusElementId=\(snapshot["webInputFocusElementId"] ?? "nil") " + - "webInputFocusTrackerInstalled=\(snapshot["webInputFocusTrackerInstalled"] ?? "nil") " + - "webInputFocusTrackedStateId=\(snapshot["webInputFocusTrackedStateId"] ?? "nil")" + "browserPageTitle=\(snapshot["browserPageTitle"] ?? "nil") " + + "browserPageURL=\(snapshot["browserPageURL"] ?? "nil")" ) } + app.typeText("z") + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["browserPageTitle"] == "cmux-ui-focus-primary:cmux-ui-focus-primaryz" + }, + "Expected typing after Escape to stay in the primary page input" + ) + let window = app.windows.firstMatch XCTAssertTrue( window.waitForExistence(timeout: 6.0), @@ -285,31 +274,30 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { .click() RunLoop.current.run(until: Date().addingTimeInterval(0.15)) - app.typeKey("l", modifierFlags: [.command]) - let clickMovedFocusToSecondary = waitForDataMatch(timeout: 6.0) { data in - data["webViewFocusedAfterAddressBarFocus"] == "false" && - data["addressBarFocusActiveElementId"] == expectedSecondaryInputId && - data["addressBarFocusActiveElementEditable"] == "true" + let clickedSecondary = waitForDataMatch(timeout: 6.0) { data in + data["browserPageTitle"] == "cmux-ui-focus-secondary:cmux-ui-focus-secondary" } - if !clickMovedFocusToSecondary { + if !clickedSecondary { let snapshot = loadData() ?? [:] XCTFail( "Expected post-escape click to focus secondary page input before Cmd+L. " + - "secondaryInputId=\(expectedSecondaryInputId) " + - "addressBarFocusActiveElementId=\(snapshot["addressBarFocusActiveElementId"] ?? "nil") " + - "addressBarFocusActiveElementTag=\(snapshot["addressBarFocusActiveElementTag"] ?? "nil") " + - "addressBarFocusActiveElementType=\(snapshot["addressBarFocusActiveElementType"] ?? "nil") " + - "addressBarFocusActiveElementEditable=\(snapshot["addressBarFocusActiveElementEditable"] ?? "nil") " + - "addressBarFocusTrackedFocusStateId=\(snapshot["addressBarFocusTrackedFocusStateId"] ?? "nil") " + - "addressBarFocusFocusTrackerInstalled=\(snapshot["addressBarFocusFocusTrackerInstalled"] ?? "nil")" + "browserPageTitle=\(snapshot["browserPageTitle"] ?? "nil") " + + "browserPageURL=\(snapshot["browserPageURL"] ?? "nil")" ) } + app.typeKey("l", modifierFlags: [.command]) + XCTAssertTrue( + waitForDataMatch(timeout: 5.0) { data in + data["webViewFocusedAfterAddressBarFocus"] == "false" + }, + "Expected Cmd+L to focus omnibar after clicking the secondary page input" + ) + app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) if !waitForDataMatch(timeout: 2.0, predicate: { data in data["webViewFocusedAfterAddressBarExit"] == "true" && - data["addressBarExitActiveElementId"] == expectedSecondaryInputId && - data["addressBarExitActiveElementEditable"] == "true" + data["browserPageTitle"] == "cmux-ui-focus-secondary:cmux-ui-focus-secondary" }) { app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) } @@ -317,11 +305,18 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { XCTAssertTrue( waitForDataMatch(timeout: 6.0) { data in data["webViewFocusedAfterAddressBarExit"] == "true" && - data["addressBarExitActiveElementId"] == expectedSecondaryInputId && - data["addressBarExitActiveElementEditable"] == "true" + data["browserPageTitle"] == "cmux-ui-focus-secondary:cmux-ui-focus-secondary" }, "Expected Escape to restore focus to the clicked secondary page input" ) + + app.typeText("y") + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["browserPageTitle"] == "cmux-ui-focus-secondary:cmux-ui-focus-secondaryy" + }, + "Expected typing after Escape to stay in the clicked secondary page input" + ) } func testCmdLOpensBrowserWhenTerminalFocused() { @@ -942,6 +937,278 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { ) } + func testEscapeAfterBrowserFindPaneRoundTripRestoresPageInputFocus() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_INPUT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_BROWSER_URL"] = focusedInputRoundTripPageURL + launchAndEnsureForeground(app) + + XCTAssertTrue( + waitForData( + keys: [ + "browserPanelId", + "terminalPaneId", + "webViewFocused", + "webInputFocusSeeded", + "webInputFocusElementId" + ], + timeout: 12.0 + ), + "Expected setup data including focused page input to be written. path=\(dataPath) data=\(loadData() ?? [:])" + ) + + guard let setup = loadData() else { + XCTFail("Missing goto_split setup data") + return + } + + XCTAssertEqual(setup["webViewFocused"], "true", "Expected WKWebView to be first responder for this test") + XCTAssertEqual(setup["webInputFocusSeeded"], "true", "Expected test page input to be focused before Cmd+F") + + guard let expectedTerminalPaneId = setup["terminalPaneId"] else { + XCTFail("Missing terminalPaneId in setup data") + return + } + + app.typeKey("f", modifierFlags: [.command]) + let findField = app.textFields["BrowserFindSearchTextField"].firstMatch + XCTAssertTrue(findField.waitForExistence(timeout: 6.0), "Expected browser find field after Cmd+F") + + app.typeText("seed") + XCTAssertTrue( + waitForCondition(timeout: 4.0) { + ((findField.value as? String) ?? "") == "seed" + }, + "Expected browser find field to capture initial typing. value=\(String(describing: findField.value))" + ) + + app.typeKey("h", modifierFlags: [.command, .control]) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["focusedPaneId"] == expectedTerminalPaneId && + data["focusedPanelKind"] == "terminal" && + data["browserFindNeedle"] == "seed" + }, + "Expected Cmd+Ctrl+H to focus the terminal pane while browser find stays open. data=\(loadData() ?? [:])" + ) + + app.typeKey("l", modifierFlags: [.command, .control]) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["focusedPanelKind"] == "browser" && + data["browserFindNeedle"] == "seed" + }, + "Expected Cmd+Ctrl+L to return to the browser pane with browser find still active. data=\(loadData() ?? [:])" + ) + + app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) + XCTAssertTrue(waitForNonExistence(findField, timeout: 5.0), "Expected Escape to close browser find") + XCTAssertTrue( + waitForDataMatch(timeout: 5.0) { data in + data["browserFindVisible"] == "false" + }, + "Expected browser find state to be cleared after Escape. data=\(loadData() ?? [:])" + ) + + app.typeText("z") + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["browserPageTitle"] == "cmux-ui-focus-primary:cmux-ui-focus-primaryz" + }, + "Expected typing after Escape to return to the page input. data=\(loadData() ?? [:])" + ) + } + + func testEscapeAfterBrowserFindRestoresPageInputFocusWithoutPaneSwitch() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_INPUT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_BROWSER_URL"] = focusedInputRoundTripPageURL + launchAndEnsureForeground(app) + + XCTAssertTrue( + waitForData( + keys: [ + "browserPanelId", + "webViewFocused", + "webInputFocusSeeded", + "webInputFocusElementId" + ], + timeout: 12.0 + ), + "Expected setup data including focused page input to be written. path=\(dataPath) data=\(loadData() ?? [:])" + ) + + guard let setup = loadData() else { + XCTFail("Missing goto_split setup data") + return + } + + XCTAssertEqual(setup["webViewFocused"], "true", "Expected WKWebView to be first responder for this test") + XCTAssertEqual(setup["webInputFocusSeeded"], "true", "Expected test page input to be focused before Cmd+F") + + app.typeKey("f", modifierFlags: [.command]) + let findField = app.textFields["BrowserFindSearchTextField"].firstMatch + XCTAssertTrue(findField.waitForExistence(timeout: 6.0), "Expected browser find field after Cmd+F") + + app.typeText("seed") + XCTAssertTrue( + waitForCondition(timeout: 4.0) { + ((findField.value as? String) ?? "") == "seed" + }, + "Expected browser find field to capture typing. value=\(String(describing: findField.value))" + ) + + app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) + XCTAssertTrue(waitForNonExistence(findField, timeout: 5.0), "Expected Escape to close browser find") + XCTAssertTrue( + waitForDataMatch(timeout: 5.0) { data in + data["browserFindVisible"] == "false" + }, + "Expected browser find state to be cleared after Escape. data=\(loadData() ?? [:])" + ) + + app.typeText("z") + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["browserPageTitle"] == "cmux-ui-focus-primary:cmux-ui-focus-primaryz" + }, + "Expected typing after Escape to return to the page input without pane switching. data=\(loadData() ?? [:])" + ) + } + + func testRepeatedEscapeAfterBrowserFindCmdOptionPaneRoundTripRestoresPageInputFocus() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_INPUT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_BROWSER_URL"] = focusedInputRoundTripPageURL + launchAndEnsureForeground(app) + + XCTAssertTrue( + waitForData( + keys: [ + "browserPanelId", + "terminalPaneId", + "webViewFocused", + "webInputFocusSeeded", + "webInputFocusElementId" + ], + timeout: 12.0 + ), + "Expected setup data including focused page input to be written. path=\(dataPath) data=\(loadData() ?? [:])" + ) + + guard let setup = loadData() else { + XCTFail("Missing goto_split setup data") + return + } + + XCTAssertEqual(setup["webViewFocused"], "true", "Expected WKWebView to be first responder for this test") + XCTAssertEqual(setup["webInputFocusSeeded"], "true", "Expected test page input to be focused before Cmd+F") + + guard let expectedTerminalPaneId = setup["terminalPaneId"] else { + XCTFail("Missing terminalPaneId in setup data") + return + } + + let iterationMarkers = Array("abcdefghijklmnopqrstuvwxyzabcd") + var expectedInputValueAfterTyping = "cmux-ui-focus-primary" + + for iteration in 1...30 { + let marker = String(iterationMarkers[iteration - 1]) + let typedTextAfterEscape = " \(marker)" + expectedInputValueAfterTyping += typedTextAfterEscape + assertBrowserFindPaneRoundTripRestoresPageInput( + app, + expectedTerminalPaneId: expectedTerminalPaneId, + route: .cmdOptionArrows, + findNeedle: "seed\(iteration)", + typedTextAfterEscape: typedTextAfterEscape, + expectedInputValueAfterTyping: expectedInputValueAfterTyping, + iteration: iteration + ) + } + } + + func testGoogleLikeSearchInputKeepsCaretAndSpacesAfterRepeatedPaneFocusRoundTrips() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_BROWSER_URL"] = googleLikeSearchPageURL + launchAndEnsureForeground(app) + + XCTAssertTrue( + waitForData( + keys: [ + "browserPanelId", + "terminalPaneId", + "webViewFocused", + "browserPageTitle" + ], + timeout: 12.0 + ), + "Expected setup data including Google-like page state to be written. path=\(dataPath) data=\(loadData() ?? [:])" + ) + + guard let setup = loadData() else { + XCTFail("Missing goto_split setup data") + return + } + + XCTAssertEqual(setup["webViewFocused"], "true", "Expected WKWebView to be first responder for this test") + + guard let expectedTerminalPaneId = setup["terminalPaneId"] else { + XCTFail("Missing terminalPaneId in setup data") + return + } + + var expectedSearchValue = "cmux google" + XCTAssertTrue( + waitForGoogleLikeSearchState(expectedValue: expectedSearchValue, timeout: 8.0), + "Expected Google-like page input to start focused with the caret at the end. data=\(loadData() ?? [:])" + ) + + let iterationMarkers = Array("abcdefghijklmnopqrstuvwxyzabcd") + for iteration in 1...30 { + focusLeftPaneForFindScenario(app, route: .cmdOptionArrows) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["focusedPaneId"] == expectedTerminalPaneId && + data["focusedPanelKind"] == "terminal" + }, + "Expected focus-left shortcut to focus the terminal pane. iteration=\(iteration) data=\(loadData() ?? [:])" + ) + + focusRightPaneForFindScenario(app, route: .cmdOptionArrows) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["focusedPanelKind"] == "browser" + }, + "Expected focus-right shortcut to return to the browser pane. iteration=\(iteration) data=\(loadData() ?? [:])" + ) + + let marker = String(iterationMarkers[iteration - 1]) + let typedText = " \(marker)" + expectedSearchValue += typedText + app.typeText(typedText) + + XCTAssertTrue( + waitForGoogleLikeSearchState(expectedValue: expectedSearchValue, timeout: 8.0), + "Expected Google-like search input, suggestion text, and caret to include the typed leading space. " + + "iteration=\(iteration) expected=\(expectedSearchValue) data=\(loadData() ?? [:])" + ) + } + } + func testWorkspaceRoundTripPreservesFocusedTerminalFindWhenBrowserFindIsAlsoOpen() { runSplitFindWorkspaceRoundTripScenario(restoredOwner: .terminal) } @@ -1240,6 +1507,99 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { } } + private func assertBrowserFindPaneRoundTripRestoresPageInput( + _ app: XCUIApplication, + expectedTerminalPaneId: String, + route: FindFocusRoute, + findNeedle: String, + typedTextAfterEscape: String, + expectedInputValueAfterTyping: String, + iteration: Int + ) { + app.typeKey("f", modifierFlags: [.command]) + let findField = app.textFields["BrowserFindSearchTextField"].firstMatch + XCTAssertTrue(findField.waitForExistence(timeout: 6.0), "Expected browser find field after Cmd+F. iteration=\(iteration)") + + app.typeKey("a", modifierFlags: [.command]) + app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: []) + app.typeText(findNeedle) + XCTAssertTrue( + waitForCondition(timeout: 4.0) { + ((findField.value as? String) ?? "") == findNeedle + }, + "Expected browser find field to capture typing. iteration=\(iteration) value=\(String(describing: findField.value))" + ) + + focusLeftPaneForFindScenario(app, route: route) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["focusedPaneId"] == expectedTerminalPaneId && + data["focusedPanelKind"] == "terminal" && + data["browserFindNeedle"] == findNeedle + }, + "Expected focus-left shortcut to focus the terminal pane while browser find stays open. iteration=\(iteration) data=\(loadData() ?? [:])" + ) + + focusRightPaneForFindScenario(app, route: route) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["focusedPanelKind"] == "browser" && + data["browserFindNeedle"] == findNeedle + }, + "Expected focus-right shortcut to return to the browser pane with browser find still active. iteration=\(iteration) data=\(loadData() ?? [:])" + ) + + app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) + XCTAssertTrue(waitForNonExistence(findField, timeout: 5.0), "Expected Escape to close browser find. iteration=\(iteration)") + XCTAssertTrue( + waitForDataMatch(timeout: 5.0) { data in + data["browserFindVisible"] == "false" + }, + "Expected browser find state to be cleared after Escape. iteration=\(iteration) data=\(loadData() ?? [:])" + ) + + app.typeText(typedTextAfterEscape) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["browserPageTitle"] == "cmux-ui-focus-primary:\(expectedInputValueAfterTyping)" + }, + "Expected typing after Escape to return to the page input. iteration=\(iteration) data=\(loadData() ?? [:])" + ) + } + + private func waitForGoogleLikeSearchState(expectedValue: String, timeout: TimeInterval) -> Bool { + waitForDataMatch(timeout: timeout) { data in + let state = self.googleLikeSearchState(from: data) + let expectedEncoded = self.googleLikeTitleEncoded(expectedValue) + let expectedCaret = String(expectedValue.count) + return state["value"] == expectedEncoded && + state["suggestion"] == expectedEncoded && + state["start"] == expectedCaret && + state["end"] == expectedCaret && + state["active"] == "q" + } + } + + private func googleLikeSearchState(from data: [String: String]) -> [String: String] { + guard let title = data["browserPageTitle"], + title.hasPrefix("cmux-google-like|") else { + return [:] + } + + return title + .split(separator: "|") + .dropFirst() + .reduce(into: [String: String]()) { result, component in + let parts = component.split(separator: "=", maxSplits: 1) + guard parts.count == 2 else { return } + result[String(parts[0])] = String(parts[1]) + } + } + + private func googleLikeTitleEncoded(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? value + } + private func waitForOmnibarToContainExampleDomain(_ omnibar: XCUIElement, timeout: TimeInterval) -> Bool { waitForCondition(timeout: timeout) { let value = (omnibar.value as? String) ?? "" @@ -1345,6 +1705,215 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { "data:text/html,%3Cinput%20id%3D%22q%22%3E%3Cscript%3EsetTimeout%28function%28%29%7Bdocument.getElementById%28%22q%22%29.focus%28%29%3Blocation.hash%3D%22focused%22%3B%7D%2C700%29%3B%3C%2Fscript%3E" } + private var focusedInputRoundTripPageURL: String { + let html = """ + + + + + cmux-ui-focus-loading + + + + + + + + + """ + let encoded = html.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + return "data:text/html,\(encoded)" + } + + private var googleLikeSearchPageURL: String { + let html = """ + + + + + cmux-google-like-loading + + + +
+ +
+
+ + + + """ + let encoded = html.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + return "data:text/html,\(encoded)" + } + private var zoomRoundTripPageURL: String { "data:text/html,%3Ctitle%3EIssue%201144%3C/title%3E%3Cbody%20style%3D%22margin:0;background:%231d1f24;color:white;font-family:system-ui;height:2200px%22%3E%3Cmain%20style%3D%22padding:32px%22%3E%3Ch1%3EIssue%201144%20Regression%20Page%3C/h1%3E%3Cp%3EZoom%20should%20not%20leave%20stale%20split%20chrome%20above%20the%20browser%20omnibar.%3C/p%3E%3C/main%3E%3C/body%3E" } diff --git a/cmuxUITests/MenuKeyEquivalentRoutingUITests.swift b/cmuxUITests/MenuKeyEquivalentRoutingUITests.swift index 1a6248df5db4..841a318f7fe2 100644 --- a/cmuxUITests/MenuKeyEquivalentRoutingUITests.swift +++ b/cmuxUITests/MenuKeyEquivalentRoutingUITests.swift @@ -442,12 +442,12 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { let preTerminalSurfaceNil = Int(data["preTerminalSurfaceNil"] ?? "") ?? -1 // Expected correct behavior: after closing the two right panes, we should have a clean 1x2 stack, - // and both panes should have a selected bonsplit tab that maps to an existing Panel. + // and both panes should have a selected WorkspaceSplit tab that maps to an existing Panel. XCTAssertEqual(preTerminalAttached, 1, "Expected the initial terminal view to be attached to a window before the repro runs") XCTAssertEqual(preTerminalSurfaceNil, 0, "Expected the initial terminal to have a non-nil ghostty_surface before the repro runs") XCTAssertEqual(finalPaneCount, 2, "Expected 2 panes after closing both right splits") XCTAssertEqual(missingSelected, 0, "Expected no pane to have a nil selected tab") - XCTAssertEqual(missingMapping, 0, "Expected no selected bonsplit tab to be missing its Panel mapping") + XCTAssertEqual(missingMapping, 0, "Expected no selected WorkspaceSplit tab to be missing its Panel mapping") XCTAssertEqual(emptyPanels, 0, "Expected no Empty Panel views to appear during the close sequence") XCTAssertEqual(selectedTerminalCount, 2, "Expected both remaining panes to be terminal panels") XCTAssertEqual(selectedTerminalAttached, 2, "Expected both remaining terminal views to be attached to a window") diff --git a/cmuxUITests/SidebarResizeUITests.swift b/cmuxUITests/SidebarResizeUITests.swift index d8c7d7abd5ee..27fcf47d2838 100644 --- a/cmuxUITests/SidebarResizeUITests.swift +++ b/cmuxUITests/SidebarResizeUITests.swift @@ -8,7 +8,10 @@ final class SidebarResizeUITests: XCTestCase { func testSidebarResizerTracksCursor() { let app = XCUIApplication() - app.launch() + XCTAssertTrue( + launchAndAllowBackground(app), + "Expected app to launch and reach foreground before resizing sidebar" + ) let elements = app.descendants(matching: .any) let resizer = elements["SidebarResizer"] @@ -39,7 +42,10 @@ final class SidebarResizeUITests: XCTestCase { func testSidebarResizerAllowsSmallerMinimumWidth() { let app = XCUIApplication() - app.launch() + XCTAssertTrue( + launchAndAllowBackground(app), + "Expected app to launch and reach foreground before resizing sidebar" + ) let window = app.windows.firstMatch XCTAssertTrue(window.waitForExistence(timeout: 5.0)) @@ -63,7 +69,10 @@ final class SidebarResizeUITests: XCTestCase { func testSidebarResizerHasMaximumWidthCap() { let app = XCUIApplication() - app.launch() + XCTAssertTrue( + launchAndAllowBackground(app), + "Expected app to launch and reach foreground before resizing sidebar" + ) let window = app.windows.firstMatch XCTAssertTrue(window.waitForExistence(timeout: 5.0)) @@ -100,4 +109,20 @@ final class SidebarResizeUITests: XCTestCase { ) return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } + + private func launchAndAllowBackground(_ app: XCUIApplication) -> Bool { + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + + let options = XCTExpectedFailure.Options() + options.isStrict = false + XCTExpectFailure("App activation may fail on headless CI runners", options: options) { + app.launch() + } + + if app.state == .runningBackground { + app.activate() + return app.wait(for: .runningForeground, timeout: 5.0) + } + return app.state == .runningForeground || app.wait(for: .runningForeground, timeout: 5.0) + } } diff --git a/cmuxUITests/BonsplitTabDragUITests.swift b/cmuxUITests/WorkspaceSplitTabDragUITests.swift similarity index 95% rename from cmuxUITests/BonsplitTabDragUITests.swift rename to cmuxUITests/WorkspaceSplitTabDragUITests.swift index 88812cb4dd62..955b4a634fac 100644 --- a/cmuxUITests/BonsplitTabDragUITests.swift +++ b/cmuxUITests/WorkspaceSplitTabDragUITests.swift @@ -3,7 +3,7 @@ import Foundation import AppKit import CoreGraphics -final class BonsplitTabDragUITests: XCTestCase { +final class WorkspaceSplitTabDragUITests: XCTestCase { private let launchTimeout: TimeInterval = 20.0 private let setupTimeout: TimeInterval = 25.0 @@ -21,7 +21,7 @@ final class BonsplitTabDragUITests: XCTestCase { XCTAssertTrue( ensureForegroundAfterLaunch(app, timeout: launchTimeout), - "Expected app to launch for minimal-mode Bonsplit tab drag UI test. state=\(app.state.rawValue)" + "Expected app to launch for minimal-mode WorkspaceSplit tab drag UI test. state=\(app.state.rawValue)" ) XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { @@ -70,7 +70,7 @@ final class BonsplitTabDragUITests: XCTestCase { ) XCTAssertTrue( waitForCondition(timeout: 2.0) { dropIndicator.exists }, - "Expected dragging beta onto alpha to reveal the Bonsplit drop indicator." + "Expected dragging beta onto alpha to reveal the WorkspaceSplit drop indicator." ) endMouseDrag(dragSession, atAccessibilityPoint: destination) @@ -382,7 +382,7 @@ final class BonsplitTabDragUITests: XCTestCase { XCTAssertTrue( ensureForegroundAfterLaunch(app, timeout: launchTimeout), - "Expected app to launch for collapsed-sidebar minimal-mode Bonsplit controls hover UI test. state=\(app.state.rawValue)" + "Expected app to launch for collapsed-sidebar minimal-mode WorkspaceSplit controls hover UI test. state=\(app.state.rawValue)" ) XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { @@ -441,17 +441,17 @@ final class BonsplitTabDragUITests: XCTestCase { presentationMode: WorkspacePresentationMode = .minimal ) -> (XCUIApplication, String) { let app = XCUIApplication() - let dataPath = "/tmp/cmux-ui-test-bonsplit-tab-drag-\(UUID().uuidString).json" + let dataPath = "/tmp/cmux-ui-test-split-tab-drag-\(UUID().uuidString).json" try? FileManager.default.removeItem(atPath: dataPath) - app.launchEnvironment["CMUX_UI_TEST_BONSPLIT_TAB_DRAG_SETUP"] = "1" - app.launchEnvironment["CMUX_UI_TEST_BONSPLIT_TAB_DRAG_PATH"] = dataPath + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_SPLIT_TAB_DRAG_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_SPLIT_TAB_DRAG_PATH"] = dataPath if startWithHiddenSidebar { - app.launchEnvironment["CMUX_UI_TEST_BONSPLIT_START_WITH_HIDDEN_SIDEBAR"] = "1" + app.launchEnvironment["CMUX_UI_TEST_SPLIT_START_WITH_HIDDEN_SIDEBAR"] = "1" } app.launchArguments += ["-workspacePresentationMode", presentationMode.rawValue] - app.launch() - app.activate() + launchAndAllowBackground(app) return (app, dataPath) } @@ -466,6 +466,20 @@ final class BonsplitTabDragUITests: XCTestCase { return false } + private func launchAndAllowBackground(_ app: XCUIApplication) { + // Headless CI runners can launch the app successfully but fail foreground activation. + // The split-host UI remains reachable through accessibility in that state. + let options = XCTExpectedFailure.Options() + options.isStrict = false + XCTExpectFailure("App activation may fail on headless CI runners", options: options) { + app.launch() + } + + if app.state == .runningBackground { + app.activate() + } + } + private func waitForAnyJSON(atPath path: String, timeout: TimeInterval) -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { diff --git a/docs/appkit-workspace-behavior-spec.md b/docs/appkit-workspace-behavior-spec.md new file mode 100644 index 000000000000..4c60e1e71fa1 --- /dev/null +++ b/docs/appkit-workspace-behavior-spec.md @@ -0,0 +1,218 @@ +# AppKit Workspace Behavior Spec + +This document defines the required behavior for the workspace split, pane, and tab system that replaces Bonsplit. + +This document is intentionally about `what` the system must do. It does not define classes, views, frameworks, ownership patterns, or any other implementation detail. + +## Scope + +The replacement owns all behavior inside a workspace content area: + +- Split tree structure +- Pane chrome and tab chrome +- Tab ordering, selection, and close behavior +- Split creation, resize, close, and zoom behavior +- Drag and drop within a workspace and across workspaces +- Focus and first-responder convergence +- Geometry snapshots for automation and persistence +- Session restore of layout and panel state +- Hosting behavior for terminal, browser, and markdown panels + +## Core Terms + +- Workspace: one top-level document-like container with its own split tree and panel set. +- Pane: one leaf in the split tree. +- Panel: one live content surface, such as terminal, browser, or markdown. +- Tab: one visible tab-strip entry that maps to exactly one live panel. +- Selected tab: the active tab inside a pane. +- Focused pane: the pane that owns keyboard navigation context. +- Focused panel: the panel that should receive keyboard input. +- Split zoom: a state where one pane temporarily occupies the full workspace area without destroying the underlying split tree. + +## Structural Contract + +- A workspace is always a recursive binary split tree with one or more leaf panes. +- Every pane has a stable opaque pane ID. +- Every tab has a stable opaque tab ID. +- Every visible tab maps to exactly one live panel. +- Placeholder tabs are not allowed in the final system. +- A non-empty pane always has exactly one selected tab. +- The last remaining pane may not be closed by normal pane-close behavior. +- Closing the last tab in a non-last pane closes that pane. +- Closing the last tab in the last remaining pane closes the workspace only when the higher-level app policy explicitly allows it. + +## Visual Contract + +- The replacement must look materially identical to the current workspace UI. +- Pane chrome background follows Ghostty's resolved runtime background color and runtime background opacity. +- When no explicit border color is provided, separators and dividers derive from the current chrome background. +- Focused and unfocused panes preserve the current saturation, tint, and contrast behavior. +- Tab bar height is `30`. +- Tab height is `30`. +- Tab minimum width is `48`. +- Tab maximum width is `220`. +- Tab horizontal padding is `6`. +- Tab spacing is `0`. +- Tab corner radius is `0`. +- Active tab indicator height is `2`. +- Icon size is `14`. +- Title font size is `11`. +- Close button size is `16`. +- Close icon size is `9`. +- Dirty-indicator size is `8`. +- Notification-badge size is `6`. +- Content spacing inside a tab is `6`. +- Drop-indicator width is `2`. +- Drop-indicator height is `20`. +- Divider thickness is `1`. +- Minimum pane width is `100`. +- Minimum pane height is `100`. +- Split-action buttons remain in the pane chrome and preserve current iconography, hover states, pressed states, tooltips, and visibility rules. +- Minimal-mode behavior remains unchanged, including the 30-point top interaction strip and hover-driven action visibility. + +## Color Contract + +- With no custom chrome background, chrome colors match the current native system color behavior. +- With a custom chrome background, text color flips between dark and light treatment based on background luminance. +- Active text uses alpha `0.82`. +- Secondary or inactive text uses alpha `0.62` on dark-text treatment and `0.68` on light-text treatment. +- Active tab background is the chrome background darkened by `0.065` for light chrome or lightened by `0.12` for dark chrome. +- Hovered tab background is the chrome background darkened by `0.03` for light chrome or lightened by `0.07` for dark chrome, then shown with alpha `0.78`. +- Derived separator color uses alpha `0.26` for light chrome or `0.36` for dark chrome. +- Dirty indicators use the active text color at alpha `0.72` when custom chrome is active. +- Notification badges use the current blue accent treatment. +- Drop indicators use the current accent-color treatment. + +## Tab Behavior + +- Tabs inside a pane are ordered and stable. +- Pinned tabs always occupy a contiguous block at the front of the pane. +- Unpinned tabs always appear after the pinned block. +- Reordering must preserve the pinned and unpinned partition. +- Creating a pinned tab inserts it at the end of the pinned block. +- Creating an unpinned tab inserts it after the currently selected tab when the current new-tab policy applies, otherwise at the end of the unpinned block. +- Selecting a tab selects it in its pane and focuses that pane. +- Programmatic selection and user-driven selection must produce the same final state. +- Closing the selected tab picks the tab that moves into the same slot when possible, otherwise the previous tab if the closed tab was last. +- Tab metadata includes title, custom-title state, icon or icon image, kind, dirty state, unread state, loading state, and pinned state. +- Tab switching must preserve underlying panel state, including terminal state, browser state, markdown scroll state, find state, and developer-tools state. +- Only the selected tab in a pane may be visible and interactive. +- Non-selected content may not remain visually visible above the selected content. +- Non-selected content may not intercept drag, drop, hit-testing, or keyboard focus meant for the selected content. + +## Tab Context Menu Contract + +- The tab context menu supports rename. +- The tab context menu supports clearing a custom name. +- The tab context menu supports close to left. +- The tab context menu supports close to right. +- The tab context menu supports close others. +- The tab context menu supports move. +- The tab context menu supports move to left pane when such a destination exists. +- The tab context menu supports move to right pane when such a destination exists. +- The tab context menu supports creating a new terminal to the right. +- The tab context menu supports creating a new browser to the right. +- Browser tabs expose reload. +- Browser tabs expose duplicate. +- Tabs expose toggle pin. +- Tabs expose mark as read and mark as unread, gated by current unread state. +- Tabs expose toggle split zoom when the workspace has more than one pane. +- Context-menu shortcut hints remain synchronized with customizable keyboard shortcuts. + +## Pane Behavior + +- A pane shows its tab strip, its selected content, and any pane-local overlays. +- Clicking content inside a pane makes that pane the focused pane. +- A focused pane visually matches the current focused-pane treatment. +- An unfocused pane visually matches the current unfocused-pane treatment. +- Pane-local unread rings and attention flash effects preserve current behavior. +- Empty panes remain user-visible and actionable. +- An empty pane offers the same entry actions as today, including creating a terminal or browser in that pane. + +## Split Behavior + +- A split is always either horizontal or vertical. +- Horizontal means side-by-side, first pane on the left and second pane on the right. +- Vertical means stacked, first pane on top and second pane on bottom. +- New user-created splits begin at a `0.5` divider ratio. +- Programmatic divider updates clamp by both ratio and absolute pane minimum. +- Ratio clamp remains `0.1...0.9`. +- Absolute clamp enforces the current pane minimum width and height of `100` points. +- Effective divider bounds are `max(0.1, 100 / axisLength)...min(0.9, 1 - 100 / axisLength)` when axis length permits. +- If axis length cannot satisfy two 100-point panes, the divider clamps to the closest valid value and pane minimum wins over ratio. +- Divider movement updates geometry snapshots and layout persistence. +- Closing a pane collapses the tree by promoting its sibling. +- After pane close, focus moves to the surviving sibling when possible, otherwise the first remaining pane. +- Split creation from pane chrome preserves the current behavior of seeding a terminal in the new pane. +- Split creation by moving an existing tab preserves that tab's metadata, panel identity, and panel state. +- Dragging the only tab from a pane to create a split must not leave behind a placeholder tab or a pane with no real panel mapping. +- Adjacent-pane navigation remains geometry-driven. +- If no pane exists in the requested direction, pane navigation is a no-op. +- When more than one candidate exists in a direction, the chosen pane is the one with the strongest perpendicular overlap, then the shortest distance. + +## Split Zoom Behavior + +- Split zoom is unavailable when the workspace has only one pane. +- Toggling split zoom on a pane shows only that pane in the workspace content area. +- Toggling split zoom again restores the prior split tree and divider ratios. +- Split zoom does not destroy panel state. +- Split zoom does not change tab ordering. +- Split zoom does not leave stale content, stale portal layers, or stale pane chrome visible after entering or exiting zoom. +- The selected tab in the zoomed pane shows the current zoom indicator treatment. + +## Drag and Drop Contract + +- Tabs can be reordered within a pane. +- Tabs can move across panes inside the same workspace. +- Tabs can move across workspaces and windows. +- Dropping on a pane center inserts into that pane. +- Dropping on a pane edge creates a split on that edge and places the tab in the new pane on the correct side. +- Dropping after the last tab in a tab strip inserts after the last tab. +- Dragging a tab over trailing empty space in a short tab strip still permits an after-last insertion. +- Inactive workspaces may remain mounted for state preservation, but they must not accept or steal drag interactions. +- File drops from outside the app onto a pane's content area target the currently selected terminal panel in that pane. +- Tab drags and sidebar reordering drags remain distinct and may not be confused with each other. +- Drag and drop may not violate pinned-tab ordering rules. + +## Focus Contract + +- Focused pane state, selected tab state, and AppKit first responder must converge. +- Visual selection may not drift away from actual keyboard target. +- Explicit pane focus changes focus the selected tab's panel in that pane. +- Explicit tab selection focuses that tab's pane and panel. +- Focus changes caused by socket or automation commands may only change in-app focus for commands that are explicitly focus-intent commands. +- Non-focus automation commands may not steal macOS app activation or window activation. +- Terminal focus, browser focus, omnibar focus, browser find focus, and terminal find focus must preserve their current behavior. +- Switching away from a browser tab must hide its visible browser surface. +- Switching away from a terminal tab must prevent stale terminal focus from stealing first responder back. + +## Panel-Specific Contract + +- Terminal panels preserve terminal process state, scrollback state, selection state, find state, and runtime font zoom state across tab switches, splits, zoom, and pane resizes. +- Terminal overlays, including find UI and unread or flash effects, remain correctly layered during split churn. +- Browser panels preserve URL, history stacks, profile, page zoom, developer-tools visibility intent, inline focus state, and current web content across tab switches, splits, zoom, and pane resizes. +- Browser panels may not remain visible when deselected. +- Markdown panels preserve their file-backed content and current viewing state across tab switches, splits, zoom, and pane resizes. + +## Persistence Contract + +- Session snapshots preserve workspace title, custom title, custom color, pinned state, current directory, focused panel, split tree, divider positions, pane membership, selected panel per pane, panel metadata, status entries, log entries, progress, and git-branch state. +- Session snapshots preserve workspace ID so automation references and restore mapping remain stable. +- Terminal panel snapshots preserve working directory and eligible scrollback. +- Browser panel snapshots preserve URL, profile, page zoom, developer-tools visibility, render intent, and back and forward history. +- Markdown panel snapshots preserve file path. +- Session restore rebuilds the same pane tree, same divider ratios, same tab ordering, same selected tab per pane, same focused panel when available, and same panel metadata. +- Session restore skips automatic restore in the same cases as today, including explicit launch arguments and automated-test launches. + +## Automation Contract + +- The workspace exposes a layout snapshot containing container frame, pane frames, selected tab per pane, tab IDs per pane, focused pane ID, and timestamp. +- The workspace exposes a tree snapshot containing the recursive split tree, split orientations, divider ratios, pane frames, tab titles, tab IDs, and selected tab ID. +- Pane and tab IDs stay stable enough for the current automation and persistence layers to address them within a session. + +## Rewrite Guardrails + +- The replacement must preserve current user-visible behavior before it introduces any new behavior. +- The replacement may not rely on placeholder tabs as a steady-state concept. +- The replacement may not regress focus correctness, drag correctness, resize correctness, or session restore correctness. +- The replacement may choose any implementation strategy as long as every behavior above remains true. diff --git a/docs/workspace-layout-simplification-plan.md b/docs/workspace-layout-simplification-plan.md new file mode 100644 index 000000000000..a5db6698c17d --- /dev/null +++ b/docs/workspace-layout-simplification-plan.md @@ -0,0 +1,639 @@ +# Workspace Layout Simplification Plan + +## Current Cycle (5-step retained viewport renderer cut) + +Status on `2026-04-16`: + +1. done, `Workspace` now derives one immutable `WorkspaceLayoutRenderSnapshot` with pane chrome plus explicit `WorkspaceLayoutViewportSnapshot` content mounts. +2. done, the root AppKit host now owns a dedicated `WorkspaceLayoutViewportCanvasView` that retains one surface host per `WorkspacePaneMountIdentity`. +3. done, pane hosts are now shell-only, tab chrome plus drop overlay, and no longer install pane content directly. +4. done, terminal, browser, markdown, and placeholder content now mount only through the retained viewport path, and selected-only visibility replaced the old focused-but-unselected fallback. +5. done, targeted verification passed locally with `39` tests and `0` failures (`WorkspaceContentViewVisibilityTests`, `WorkspaceLayoutSimplificationTests`, `WorkspaceSurfaceRegistryTests`), passed again on `ssh cmux-macmini` with `32` tests and `0` failures using `CMUX_SKIP_ZIG_BUILD=1`, and the retained-host coverage now directly exercises browser and markdown mount, reuse, and unmount behavior in addition to terminal and placeholder paths. + +## Current Cycle (7-step focus/visibility refactor) + +Status on `2026-04-16`: + +1. done, render/mount path is now idempotent for terminal visibility/active updates, and no longer performs focus side effects from `setVisibleInUI`/`setActive`. +2. done, pane-level visibility/focus intent now comes from one immutable layout-derived facts object: `WorkspacePanelPresentationFacts`. +3. done, terminal host mount/unmount now diffs old/new desired presentation state through `WorkspaceTerminalPresentationTransitionResolver` and applies only edge operations. +4. done, first-responder scheduling now goes through one `@MainActor` per-window owner coordinator (`GhosttyWindowFocusCoordinator`) with a generation token. +5. done, focus scheduling moved from `DispatchQueue.main.async` coalescing to structured `Task { @MainActor }` with cancellation and generation checks. +6. done, new pure transition/presentation structs are value types with explicit `Sendable` conformance where appropriate. +7. done, deterministic tests now cover the new transition resolver plus split/close and child-exit behavior. `cmux-unit` targeted run passed with `13` tests and `0` failures (`WorkspaceContentViewVisibilityTests`, selected `WorkspaceUnitTests` split-close cases, and `TabManagerChildExitCloseTests`). + Re-verified on `2026-04-16` with the same targeted suite, `13` tests and `0` failures, using `-derivedDataPath /tmp/cmux-issue-2289-7steps-verify`. + Re-verified on `2026-04-16` on `ssh cmux-macmini` from branch commit `0c1aaa8e`, same targeted suite, `11` executed tests and `0` failures (`/tmp/cmux-issue-2289-7steps-remote-verify`), with `CMUX_SKIP_ZIG_BUILD=1` because that machine does not have `zig` installed. + +## Current Cycle (viewport lifecycle ownership cut) + +Status on `2026-04-17`: + +1. done, terminal surface binding and runtime realization are now split, so `Workspace` and retained-host mounting bind the surface early but only the viewport lifecycle path realizes the Ghostty runtime. +2. done, `WorkspaceSurfaceRegistry.reconcileViewportLifecycle(...)` is now the workspace-owned handoff into terminal lifecycle reconciliation, and `GhosttySurfaceScrollView.reconcileViewportLifecycle(...)` is the single visible-viewport coordinator for runtime bootstrap, geometry sync, and focus reassert. +3. done, `GhosttyNSView.viewDidMoveToWindow()` no longer creates runtime surfaces as a side effect, and `WorkspaceTerminalRetainedSurfaceHost.mount(...)` no longer warm-starts terminals from mount timing guesses. +4. done, the workspace command-wait path, tab-manager terminal readiness waits, terminal-controller surface resolution waits, and hosted terminal focus-reconcile path now use explicit `TerminalSurface.onRuntimeReady(...)` callbacks instead of local NotificationCenter waiting. +5. done, targeted `cmux-unit` verification passed locally on `2026-04-17`, covering workspace content visibility plus retained terminal/browser/markdown mounting, and added direct regression coverage for viewport reconcile runtime creation and runtime-ready callback delivery after window entry. +6. done, `GhosttySurfaceScrollView.synchronizeGeometryAndContent()` is now split into explicit preparation, geometry/layout execution, and debug logging stages, so the viewport coordinator owns the policy boundary and the host sync path no longer concentrates geometry, runtime, and instrumentation in one monolithic method. +7. done, background terminal startup demand now lives on `GhosttySurfaceScrollView` instead of `TerminalSurface`, so pre-window background-start requests are no longer lossy and delayed text-send now uses `onRuntimeReady(...)` in `AppDelegate` rather than another runtime-ready NotificationCenter observer. +8. done, `GhosttyNSView.ensureSurfaceReadyForInput()` no longer creates runtime surfaces directly. Focus and input paths now bounce through viewport reconcile, which removed the last production side door that could realize Ghostty at `0x0` before the lifecycle controller admitted usable geometry. +9. done, temporary terminal-host probe logs from this refactor were removed after the lifecycle-owner cut stabilized, so the branch ends with the controller change instead of permanent dogfood instrumentation. +10. done, the viewport phase machine now lives in a first-class `TerminalViewportLifecycleController` with explicit `TerminalViewportLifecycleFacts`, `TerminalViewportLifecyclePhase`, and `TerminalViewportLifecycleUpdate` types instead of being private nested state inside `GhosttySurfaceScrollView`. +11. done, direct controller tests now cover the transition matrix for visible startup, awaiting-first-frame entry, visible-focused promotion, and background-demand persistence, so the lifecycle contract is verified independently from AppKit host behavior. + +## Finalized Terminal Lifecycle Shape + +The terminal runtime path now has one owner: + +1. `Workspace` and the retained host bind surfaces and pass desired visibility/focus facts into viewport reconcile. +2. `TerminalViewportLifecycleController` owns the terminal phase machine on `@MainActor`. +3. `GhosttySurfaceScrollView` reports AppKit facts such as window attachment, geometry readiness, frame presentation, and focus state, then applies controller-issued commands. +4. `TerminalSurface` is the Ghostty runtime wrapper only. It no longer decides visible startup or background-prewarm policy itself. + +The explicit phases are: + +- `detached` +- `mountedHidden` +- `mountedAwaitingWindow` +- `mountedAwaitingGeometry` +- `runtimeRealized` +- `awaitingFirstFrame` +- `visible` +- `visibleFocused` + +## Verification Bar For This Cut + +This cut is considered complete when both of these hold: + +1. retained-host behavior passes through the workspace and AppKit host boundary +2. the phase machine itself passes direct transition tests without relying on window hover, resize, or other incidental callbacks + +## Status + +Implemented on `2026-04-16`: + +- `WorkspaceLayoutController` now owns the split tree, focus state, drag state, geometry state, and split algorithms directly, so `SplitViewController` is gone and there is no nested mutable layout owner below `Workspace` +- `PaneState` now stores only ordered surface IDs plus selected surface ID, so the layout tree no longer retains fallback title or pin metadata alongside topology +- `PaneState` and `SplitState` are now value types, so the controller no longer mutates aliased pane or split references behind `rootNode.findPane` and `findSplit` +- `PaneState` and `SplitState` no longer participate in Swift Observation, because the shell now renders from workspace-owned snapshots instead of nested live layout observation +- `Workspace` now resolves tab chrome back into `WorkspaceLayoutController` through a tab-snapshot provider, so queries, context actions, and external tree snapshots no longer depend on layout-owned `TabItem` payloads +- the layout controller now starts from a genuinely empty pane, and drag-to-split no longer synthesizes fake `"Empty"` tabs just to avoid a tabless pane +- `WorkspaceLayoutDelegate` tab callbacks now carry `TabID` instead of resolved `WorkspaceLayout.Tab` payloads, so split/create/select/move/close/context-action flow no longer round-trips runtime chrome through the controller +- `WorkspaceLayoutController` now exposes `tabIds(inPane:)` and `selectedTabId(inPane:)`, and runtime callers have started moving off `tabs(inPane:)` and `selectedTab(inPane:)` for non-render logic +- `Workspace` now exposes panel-level pane queries such as `surfaceIds(inPane:)`, `selectedSurfaceId(inPane:)`, `containsSurface(_:inPane:)`, and `indexInPane(forPanelId:)`, so AppDelegate, GhosttyTerminalView, TerminalController, and workspace runtime code no longer map pane membership through layout tab snapshots +- app and test code no longer call `WorkspaceLayoutController.tabs(inPane:)` or `selectedTab(inPane:)`, so full-tab pane queries are no longer part of the workspace runtime path +- outside `Workspace` itself, app and test code no longer call `WorkspaceLayoutController.tabIds(inPane:)` or `selectedTabId(inPane:)` either, so pane selection and membership now cross the runtime boundary as workspace surface IDs instead of layout-controller reads +- `Workspace` now exposes `layoutSnapshot()` and `treeSnapshot()` for external consumers, so `ContentView`, `TabManager`, `TerminalController`, and routing tests no longer depend on `WorkspaceLayoutController.layoutSnapshot()` or `treeSnapshot()` directly +- `Workspace` now also owns pane focus, selected-surface selection, split zoom state, tab-bar leading inset, split-button visibility, and interactivity as app-facing APIs, so window chrome sync, workspace content rendering, socket pane focus, and shortcut tests no longer reach into `splitController` for those runtime concerns +- existing-surface split operations now cross the runtime boundary as `Workspace.splitSurface(...)`, so AppDelegate cross-workspace moves and TerminalController socket commands no longer invoke `WorkspaceLayoutController.splitPane(...)` directly +- app and test code outside the native layout host no longer access `WorkspaceLayoutController` directly, and delegate-injection tests now go through a dedicated workspace test seam instead of mutating `splitController.delegate` themselves +- `WorkspaceLayoutDelegate` callbacks no longer thread the live `WorkspaceLayoutController` instance through every runtime callback, so selection, move, split, close, and geometry notifications now cross the boundary as plain pane and tab IDs +- split equalization now crosses the runtime boundary as `Workspace.equalizeSplits(...)`, so `TabManager`, socket automation, and tests no longer pass `WorkspaceLayoutController` around for divider mutations +- split orientation, divider position, and split-entry animation origin now flow through the canonical render snapshot, so `WorkspaceLayoutRootHostView` and `WorkspaceLayoutNativeSplitView` no longer read live split state back out of the controller tree +- divider drags now update split geometry through `WorkspaceLayoutController` APIs instead of mutating `SplitState` directly from the renderer +- focused workspace suites passed on `ssh cmux-macmini` after the ownership cut: `WorkspaceLayoutSimplificationTests`, `WorkspaceSurfaceRegistryTests`, `WorkspaceContentViewVisibilityTests`, `WorkspaceUnitTests`, and `TabManagerUnitTests` +- pane-content snapshots now carry value-only `surfaceId` descriptors instead of live `TerminalPanel`, `BrowserPanel`, or `MarkdownPanel` references +- `Workspace` now owns a retained `WorkspaceSurfaceRegistry`, so terminal, browser, and markdown mounting no longer lives inside `WorkspaceLayoutPaneHostView` +- placeholder empty-pane hosting now also lives in `WorkspaceSurfaceRegistry`, so the pane host no longer carries a parallel SwiftUI placeholder-controller lifecycle +- the AppKit pane host is now a snapshot applier over slot views plus the workspace-owned surface registry, instead of a type-switched content owner +- each pane now mounts only its selected content into one active slot view, so the renderer no longer keeps a hidden per-tab mount cache alongside the retained surface registry +- each pane render snapshot now carries one workspace-chosen displayed-content payload instead of a per-tab content dictionary plus renderer-side selected-content lookup +- the retained-surface registry is now one map keyed by `WorkspacePaneMountIdentity`, with per-kind retained host adapters behind it instead of parallel browser, markdown, and placeholder host stores +- retained-surface mounts no longer receive `selectedTabId` from the old per-tab host path, because each pane now mounts only one workspace-selected content payload +- pane-host content refresh now compares explicit mount identity before reuse, so content-kind transitions tear down deterministically instead of relying on `installContentView` side effects +- off-window terminal mounts now keep the hosted view installed immediately and only defer the runtime `attachSurface` step until a real window-backed refresh, which removes the blank-pane root-collapse failure during split close +- regression coverage now checks the new retained-surface contract directly, including off-window terminal mounting, shared-slot content replacement, workspace-selected displayed content, placeholder mounting, terminal-host detachment on surface removal, and later attachment after the host enters a window +- retained-surface regression coverage now also directly checks browser and markdown mount, remount reuse, and unmount behavior through `WorkspaceSurfaceRegistryTests`, so every currently supported surface kind is exercised through the new viewport path +- `Workspace` now conforms to the `WorkspaceLayoutHost` protocol directly, so production rendering no longer stores or passes a `WorkspaceLayoutController` bridge through `Workspace` and `WorkspaceContentView` +- the AppKit layout host now talks to `Workspace` through that host protocol for geometry, drag, split, close, zoom, context action, file-drop, and external-tab-drop routing +- `WorkspaceLayoutControllerHostAdapter` is gone, because the native tab-button leaf now receives explicit action closures for select, close, zoom, context actions, and drag lifecycle instead of retaining the full host protocol just for preview and debug wiring +- `WorkspaceLayoutRenderSnapshot` now owns workspace-shell presentation state such as appearance, interactivity, and local tab-drag identity, so the native AppKit host no longer reads those values back through `WorkspaceLayoutHost` +- workspace shell interactivity now comes from `WorkspaceLayoutRenderContext` instead of a body-time mutation in `WorkspaceContentView`, so SwiftUI render passes no longer write controller state just to disable drag interception for inactive workspaces +- `WorkspaceLayoutController` no longer participates in Swift Observation at all, because production rendering now depends on workspace-owned snapshots plus command routing instead of observing the nested layout engine +- `Workspace` now owns external tree snapshot assembly too, so the layout controller no longer resolves tab titles or depends on a workspace-owned tab-chrome provider for non-render APIs +- layout-geometry publication no longer crosses `WorkspaceLayoutDelegate`; `WorkspaceLayoutController` now publishes geometry through a dedicated snapshot sink owned by `Workspace`, and programmatic divider updates now refresh the cached tmux geometry snapshot through the same workspace boundary +- focused remote validation for that final host-boundary cut passed on `ssh cmux-macmini`, `113` tests and `0` failures, including layout simplification, surface registry, content visibility, workspace state, tab manager, shortcut routing, remote connection, session persistence, and sidebar ordering suites +- focused remote validation passed again on `ssh cmux-macmini` after the presentation-snapshot cut, `114` tests and `0` failures, covering the same suites plus the new snapshot-presentation regression +- focused remote validation passed again on `ssh cmux-macmini` after removing Swift Observation from `WorkspaceLayoutController`, `114` tests and `0` failures +- focused remote validation passed again on `ssh cmux-macmini` after moving external tree snapshots to `Workspace`, `114` tests and `0` failures, including the new tree-snapshot title regression + +The old status list below reflects earlier cleanup work that landed before this bug surfaced. The current migration is the ownership cut that makes close, split, and root collapse deterministic. + +## Current Cycle (single live scene reveal cut, Option A) + +Status on `2026-04-17`: + +1. done, the split host now uses one live `WorkspaceLayoutViewportCanvasView`; the old staged viewport path, pending snapshot commit gate, and stage-to-live promotion flow are gone. +2. done, surface reveal is now an explicit value contract through `WorkspaceSurfacePresentationFacts` and `WorkspaceSurfaceRevealPhase`, instead of an ad hoc readiness boolean hidden behind retained-host dispatch. +3. done, terminal retained hosts now publish reveal state from `GhosttySurfaceScrollView` using terminal lifecycle facts, and browser, markdown, and placeholder retained hosts publish immediate visible or hidden facts through the same registry boundary. +4. done, visible terminal panes now mount their retained `GhosttySurfaceScrollView` directly inside `WorkspaceLayoutPaneHostView`, so terminals no longer use viewport hosts or reveal-cover state on the visible path. Browser, markdown, and placeholder content still use the viewport canvas. +5. done, focused verification passed locally on `2026-04-17` with `49` tests and `0` failures across `WorkspaceContentViewVisibilityTests`, `WorkspaceLayoutSimplificationTests`, and `WorkspaceSurfaceRegistryTests` (`/tmp/cmux-issue-2289-direct-pane/Logs/Test/Test-cmux-unit-2026.04.17_23-01-47--0700.xcresult`). + +This is the architecture cut that removes the blank staged-pane class of bug. The shell no longer commits a second scene ahead of surface readiness, and visible terminal panes no longer have a separate shell-owned reveal layer at all. + +Implemented on `issue-2289-appkit-split-host`: + +- canonical surface identity now uses the panel UUID directly, so normal layout operations no longer depend on `surfaceIdToPanelId` +- the legacy `panelIdFromSurfaceId` shim is gone from production code, so active workspace, tab-manager, app-delegate, and terminal-controller paths now look panels up directly by `surfaceId.id` +- `TabItem` no longer carries runtime chrome payload such as icon, loading, unread, dirty, kind, or custom-title state +- visible tab chrome is projected from `Workspace` into the AppKit host instead of being read directly from mutable `PaneState` tab metadata +- the AppKit host renders from a first-class `WorkspaceLayoutRenderSnapshot` tree instead of pane-local ad hoc chrome rebuilds +- workspace-owned tab chrome now projects from explicit published workspace state plus browser chrome snapshot state, so title, pin, unread, loading, and favicon changes no longer depend on a manual refresh token +- split, create, move, reorder, and zoom operations now have one explicit `Workspace.performLayoutCommand` boundary +- the current surface kinds, terminal, browser, and markdown, now all create and split through typed helpers backed by that command boundary +- restore/import and placeholder-repair paths inside `Workspace` now also go through those typed helpers +- the old raw create-split methods are now private implementation detail inside `Workspace`, and tests were migrated to the typed helper API +- `WorkspaceLayoutController.createTab` now only accepts the layout-owned fields that survive into `TabItem`, and detached-surface transfer payloads no longer carry dead chrome data +- split-view drag state and same-process tab transfer payloads now carry stable `TabID` values instead of duplicating full tab chrome payload across controller state and pasteboard transport +- same-pane edge-split moves now route through `Workspace.splitSurface(...)`, so local drag no longer bypasses workspace semantics, and last-tab self-splits create a same-kind replacement terminal or browser instead of leaving the source pane empty +- pane render snapshots no longer duplicate raw tab ordering and selection alongside pane chrome snapshots, so the native host now reads one canonical pane-tab contract +- empty-pane placeholder descriptors now expose explicit create actions instead of handing the renderer a live `Workspace` reference, so placeholder UI follows the same action-driven boundary as the other pane content kinds +- the layout host command and drag path now use typed `PanelType` surface kinds instead of raw `"terminal"` and `"browser"` strings +- browser portal and visibility assumptions now sit behind a dedicated `BrowserPanelWorkspaceContentView` mounting adapter instead of leaking through `WorkspaceSplitNativeHost` +- the keyboard/browser "insert at end" path now uses the correct reorder slot semantics, which restored the existing append-at-end invariant +- workspace no longer runs a terminal geometry recovery loop after split, close, move, reorder, or zoom mutations, and the timer-based moved-terminal refresh path is gone +- the dead `TerminalPanel.requestViewReattach()` abstraction is gone, so terminal geometry reconciliation now stays at the retained host layer instead of being driven from `Workspace` +- browser portal teardown now happens at the retained browser host boundary, and the workspace-wide browser portal follow-up loop is gone +- unfocused split and create commands now preserve pane focus and tab selection at the `WorkspaceLayoutController` boundary instead of mutating focus first and repairing it later +- the old non-focus split reassert path is gone, so split/create command flow no longer depends on nested async focus retries +- the AppKit host no longer self-observes layout state or re-computes snapshots internally, and now acts as a pure apply-the-provided-snapshot renderer +- split zoom no longer recreates the entire workspace layout subtree just to clear stale chrome +- terminal reparent focus suppression now clears from the retained host mount path, so the split path no longer uses a fixed 50 ms timing delay +- browser find focus now relies on overlay mount/update state instead of repeated notification pulses +- browser omnibar select-all now executes from the native text-field host instead of a timed retry pair +- browser address-bar pending focus now resumes from command-palette visibility change signals instead of polling +- browser portal presentation refresh now uses the same immediate-plus-next-runloop reattach shape as the local inline host, and the fixed 30 ms follow-up pass is gone +- external divider updates in `WorkspaceSplit` now suppress only the immediate geometry echo instead of reopening notifications after a fixed 50 ms delay +- browser import modal presentation now waits on popover dismissal state instead of a fixed 120 ms delay +- checkpoint commit `520b8483` preserves the pre-reset branch state before the architecture cleanup +- `ContentView` no longer owns `mountedWorkspaceIds`, `retiringWorkspaceId`, workspace handoff timers, or background workspace priming +- the window now keeps all workspaces mounted and lets selection control visibility and input instead of maintaining a second mount cache +- `TabManager` no longer defers unfocus through `pendingWorkspaceUnfocusTarget`; switching workspaces now focuses the target and unfocuses the previous workspace directly +- temporary blank-pane debug probes such as `close.blankstate.*`, `ws.handoff.*`, and `ws.mount.reconcile` are gone +- `Workspace` now owns canonical per-surface runtime metadata in one `surfaceStatesByPanelId` map instead of parallel title, directory, pin, unread, browser chrome, git, PR, port, and tty stores +- `Workspace` internals now read and mutate that canonical surface state directly for session restore, focus, close, detach/attach, sidebar ordering, browser/markdown creation, remote tty/port updates, and tab chrome projection +- `WorkspaceContentView` no longer assembles tab chrome projection state locally, and `Workspace` now builds the layout render snapshot that the AppKit host applies +- `Workspace` now also resolves terminal/browser native pane content while building that render snapshot, so the AppKit host no longer asks `WorkspaceContentView` to rebuild runtime native descriptors from tabs during rendering +- `WorkspaceLayoutView` and `WorkspaceLayoutNativeHost` no longer expose a separate native-content builder seam; the host consumes one canonical snapshot tree for all pane content +- all current surface kinds, terminal, browser, and markdown, now mount through the same workspace-owned pane-content descriptor path inside the AppKit host, and the dead `PanelContentView` fallback path is gone +- `WorkspaceLayoutView` now requires a workspace-owned render snapshot instead of synthesizing one internally, so the shell boundary is purely "apply this snapshot" even for markdown and placeholder tabs +- the native AppKit host now receives a concrete `WorkspaceLayoutInteractionHandlers` bundle instead of a live `WorkspaceLayoutHost` protocol object, so the renderer boundary is "snapshot plus actions" rather than a retained backchannel to the workspace owner +- visible terminal panes now bypass `WorkspaceLayoutSurfaceViewportHostView` and mount directly in the pane host, while the viewport canvas remains responsible only for non-terminal visible content +- the native tab bar no longer reads `workspacePresentationMode` from `UserDefaults`, because minimal-mode policy now enters through `WorkspaceLayoutPresentationSnapshot` with the rest of the workspace-owned presentation state +- tab chrome context-menu eligibility no longer reaches into `WorkspaceLayoutController` through `workspaceSplitContextMenuState`, because `Workspace` now resolves move/zoom/split facts before building `WorkspaceLayoutPaneChromeSnapshot` +- `TabManager`, `ContentView`, `WorkspaceContentView`, `TerminalController`, `AppleScriptSupport`, `GhosttyTerminalView`, and `TerminalImageTransfer` now consume canonical surface snapshots/accessors instead of wrapper-shaped metadata dictionaries +- the old wrapper-shaped surface metadata API is gone from `Workspace`, so production code no longer reaches into parallel `panelTitles`-style stores +- `TerminalController` workspace resolution now prefers its attached `TabManager` and only falls back to `AppDelegate` window contexts for cross-window routing, which keeps socket and telemetry ownership aligned with the runtime workspace owner +- focused behavior coverage for the ownership cut passes, including workspace state, content visibility, socket security, session persistence, sidebar ordering, browser config, and remote connection suites +- browser portal teardown is now owned by `BrowserPanel.close()` instead of a workspace-wide pre-close sweep, which keeps portal lifecycle at the retained-surface boundary +- `Workspace` now owns render-snapshot construction directly, and the old generic `workspaceLayoutMakeRenderSnapshot` builder seam is gone from production code +- `WorkspaceLayoutDelegate` is now `@MainActor`, matching the controller and workspace shell ownership boundary instead of relying on a nonisolated delegate protocol +- empty panes now also come through the workspace-owned pane-content snapshot, so `WorkspaceLayoutView` and the native host no longer accept a separate empty-pane view builder +- `workspaceSplitContextMenuState` now lives in `Sources/WorkspaceSplit.swift`, so workspace-owned chrome projection no longer depends on a helper defined in `Sources/WorkspaceSplitNativeHost.swift` +- the canonical `WorkspaceLayoutRenderSnapshot` family now lives in `Sources/WorkspaceSplit.swift` instead of `Sources/WorkspaceSplitNativeHost.swift`, so `Workspace` owns runtime snapshot assembly without depending on renderer-local type definitions +- file-drop handling for native panes is now installed from `Workspace` initialization instead of being mutated from `WorkspaceContentView.body`, so SwiftUI render passes no longer reconfigure split-controller command callbacks + +No required architecture work from this simplification plan remains on this branch. + +Optional follow-up work, outside the scope of this refactor: + +1. continue Swift 6 actor-isolation cleanup in unrelated legacy areas such as delegate conformances and AppDelegate notification glue +2. add broader e2e coverage for workspace shell behavior once the next user-visible workflow needs it +3. introduce a dedicated retained-surface registry only if future surface kinds outgrow `Workspace.panels` as the lifetime boundary + +`TabItem.title` remains intentionally as the serialized fallback title used by layout snapshots, placeholders, and export/debug paths. Runtime tab chrome truth no longer depends on it. + +## Problem + +The current workspace layout path has too many runtime models and too many translation boundaries. + +Before this migration, a single user action such as split, close, move, rename, or focus crossed AppDelegate, TabManager, Workspace, `WorkspaceLayoutController`, `SplitViewController`, and the AppKit host. Runtime state was duplicated between panel dictionaries in `Sources/Workspace.swift`, tab metadata in `Sources/WorkspaceSplit.swift`, and renderer-local snapshots in `Sources/WorkspaceSplitNativeHost.swift`. That duplication was the main source of stale tab chrome, focus churn, and brittle feature work. + +## Constraints + +- Keep the workspace shell in AppKit. +- Support all current and planned surface kinds: terminal, browser, markdown, file editor, VNC, embedded simulators, and similar primitives. +- Keep Ghostty-backed terminals. +- Do not require every surface implementation to be AppKit-only. +- Do not rewrite the app around a large framework such as TCA. +- Do not change user-visible workspace behavior in the first migration steps. + +## Original Pain Points + +### Duplicated chrome state + +Before this migration, `TabItem` in `Sources/WorkspaceSplit.swift` stored title, icon, loading, unread, pinned, and related chrome state, even though the real source of truth already existed on the surface or panel owned by `Workspace`. + +### Parallel identity systems + +Before this migration, `Workspace` maintained `surfaceIdToPanelId` in `Sources/Workspace.swift`. That meant normal operations translated between layout IDs and panel IDs instead of operating on one canonical identity. + +### Renderer observes nested mutable state directly + +Before the snapshot conversion, `WorkspaceLayoutRootHostView` in `Sources/WorkspaceSplitNativeHost.swift` depended on nested pane and tab observation for chrome rebuild timing. That made redraw behavior depend on mutation shape instead of an explicit render contract. + +### Content mounting is type-switched in the renderer + +`WorkspaceNativePaneContent` and the AppKit host special-case terminal, browser, and SwiftUI fallback content. That makes each new surface kind more expensive to add. + +### Layout and content responsibilities are mixed + +The layout tree owns tab order and selection, but it also owns tab chrome payload. The renderer mounts views, computes presentation state, and knows about content kind. That is too much coupling for the core workspace shell. + +## Design Principles + +### 1. One canonical workspace owner + +There should be one workspace-owned source of truth for: +- layout topology +- surface identity +- selected surface per pane +- focused pane +- zoomed pane +- per-surface chrome state + +The simplest first step is not to add a new wrapper store. It is to make `Workspace` become the canonical `@MainActor @Observable` owner of this state over time. + +### 2. Layout state and surface state are separate + +The split tree should know only layout facts: +- pane IDs +- ordered `SurfaceID`s in each pane +- selected `SurfaceID` per pane +- focus state +- split ratios and orientation + +It should not own title, icon, loading, unread, pinned, dirty, or custom-title state. Those belong to the surface model. + +### 3. AppKit shell, pluggable surfaces + +Workspace layout, split dividers, tab strip, drag and drop, focus routing, and hit testing stay AppKit. + +Individual surfaces can be: +- pure AppKit +- SwiftUI behind one hosting adapter +- mixed wrappers such as Ghostty terminal or WKWebView browser + +This keeps the shell deterministic while allowing surface-specific implementation freedom. + +### 4. Snapshot-driven shell rendering, retained native surfaces + +The AppKit host should render the shell from immutable snapshots. It should not depend on nested observation over mutable pane and tab collections. + +But live native surfaces are not snapshot data. Ghostty surfaces, WKWebViews, and similar retained views should live in a separate retained registry keyed by `SurfaceID`. The snapshot tells the shell what should be visible and where it should be. The retained registry owns the real `NSView` objects. + +### 5. State is synchronous and main-actor owned + +Workspace mutation and rendering should stay on `@MainActor`. Per the SwiftGuidanceSkill guidance, UI state should use Observation-era models and explicit ownership, not scattered callback glue or GCD hops. + +Heavy non-UI work can still happen off-main, but the workspace shell itself should have a single synchronous state boundary. + +### 6. Focus is a separate imperative subsystem + +The snapshot can describe intended focus. Actual `firstResponder` changes still need explicit imperative focus APIs. Focus should not depend on incidental redraw or remount behavior. + +## Proposed Target Architecture + +### Workspace runtime store + +Use `Workspace` as the initial canonical `@MainActor @Observable` runtime store. It should own: +- `layout: WorkspaceLayoutState` +- `surfaces: [SurfaceID: SurfaceModel]` +- workspace-level focus and selection state +- drag state and drop targets +- command entry points for split, close, move, select, rename, pin, unread, loading, zoom, and drop handling + +A separate `WorkspaceStore` type is optional later. It is not required for the first simplification cut. + +### WorkspaceLayoutState + +Keep this as a pure value-oriented model: +- split tree +- pane IDs +- ordered `surfaceIDs` per pane +- selected `SurfaceID` per pane +- focused pane ID +- zoomed pane ID +- split ratios and orientation + +This model should not carry tab chrome payload. + +### SurfaceModel + +Each user-visible thing in the workspace becomes a surface. + +```swift +@MainActor +@Observable +final class SurfaceModel { + let id: SurfaceID + let kind: SurfaceKind + var chrome: SurfaceChromeState +} +``` + +`SurfaceChromeState` contains: +- title +- custom title flag +- icon descriptor +- loading +- dirty +- unread +- pinned +- closeability and similar tab-strip metadata + +The actual live surface implementation stays outside this value-like state. + +`SurfaceID` should be the panel identity, or a thin wrapper around it. The plan should not preserve a peer `panelID` inside runtime surface state. + +### Retained surface lifetime + +Use `Workspace.panels` as the provisional retained surface registry in the first migration slices. That keeps lifetime ownership flat while identity and chrome ownership are being simplified. + +A dedicated `RetainedSurfaceRegistry` is only worth adding later if `Workspace.panels` can no longer express retained surface lifetime cleanly after the ownership cut. + +The renderer should never treat live surface objects as snapshot payload. + +### Surface boundary + +Do not introduce a second controller hierarchy unless `Panel` proves insufficient. + +The current default plan is: +- keep `Panel` as the canonical surface boundary during the first migration +- extract chrome and layout ownership out of `TabItem` +- add a thin mounting adapter only where a panel needs extra host-specific behavior + +If later we still need a cleaner protocol, define it after the ownership cut, not before. + +### WorkspaceRenderSnapshot + +The renderer consumes one immutable shell snapshot. + +```swift +struct WorkspaceRenderSnapshot { + let layout: WorkspaceLayoutSnapshot + let panes: [PaneID: PaneChromeSnapshot] + let surfaces: [SurfaceID: SurfaceRenderSnapshot] + let focusedPaneID: PaneID? + let selectedSurfaceIDs: [PaneID: SurfaceID] + let zoomedPaneID: PaneID? + let dragState: WorkspaceDragSnapshot? +} +``` + +`SurfaceRenderSnapshot` should contain only shell-facing presentation data, for example: +- title +- icon +- loading +- dirty +- unread +- pinned +- selected and visible flags + +It should not contain live controllers, `NSView`s, or other retained runtime objects. + +### AppKit host responsibilities + +The AppKit host should do only this: +- `applyLayout(snapshot.layout)` +- `applyPaneChrome(snapshot.panes)` +- `applySurfaceChrome(snapshot.surfaces)` +- ask the retained surface lifetime owner, initially `Workspace.panels`, to show, hide, move, or focus the visible surfaces + +The host does not compute business state. It applies state. + +## Required Invariants + +These need to be explicit before implementation starts. + +### Identity + +- `SurfaceID` is the canonical ID for every user-visible surface. +- Normal layout operations do not translate through `surfaceIdToPanelId`. +- `TabID` stops being a separate runtime identity. + +### Focus + +- one canonical focused pane ID +- one canonical selected `SurfaceID` per pane +- one canonical focused surface intent +- one canonical `PanelFocusIntent` or equivalent sub-focus intent +- explicit rule for AppKit `firstResponder` +- explicit rule for browser address-bar focus vs pane focus +- explicit rule for terminal find focus and similar sub-focus modes + +### Visibility + +- one rule for which surfaces are mounted +- one rule for which mounted surfaces are hidden vs visible +- one rule for browser portal visibility +- one rule for zoomed-pane behavior + +### Empty panes and transfers + +- decide whether empty panes are valid steady-state layout or only transient state +- define detach, move, restore, and cross-workspace transfer in terms of `SurfaceID` + +## Why This Is Simpler + +### Simpler than the current path + +This removes: +- duplicated tab chrome metadata in `TabItem` +- most `surfaceIdToPanelId` lookup churn +- renderer observation over nested mutable arrays +- renderer-side branching as the main extensibility mechanism +- mixed ownership of focus, visibility, and title state + +### Simpler than a full SwiftUI shell + +Ghostty's upstream split path is simpler because it has one immutable terminal tree and one renderer. Our problem is not that we use AppKit. Our problem is that we have duplicated state and heterogeneous surface kinds. + +AppKit is still the better shell for desktop split layout, hit testing, drag and drop, portal-hosted views, and future complex primitives. + +### Simpler than forcing every surface into AppKit + +The workspace shell should be AppKit. Surface internals do not all need to be AppKit. A markdown editor or settings-like surface can still live behind one hosting adapter if that is the lowest-complexity implementation. + +## Alternative Approaches Rejected + +### 1. Keep the current model and patch invalidation harder + +Rejected because it keeps duplicated state and translation tables. It may hide symptoms, but it does not simplify the design. + +### 2. Make the full workspace shell SwiftUI + +Rejected because cmux is not just a terminal split tree. We need deterministic desktop behavior for drag, focus, portals, and mixed hosted content. + +### 3. Rewrite around a large reducer framework + +Rejected because the main problem is state shape, not lack of a framework. Plain `@Observable` models and immutable render snapshots are enough. + +## Migration Plan + +### Phase 1. Collapse identity first, completed + +Add `SurfaceID` as the canonical runtime identity everywhere layout code currently uses a separate tab identity. In the first cut, `SurfaceID` should be the panel identity, or a thin wrapper around it. + +The main goal of this phase is to delete `surfaceIdToPanelId` from normal layout operations as early as possible. This phase also needs a session and restore audit, because persisted layout snapshots currently depend on the old identity path. + +### Phase 2. Write down invariants, completed + +Before deeper refactors, define the exact invariants for: +- focus +- visibility +- empty panes +- detach and restore +- cross-workspace transfer + +This prevents the migration from merely relocating ambiguous behavior. + +### Phase 3. Move chrome truth out of layout, completed for runtime chrome + +Change pane state so panes store ordered `SurfaceID`s and selected `SurfaceID`, not `TabItem` payloads. + +The layout algorithms stay the same. Only ownership of title, icon, loading, unread, pinned, and dirty moves to `SurfaceModel.chrome`. + +### Phase 4. Make retained surface lifetime explicit, completed + +Use `Workspace.panels` as the provisional retained surface registry and make surface lifetime rules explicit. + +The goal is to preserve live terminal and browser objects across layout changes while moving shell state out of the renderer. Add a separate `RetainedSurfaceRegistry` type only if the simpler ownership shape later proves insufficient. + +### Phase 5. Add shell snapshot projection, completed + +Add one projection layer from `Workspace` to `WorkspaceRenderSnapshot`. + +This is the key seam. It gives us one render contract and one place to reason about visible shell state. + +### Phase 6. Convert the AppKit host to snapshot application, completed + +Refactor the AppKit host to: +- stop observing nested pane tab metadata directly +- stop computing business decisions in tab views +- apply pane and tab changes from snapshots only +- ask the retained surface lifetime owner, initially `Workspace.panels`, to update live surface views imperatively + +At the end of this phase, stale UI bugs should reduce to either a bad snapshot or a bad apply step. + +### Phase 7. Normalize surface mounting boundaries, completed for the current shell + +Keep `Panel` as the initial surface boundary. Add thin mounting adapters only where a panel needs extra host-specific behavior. + +Only introduce a new surface protocol if the simplified `Panel` boundary still proves inadequate after the ownership cut. + +### Phase 8. Flatten command flow, completed for active callers + +Flatten the current command flow so split, close, move, rename, and focus operate through one workspace command boundary. + +The likely end state is: +- AppDelegate resolves shortcut intent +- `Workspace` executes command +- `Workspace` mutates state +- renderer applies new snapshot +- focus and retained-surface visibility update imperatively + +This keeps TabManager and other compatibility layers thinner over time. + +### Phase 9. Delete old translation and invalidation paths, completed for this migration + +Remove: +- `TabItem` as the runtime source of chrome truth +- `surfaceIdToPanelId` for normal layout operations +- renderer-local type switches as the main content extension point +- old observation-based rerender hooks that exist only to compensate for duplicated state + +### Phase 10. Preserve retained hosts across topology changes, completed + +Completed in this phase: + +- `WorkspaceLayoutRootHostView` no longer resets pane and split host caches when the split topology changes +- pane-host cleanup now runs after every tree rebuild, so removed panes and splits still tear down cleanly without forcing surviving panes to remount +- the unused `recreateOnSwitch` lifecycle mode was deleted, and the AppKit shell now always keeps pane content alive +- the workspace-level terminal geometry recovery pass is deleted, so split, close, move, reorder, and zoom no longer force terminal reattach and refresh from `Workspace` +- the timer-based moved-terminal refresh path is deleted, and debug-stress callers now reconcile geometry directly on the retained hosted view instead of going through workspace-owned reattach helpers +- browser portal teardown and visibility repair now live at the retained browser host boundary instead of a workspace-owned follow-up loop + +### Phase 11. Delete repair loops and duplicate renderer observation, completed + +Completed in this phase: + +- unfocused split and create paths now express their focus intent at the layout-controller boundary instead of mutating focus and reasserting it later from `Workspace` +- the old non-focus split reassert state machine is deleted +- the workspace-wide event-driven layout follow-up loop is deleted +- `WorkspaceLayoutRootHostView` no longer tracks nested mutable controller state on its own or re-projects snapshots internally +- split zoom no longer recreates the entire workspace layout subtree +- terminal reparent focus suppression now resumes from retained-host mount readiness instead of a fixed delay + +## Browser and Future Surface Kinds + +### Browser + +Browser portal and visibility assumptions now live behind the browser's retained surface entry and mounting adapter instead of leaking into the layout host. + +If browsers eventually move to a different engine, this architecture still holds. The shell continues to mount one surface by `SurfaceID`. + +### Future primitives + +Editors, VNC, simulators, and similar views fit this model cleanly. They each provide: +- a surface model +- a retained surface entry +- chrome state +- focus and visibility hooks + +No new layout architecture is needed per surface kind. + +## Risks + +### Risk 1. Migration churn + +This touches core workspace behavior. The migration must keep user-visible behavior stable and land in phases. + +### Risk 2. Focus regressions + +Focus is still the trickiest part of the current code. Each phase needs explicit invariants and behavior checks. + +### Risk 3. Snapshot bloat + +Do not let `WorkspaceRenderSnapshot` become a dumping ground. It should contain only the data the shell renderer needs to draw and place chrome. + +### Risk 4. Retained-surface churn + +If the retained lifetime owner remounts terminals or browsers too aggressively, the architecture still fails. Surface lifetime must stay stable across selection, split, and zoom changes. + +### Risk 5. Placeholder and transfer semantics + +The current split logic still depends on placeholder and detach-transfer behavior. Identity collapse and snapshot projection must account for empty-pane creation, detach, attach, and restore semantics explicitly. + +## Verification Strategy + +Prefer behavior-level verification: +- split right and split down +- close selected surface +- move surface between panes +- rename surface +- unread, loading, dirty, and pinned state updates +- focus changes between panes and surfaces +- browser and terminal visibility correctness during drag and close +- detach and restore behavior +- startup and restore behavior after the main architecture cutovers + +Add deterministic unit coverage around: +- pure layout state transitions +- shell snapshot projection +- command reducers or command handlers +- retained-surface registry decisions + +Do not add source-shape tests. + +## Recommended First Implementation Slice + +The first slice should be the highest-leverage cut with the lowest behavior risk: +- make `SurfaceID` the only runtime identity used by layout +- move tab chrome payload out of `TabItem` +- add a shell-only `WorkspaceRenderSnapshot` +- use `Workspace.panels` as the provisional retained surface registry +- keep `Workspace` as the initial canonical owner instead of adding a wrapper store +- keep `Panel` as the initial surface boundary unless it proves insufficient +- audit session restore, detach, and transfer paths during identity collapse + +That slice removes the worst duplication without forcing an all-at-once rewrite. diff --git a/scripts/capture_workspace_tab_chrome_debug_window.sh b/scripts/capture_workspace_tab_chrome_debug_window.sh new file mode 100755 index 000000000000..69425ed5b0c4 --- /dev/null +++ b/scripts/capture_workspace_tab_chrome_debug_window.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +TAG="${1:-workspace-tab-debug}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +build_log="$(mktemp)" +./scripts/reload.sh --tag "$TAG" | tee "$build_log" +app_path="$(awk '/^App path:/{getline; sub(/^ /, ""); print; exit}' "$build_log")" +rm -f "$build_log" + +if [[ -z "$app_path" || ! -d "$app_path" ]]; then + echo "Failed to locate app path for tag $TAG" >&2 + exit 1 +fi + +binary_path="$(find "$app_path/Contents/MacOS" -maxdepth 1 -type f -perm -111 ! -name "*.dylib" | head -n 1)" +output_dir="${TMPDIR:-/tmp}/cmux-workspace-tab-chrome-captures/$TAG" +outfile="$output_dir/window.png" +mkdir -p "$output_dir" +rm -f "$outfile" + +if [[ -z "$binary_path" || ! -x "$binary_path" ]]; then + echo "Failed to locate executable binary inside $app_path" >&2 + exit 1 +fi + +pkill -f "$binary_path" >/dev/null 2>&1 || true +app_pid="" +trap 'if [[ -n "$app_pid" ]]; then kill "$app_pid" >/dev/null 2>&1 || true; fi' EXIT +CMUX_WORKSPACE_TAB_CHROME_DEBUG_SNAPSHOT_FILE="$outfile" \ +CMUX_WORKSPACE_TAB_CHROME_SNAPSHOT_ONLY=1 \ +"$binary_path" >/tmp/cmux-workspace-tab-chrome-capture-$TAG.log 2>&1 & +app_pid=$! + +if [[ -z "$app_pid" ]]; then + echo "Failed to find launched app pid for $binary_path" >&2 + exit 1 +fi + +for _ in $(seq 1 80); do + if [[ -f "$outfile" ]]; then + break + fi + if ! kill -0 "$app_pid" >/dev/null 2>&1; then + break + fi + sleep 0.25 +done + +if [[ ! -f "$outfile" ]]; then + echo "Failed to render Workspace Tab Chrome Debug snapshot for pid $app_pid" >&2 + exit 1 +fi + +echo "$outfile" diff --git a/scripts/ensure-ghosttykit.sh b/scripts/ensure-ghosttykit.sh index 70e8ee7478ac..f0434f5a38ff 100755 --- a/scripts/ensure-ghosttykit.sh +++ b/scripts/ensure-ghosttykit.sh @@ -53,17 +53,56 @@ if required not in text: PY } +xcframework_is_usable() { + local path="$1" + [[ -d "$path" && -f "$path/Info.plist" && -f "$path/macos-arm64_x86_64/libghostty.a" ]] +} + +refresh_archive_index() { + local archive="$1" + if [[ ! -f "$archive" ]]; then + return 0 + fi + + echo "==> Refreshing libghostty archive index..." + if ! command -v xcrun >/dev/null 2>&1; then + echo "error: xcrun is required to refresh libghostty archive index." >&2 + exit 1 + fi + if ! XCODE_RANLIB="$(xcrun --find ranlib 2>/dev/null)"; then + echo "error: could not locate ranlib via xcrun." >&2 + exit 1 + fi + "$XCODE_RANLIB" "$archive" +} + +reuse_existing_xcframework_without_rebuild() { + local root_xcframework="$1" + local local_xcframework="$2" + local source="" + + if xcframework_is_usable "$root_xcframework"; then + source="$root_xcframework" + elif xcframework_is_usable "$local_xcframework"; then + source="$local_xcframework" + else + return 1 + fi + + if [[ "$source" != "$root_xcframework" ]]; then + rm -rf "$root_xcframework" + cp -R "$source" "$root_xcframework" + fi + + refresh_archive_index "$root_xcframework/macos-arm64_x86_64/libghostty.a" + return 0 +} + if [[ ! -d "$PROJECT_DIR/ghostty" ]]; then echo "error: ghostty submodule is missing. Run ./scripts/setup.sh first." >&2 exit 1 fi -if ! command -v zig >/dev/null 2>&1; then - echo "Error: zig is not installed." >&2 - echo "Install via: brew install zig" >&2 - exit 1 -fi - if [[ ! -f "$PROJECT_DIR/ghostty/include/ghostty.h" ]]; then echo "error: ghostty/include/ghostty.h is missing. Run ./scripts/setup.sh first." >&2 exit 1 @@ -75,6 +114,41 @@ if ! validate_bridge_header "$PROJECT_DIR/ghostty.h"; then exit 1 fi +ROOT_XCFRAMEWORK="$PROJECT_DIR/GhosttyKit.xcframework" +LOCAL_XCFRAMEWORK="$PROJECT_DIR/ghostty/macos/GhosttyKit.xcframework" +HAS_ZIG=1 +HAS_GHOSTTY_GIT=1 + +if ! command -v zig >/dev/null 2>&1; then + HAS_ZIG=0 +fi + +if ! git -C ghostty rev-parse HEAD >/dev/null 2>&1; then + HAS_GHOSTTY_GIT=0 +fi + +if [[ "$HAS_ZIG" -eq 0 || "$HAS_GHOSTTY_GIT" -eq 0 ]]; then + if reuse_existing_xcframework_without_rebuild "$ROOT_XCFRAMEWORK" "$LOCAL_XCFRAMEWORK"; then + if [[ "$HAS_ZIG" -eq 0 && "$HAS_GHOSTTY_GIT" -eq 0 ]]; then + echo "==> Reusing existing GhosttyKit.xcframework (zig missing, ghostty git metadata unavailable)" + elif [[ "$HAS_ZIG" -eq 0 ]]; then + echo "==> Reusing existing GhosttyKit.xcframework (zig missing)" + else + echo "==> Reusing existing GhosttyKit.xcframework (ghostty git metadata unavailable)" + fi + exit 0 + fi + + if [[ "$HAS_ZIG" -eq 0 ]]; then + echo "Error: zig is not installed." >&2 + echo "Install via: brew install zig" >&2 + fi + if [[ "$HAS_GHOSTTY_GIT" -eq 0 ]]; then + echo "error: ghostty git metadata is unavailable and no reusable GhosttyKit.xcframework was found." >&2 + fi + exit 1 +fi + GHOSTTY_SHA="$(git -C ghostty rev-parse HEAD)" GHOSTTY_KEY="$GHOSTTY_SHA" UNTRACKED_FILES="$(git -C ghostty ls-files --others --exclude-standard)" @@ -99,7 +173,6 @@ fi CACHE_ROOT="${CMUX_GHOSTTYKIT_CACHE_DIR:-$HOME/.cache/cmux/ghosttykit}" CACHE_DIR="$CACHE_ROOT/$GHOSTTY_KEY" CACHE_XCFRAMEWORK="$CACHE_DIR/GhosttyKit.xcframework" -LOCAL_XCFRAMEWORK="$PROJECT_DIR/ghostty/macos/GhosttyKit.xcframework" LOCAL_KEY_STAMP="$LOCAL_XCFRAMEWORK/.ghostty_state_key" LEGACY_LOCAL_SHA_STAMP="$LOCAL_XCFRAMEWORK/.ghostty_sha" LOCK_DIR="$CACHE_ROOT/$GHOSTTY_KEY.lock" @@ -240,20 +313,7 @@ else fi MACOS_ARCHIVE="$CACHE_XCFRAMEWORK/macos-arm64_x86_64/libghostty.a" -if [[ -f "$MACOS_ARCHIVE" ]]; then - # Xcode 26 can fail to resolve symbols from Ghostty's universal static archive - # until its ranlib index is refreshed after reuse or copy. - echo "==> Refreshing libghostty archive index..." - if ! command -v xcrun >/dev/null 2>&1; then - echo "error: xcrun is required to refresh libghostty archive index." >&2 - exit 1 - fi - if ! XCODE_RANLIB="$(xcrun --find ranlib 2>/dev/null)"; then - echo "error: could not locate ranlib via xcrun." >&2 - exit 1 - fi - "$XCODE_RANLIB" "$MACOS_ARCHIVE" -fi +refresh_archive_index "$MACOS_ARCHIVE" echo "==> Creating symlink for GhosttyKit.xcframework..." ln -sfn "$CACHE_XCFRAMEWORK" GhosttyKit.xcframework diff --git a/scripts/search_workspace_tab_chrome_pixels.sh b/scripts/search_workspace_tab_chrome_pixels.sh new file mode 100755 index 000000000000..1078d8cbf46f --- /dev/null +++ b/scripts/search_workspace_tab_chrome_pixels.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +set -euo pipefail + +TAG="${1:-workspace-tab-search}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +build_log="$(mktemp)" +./scripts/reload.sh --tag "$TAG" | tee "$build_log" +app_path="$(awk '/^App path:/{getline; sub(/^ /, ""); print; exit}' "$build_log")" +rm -f "$build_log" + +if [[ -z "$app_path" || ! -d "$app_path" ]]; then + echo "Failed to locate app path for tag $TAG" >&2 + exit 1 +fi + +binary_path="$(find "$app_path/Contents/MacOS" -maxdepth 1 -type f -perm -111 ! -name "*.dylib" | head -n 1)" +if [[ -z "$binary_path" || ! -x "$binary_path" ]]; then + echo "Failed to locate app binary in $app_path" >&2 + exit 1 +fi + +python3 - "$binary_path" "$ROOT/vendor/bonsplit" <<'PY' +import json +import math +import os +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +binary_path = sys.argv[1] +package_path = sys.argv[2] +scenario_ids = ",".join([ + "selected-idle", + "selected-close-hover", + "dirty-unread", + "pinned", + "zoomed", + "long-title", +]) + +param_keys = [ + "CMUX_TAB_CHROME_TITLE_DX", + "CMUX_TAB_CHROME_TITLE_DY", + "CMUX_TAB_CHROME_TITLE_POINT_SIZE_DELTA", + "CMUX_TAB_CHROME_TITLE_KERN", + "CMUX_TAB_CHROME_ICON_DX", + "CMUX_TAB_CHROME_ICON_DY", + "CMUX_TAB_CHROME_ICON_POINT_SIZE_DELTA", + "CMUX_TAB_CHROME_ACCESSORY_DX", + "CMUX_TAB_CHROME_ACCESSORY_DY", + "CMUX_TAB_CHROME_ACCESSORY_POINT_SIZE_DELTA", +] + +defaults = { + "CMUX_TAB_CHROME_TITLE_DX": 1.0, + "CMUX_TAB_CHROME_TITLE_DY": 0.375, + "CMUX_TAB_CHROME_TITLE_POINT_SIZE_DELTA": 0.0, + "CMUX_TAB_CHROME_TITLE_KERN": 0.0, + "CMUX_TAB_CHROME_ICON_DX": -1.0, + "CMUX_TAB_CHROME_ICON_DY": -0.875, + "CMUX_TAB_CHROME_ICON_POINT_SIZE_DELTA": -0.5, + "CMUX_TAB_CHROME_ACCESSORY_DX": 0.0, + "CMUX_TAB_CHROME_ACCESSORY_DY": -0.359375, + "CMUX_TAB_CHROME_ACCESSORY_POINT_SIZE_DELTA": 0.09375, +} + +cache: dict[tuple[tuple[str, float], ...], tuple[float, dict]] = {} + + +def normalized(config: dict[str, float]) -> tuple[tuple[str, float], ...]: + return tuple(sorted((key, round(float(value), 4)) for key, value in config.items())) + + +def evaluate(config: dict[str, float], full_suite: bool = False) -> tuple[float, dict]: + key = normalized(config) + (("__full__", 1.0 if full_suite else 0.0),) + if key in cache: + return cache[key] + + export_dir = Path(tempfile.mkdtemp(prefix="cmux-tab-chrome-search-")) + manifest_path = export_dir / "manifest.json" + comparison_manifest_path = export_dir / "comparison-manifest.json" + env = os.environ.copy() + env["CMUX_WORKSPACE_TAB_CHROME_EXPORT_ONLY"] = "1" + env["CMUX_WORKSPACE_TAB_CHROME_EXPORT_DIR"] = str(export_dir) + if not full_suite: + env["CMUX_WORKSPACE_TAB_CHROME_SCENARIO_IDS"] = scenario_ids + for name, value in config.items(): + env[name] = f"{value:.4f}" + + proc = subprocess.Popen( + [binary_path], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=env, + ) + try: + for _ in range(240): + if manifest_path.exists(): + break + if proc.poll() is not None: + break + time.sleep(0.25) + if not manifest_path.exists(): + raise RuntimeError(f"manifest not found for {config}") + subprocess.run( + ["swift", "run", "--package-path", package_path, "BonsplitTabChromeDebugCLI", str(export_dir)], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if not comparison_manifest_path.exists(): + raise RuntimeError(f"comparison manifest not found for {config}") + manifest = json.loads(comparison_manifest_path.read_text()) + finally: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=3) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=3) + + score = 0.0 + differing = 0 + total_pixels = 0 + for result in manifest["scenarioResults"]: + metrics = result["metrics"] + score += metrics["meanAbsoluteChannelDelta"] * metrics["totalPixelCount"] + differing += metrics["differingPixelCount"] + total_pixels += metrics["totalPixelCount"] + + payload = { + "score": score, + "differingPixelCount": differing, + "totalPixelCount": total_pixels, + "scenarioResults": manifest["scenarioResults"], + } + cache[key] = (score, payload) + return score, payload + + +best = defaults.copy() +best_score, best_payload = evaluate(best) +print("baseline", json.dumps({"score": best_score, "differingPixelCount": best_payload["differingPixelCount"]})) + +for step in (1.0, 0.5, 0.25): + improved = True + while improved: + improved = False + for key in param_keys: + candidate_values = [best[key], best[key] - step, best[key] + step] + local_best = best + local_score = best_score + local_payload = best_payload + for value in candidate_values: + candidate = dict(best) + candidate[key] = value + score, payload = evaluate(candidate) + if (score, payload["differingPixelCount"]) < (local_score, local_payload["differingPixelCount"]): + local_best = candidate + local_score = score + local_payload = payload + if local_best is not best: + best = local_best + best_score = local_score + best_payload = local_payload + improved = True + print( + "improved", + json.dumps( + { + "step": step, + "key": key, + "score": best_score, + "differingPixelCount": best_payload["differingPixelCount"], + "config": best, + }, + sort_keys=True, + ), + ) + +full_score, full_payload = evaluate(best, full_suite=True) +print("best-config", json.dumps(best, sort_keys=True)) +print( + "focused-score", + json.dumps( + { + "score": best_score, + "differingPixelCount": best_payload["differingPixelCount"], + "totalPixelCount": best_payload["totalPixelCount"], + }, + sort_keys=True, + ), +) +print( + "full-score", + json.dumps( + { + "score": full_score, + "differingPixelCount": full_payload["differingPixelCount"], + "totalPixelCount": full_payload["totalPixelCount"], + }, + sort_keys=True, + ), +) +for result in full_payload["scenarioResults"]: + metrics = result["metrics"] + print( + result["id"], + json.dumps( + { + "diff": metrics["differingPixelCount"], + "total": metrics["totalPixelCount"], + "max": metrics["maxChannelDelta"], + "mean": round(metrics["meanAbsoluteChannelDelta"], 4), + }, + sort_keys=True, + ), + ) +PY diff --git a/scripts/verify_workspace_tab_chrome_pixels.sh b/scripts/verify_workspace_tab_chrome_pixels.sh new file mode 100755 index 000000000000..1a648e1b9dd3 --- /dev/null +++ b/scripts/verify_workspace_tab_chrome_pixels.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +TAG="${1:-workspace-tab-verify}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +build_log="$(mktemp)" +./scripts/reload.sh --tag "$TAG" | tee "$build_log" +app_path="$(awk '/^App path:/{getline; sub(/^ /, ""); print; exit}' "$build_log")" +rm -f "$build_log" + +if [[ -z "$app_path" || ! -d "$app_path" ]]; then + echo "Failed to locate app path for tag $TAG" >&2 + exit 1 +fi + +app_name="$(basename "$app_path" .app)" +binary_path="$(find "$app_path/Contents/MacOS" -maxdepth 1 -type f -perm -111 ! -name "*.dylib" | head -n 1)" +export_dir="$(mktemp -d "${TMPDIR:-/tmp}/cmux-workspace-tab-chrome-verify-$TAG.XXXXXX")" +manifest_path="$export_dir/manifest.json" +comparison_manifest_path="$export_dir/comparison-manifest.json" + +pkill -f "$binary_path" >/dev/null 2>&1 || true +CMUX_WORKSPACE_TAB_CHROME_EXPORT_DIR="$export_dir" \ +CMUX_WORKSPACE_TAB_CHROME_EXPORT_ONLY=1 \ +"$binary_path" >/tmp/cmux-workspace-tab-chrome-verify-$TAG.log 2>&1 & +app_pid=$! + +for _ in $(seq 1 120); do + if [[ -f "$manifest_path" ]]; then + break + fi + if ! kill -0 "$app_pid" >/dev/null 2>&1 && [[ ! -f "$manifest_path" ]]; then + break + fi + sleep 0.25 +done + +if [[ ! -f "$manifest_path" ]]; then + echo "Export manifest not found: $manifest_path" >&2 + exit 1 +fi + +swift run --package-path "$ROOT/vendor/bonsplit" BonsplitTabChromeDebugCLI "$export_dir" >/tmp/cmux-workspace-tab-chrome-reference-$TAG.log 2>&1 + +if [[ ! -f "$comparison_manifest_path" ]]; then + echo "Comparison manifest not found: $comparison_manifest_path" >&2 + exit 1 +fi + +python3 - "$comparison_manifest_path" <<'PY' +import json +import sys +from pathlib import Path + +manifest_path = Path(sys.argv[1]) +manifest = json.loads(manifest_path.read_text()) +results = manifest.get("scenarioResults", []) +failures = [] +for result in results: + metrics = result["metrics"] + if not metrics.get("matchingPixels", False): + failures.append( + f"{result['id']}: diff={metrics['differingPixelCount']}/{metrics['totalPixelCount']} max={metrics['maxChannelDelta']} mean={metrics['meanAbsoluteChannelDelta']:.2f}" + ) + +print(f"manifest={manifest_path}") +print(f"scenarios={len(results)}") +if failures: + print("status=FAIL") + for failure in failures: + print(failure) + sys.exit(1) +print("status=PASS") +PY diff --git a/tests_v2/test_browser_space_key_event_delivery.py b/tests_v2/test_browser_space_key_event_delivery.py new file mode 100644 index 000000000000..5cb8dce960f2 --- /dev/null +++ b/tests_v2/test_browser_space_key_event_delivery.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""Browser key-event delivery regression coverage. + +This test uses debug.shortcut.simulate instead of debug.type. The former sends a +synthetic NSEvent through NSApp, which exercises NSWindow.performKeyEquivalent +and WebKit keyDown routing. The latter calls insertText directly and would miss +the swallowed-space failure mode. +""" + +import os +import sys +import time +from typing import Any, Callable, Optional + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(condition: bool, message: str) -> None: + if not condition: + raise cmuxError(message) + + +def _wait_until( + predicate: Callable[[], bool], + *, + timeout_s: float, + label: str, +) -> None: + deadline = time.time() + timeout_s + last_exc: Optional[Exception] = None + while time.time() < deadline: + try: + if predicate(): + return + except Exception as exc: # noqa: BLE001 + last_exc = exc + time.sleep(0.05) + if last_exc is not None: + raise cmuxError(f"Timed out waiting for {label}: {last_exc}") + raise cmuxError(f"Timed out waiting for {label}") + + +def _value(payload: dict[str, Any]) -> Any: + return (payload or {}).get("value") + + +def _focused_pane_id(client: cmux) -> Optional[str]: + for _idx, pane_id, _surface_count, focused in client.list_panes(): + if focused: + return pane_id + return None + + +def _browser_eval(client: cmux, surface_id: str, script: str) -> Any: + return _value(client._call("browser.eval", {"surface_id": surface_id, "script": script}) or {}) + + +def _state(client: cmux, surface_id: str) -> dict[str, Any]: + state = _browser_eval( + client, + surface_id, + """ + (() => { + const search = document.querySelector('#search'); + const active = document.activeElement; + return { + value: search ? search.value : null, + selectionStart: search ? search.selectionStart : null, + selectionEnd: search ? search.selectionEnd : null, + active: active ? (active.id || active.tagName || '') : '', + events: (window.__cmuxKeyEvents || []).slice(-16), + }; + })() + """, + ) + _must(isinstance(state, dict), f"Expected browser state dict, got: {state!r}") + return state + + +def _install_test_page(client: cmux, surface_id: str) -> None: + result = _browser_eval( + client, + surface_id, + """ + (() => { + document.title = 'cmux-space-key-event-delivery'; + document.body.innerHTML = ` +
+ +
+ `; + const search = document.querySelector('#search'); + window.__cmuxKeyEvents = []; + const activeName = () => { + const active = document.activeElement; + return active ? (active.id || active.tagName || '') : ''; + }; + const record = (type, event) => { + window.__cmuxKeyEvents.push({ + type, + key: event.key || event.data || '', + code: event.code || event.inputType || '', + value: search.value, + selectionStart: search.selectionStart, + selectionEnd: search.selectionEnd, + active: activeName(), + }); + if (window.__cmuxKeyEvents.length > 80) { + window.__cmuxKeyEvents.shift(); + } + }; + document.addEventListener('keydown', (event) => record('keydown', event), true); + document.addEventListener('beforeinput', (event) => record('beforeinput', event), true); + document.addEventListener('input', (event) => record('input', event), true); + document.addEventListener('keyup', (event) => record('keyup', event), true); + search.focus(); + search.setSelectionRange(search.value.length, search.value.length); + return { ready: true, active: activeName() }; + })() + """, + ) + _must(isinstance(result, dict) and result.get("ready") is True, f"Failed to install browser fixture: {result!r}") + + +def _focus_browser_input(client: cmux, surface_id: str) -> None: + client.focus_webview(surface_id) + client.wait_for_webview_focus(surface_id, timeout_s=5.0) + client._call("browser.focus", {"surface_id": surface_id, "selector": "#search"}) + _wait_until( + lambda: _state(client, surface_id).get("active") == "search", + timeout_s=3.0, + label="browser input DOM focus", + ) + + +def _combo_for_char(ch: str) -> str: + if ch == " ": + return "space" + if len(ch) == 1 and ch.lower() in "abcdefghijklmnopqrstuvwxyz0123456789": + return ch.lower() + raise cmuxError(f"Unsupported synthetic key character: {ch!r}") + + +def _type_via_appkit_key_events(client: cmux, surface_id: str, text: str) -> None: + expected = str(_state(client, surface_id).get("value") or "") + for ch in text: + expected += ch + before = _state(client, surface_id) + client.simulate_shortcut(_combo_for_char(ch)) + + def delivered() -> bool: + current = _state(client, surface_id) + return ( + current.get("value") == expected + and current.get("selectionStart") == len(expected) + and current.get("selectionEnd") == len(expected) + ) + + try: + _wait_until(delivered, timeout_s=1.5, label=f"input value/caret after {ch!r}") + except cmuxError as exc: + after = _state(client, surface_id) + raise cmuxError( + "Browser key event did not update native input value/caret.\n" + f"key={ch!r} combo={_combo_for_char(ch)!r} expected_value={expected!r}\n" + f"before={before}\n" + f"after={after}" + ) from exc + + +def _open_two_pane_browser_workspace(client: cmux) -> tuple[str, str, str]: + workspace_id = client.new_workspace() + client.select_workspace(workspace_id) + + _wait_until( + lambda: any(bool(row.get("in_window")) and row.get("type") == "terminal" for row in client.surface_health()), + timeout_s=5.0, + label="fresh terminal workspace in window", + ) + + browser_surface_id = client.open_browser("about:blank") + _wait_until( + lambda: str( + _browser_eval(client, browser_surface_id, "document.readyState") + ).lower() + == "complete", + timeout_s=5.0, + label="about:blank browser document ready", + ) + _install_test_page(client, browser_surface_id) + _focus_browser_input(client, browser_surface_id) + + panes = client.list_panes() + _must(len(panes) == 2, f"Expected 2 panes in fresh workspace, got: {panes}") + browser_pane_id = _focused_pane_id(client) + terminal_pane_id = next((pane_id for _idx, pane_id, _count, _focused in panes if pane_id != browser_pane_id), None) + _must(browser_pane_id is not None, f"Could not identify focused browser pane: {panes}") + _must(terminal_pane_id is not None, f"Could not identify terminal pane: {panes}") + return browser_surface_id, str(browser_pane_id), str(terminal_pane_id) + + +def test_space_delivery_to_focused_input(client: cmux) -> tuple[bool, str]: + browser_surface_id, _browser_pane_id, _terminal_pane_id = _open_two_pane_browser_workspace(client) + _type_via_appkit_key_events(client, browser_surface_id, "red blue") + state = _state(client, browser_surface_id) + return True, f"value={state.get('value')!r} selectionStart={state.get('selectionStart')}" + + +def test_space_delivery_after_pane_focus_spam(client: cmux) -> tuple[bool, str]: + browser_surface_id, browser_pane_id, terminal_pane_id = _open_two_pane_browser_workspace(client) + _type_via_appkit_key_events(client, browser_surface_id, "left right") + + for _ in range(30): + client.focus_pane(terminal_pane_id) + client.focus_pane(browser_pane_id) + client.focus_webview(browser_surface_id) + + client.wait_for_webview_focus(browser_surface_id, timeout_s=5.0) + _wait_until( + lambda: _state(client, browser_surface_id).get("active") == "search", + timeout_s=3.0, + label="browser input focus after pane focus spam", + ) + + _type_via_appkit_key_events(client, browser_surface_id, " space") + state = _state(client, browser_surface_id) + _must(state.get("value") == "left right space", f"Unexpected final state: {state}") + return True, f"value={state.get('value')!r} selectionStart={state.get('selectionStart')}" + + +def main() -> int: + print("cmux browser space key event delivery tests") + print(f"socket={SOCKET_PATH}") + + tests = [ + ("space delivery to focused browser input", test_space_delivery_to_focused_input), + ("space delivery after 30 pane focus alternations", test_space_delivery_after_pane_focus_spam), + ] + + failed = 0 + with cmux(SOCKET_PATH) as client: + for name, fn in tests: + try: + ok, message = fn(client) + except Exception as exc: # noqa: BLE001 + ok, message = False, str(exc) + status = "PASS" if ok else "FAIL" + print(f"{status}: {name} - {message}") + if not ok: + failed += 1 + + if failed: + print(f"\n{failed} test(s) failed.") + return 1 + print("\nAll tests passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())