diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index b9037f77d21..cd385eda07d 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -11,6 +11,12 @@ A5FF0007 /* SettingDefinition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0017 /* SettingDefinition.swift */; }; A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; }; A5FF0005 /* ContentView+CommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0015 /* ContentView+CommandPalette.swift */; }; + NRCV00000000000000000005 /* TabItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000006 /* TabItemView.swift */; }; + NRCV00000000000000000003 /* FileDropOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000004 /* FileDropOverlayView.swift */; }; + NRCV00000000000000000001 /* CommandPaletteSearchEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000002 /* CommandPaletteSearchEngine.swift */; }; + NRCV00000000000000000007 /* SidebarDragDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000008 /* SidebarDragDrop.swift */; }; + NRCV00000000000000000009 /* SidebarVisuals.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000010 /* SidebarVisuals.swift */; }; + NRCV00000000000000000011 /* CommandPaletteController.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000012 /* CommandPaletteController.swift */; }; E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; }; B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */; }; A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; }; @@ -24,6 +30,9 @@ A5FF0052 /* GhosttyTerminalView+Mouse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0042 /* GhosttyTerminalView+Mouse.swift */; }; A5FF0053 /* GhosttyTerminalView+DragDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0043 /* GhosttyTerminalView+DragDrop.swift */; }; A5FF0054 /* GhosttyTerminalView+Accessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0044 /* GhosttyTerminalView+Accessibility.swift */; }; + NRGV0001 /* GhosttyTerminalView+IME.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRGV0002 /* GhosttyTerminalView+IME.swift */; }; + NRGV0003 /* GhosttyTerminalView+SwiftUIWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRGV0004 /* GhosttyTerminalView+SwiftUIWrapper.swift */; }; + NRGV0005 /* GhosttyTerminalView+RenderStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRGV0006 /* GhosttyTerminalView+RenderStats.swift */; }; A5001532 /* TerminalWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001531 /* TerminalWindowPortal.swift */; }; A5001534 /* BrowserWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001533 /* BrowserWindowPortal.swift */; }; A5FF0008 /* HostedViewPortalRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0018 /* HostedViewPortalRegistry.swift */; }; @@ -60,10 +69,33 @@ A5001290 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = A5001291 /* MarkdownUI */; }; A5001405 /* PanelContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001415 /* PanelContentView.swift */; }; A5001406 /* Workspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001416 /* Workspace.swift */; }; + NRWS00000000000000000028 /* Workspace+Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000027 /* Workspace+Persistence.swift */; }; + NRWS00000000000000000030 /* Workspace+Layout.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000029 /* Workspace+Layout.swift */; }; + NRWS00000000000000000032 /* Workspace+Bonsplit.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000031 /* Workspace+Bonsplit.swift */; }; A5FF0012 /* WorkspaceRemoteSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0002 /* WorkspaceRemoteSession.swift */; }; - A5FF0011 /* WorkspaceRemoteDaemon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0001 /* WorkspaceRemoteDaemon.swift */; }; + NRWS00000000000000000018 /* WorkspaceRemoteSessionController+ConnectionOrchestration.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000017 /* WorkspaceRemoteSessionController+ConnectionOrchestration.swift */; }; + NRWS00000000000000000020 /* WorkspaceRemoteSessionController+ProcessExecution.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000019 /* WorkspaceRemoteSessionController+ProcessExecution.swift */; }; + NRWS00000000000000000022 /* WorkspaceRemoteSessionController+DaemonInstall.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000021 /* WorkspaceRemoteSessionController+DaemonInstall.swift */; }; + NRWS00000000000000000024 /* WorkspaceRemoteSessionController+ScriptBuilders.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000023 /* WorkspaceRemoteSessionController+ScriptBuilders.swift */; }; + NRWS00000000000000000026 /* WorkspaceRemoteSessionController+PortScanning.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000025 /* WorkspaceRemoteSessionController+PortScanning.swift */; }; + NRWS00000000000000000006 /* WorkspaceRemoteDaemonPendingCallRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000005 /* WorkspaceRemoteDaemonPendingCallRegistry.swift */; }; + NRWS00000000000000000008 /* WorkspaceRemoteSSHBatchCommandBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000007 /* WorkspaceRemoteSSHBatchCommandBuilder.swift */; }; + NRWS00000000000000000010 /* WorkspaceRemoteDaemonRPCClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000009 /* WorkspaceRemoteDaemonRPCClient.swift */; }; + NRWS00000000000000000012 /* WorkspaceRemoteLoopbackHTTPRewriting.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000011 /* WorkspaceRemoteLoopbackHTTPRewriting.swift */; }; + NRWS00000000000000000014 /* WorkspaceRemoteProxyBroker.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000013 /* WorkspaceRemoteProxyBroker.swift */; }; + NRWS00000000000000000016 /* WorkspaceRemoteCLIRelayServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000015 /* WorkspaceRemoteCLIRelayServer.swift */; }; + NRWS00000000000000000002 /* RemoteSSHConnectionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000001 /* RemoteSSHConnectionPolicy.swift */; }; + NRWS00000000000000000004 /* RemoteSCPUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000003 /* RemoteSCPUpload.swift */; }; A5001407 /* WorkspaceContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001417 /* WorkspaceContentView.swift */; }; A5001093 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001090 /* AppDelegate.swift */; }; + NRAD1001 /* MainWindowHostingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0001 /* MainWindowHostingView.swift */; }; + NRAD1002 /* TypingProfiler.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0002 /* TypingProfiler.swift */; }; + NRAD1003 /* TerminalDirectoryOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0003 /* TerminalDirectoryOpener.swift */; }; + NRAD1004 /* VSCodeIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0004 /* VSCodeIntegration.swift */; }; + NRAD1005 /* WorkspaceShortcutMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0005 /* WorkspaceShortcutMapper.swift */; }; + NRAD1006 /* CLIInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0006 /* CLIInstaller.swift */; }; + NRAD1007 /* MenuBarIconRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0007 /* MenuBarIconRenderer.swift */; }; + NRAD1008 /* WindowSwizzles.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0008 /* WindowSwizzles.swift */; }; A5FF0031 /* AppDelegate+UITestCmdClick.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0021 /* AppDelegate+UITestCmdClick.swift */; }; A5FF0032 /* AppDelegate+UITestStressWorkspaces.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0022 /* AppDelegate+UITestStressWorkspaces.swift */; }; A5FF0033 /* AppDelegate+UITestHarnesses.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0023 /* AppDelegate+UITestHarnesses.swift */; }; @@ -95,7 +127,6 @@ A5001650 /* ProgramaConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001651 /* ProgramaConfig.swift */; }; A5001652 /* ProgramaConfigExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001653 /* ProgramaConfigExecutor.swift */; }; A5001654 /* ProgramaDirectoryTrust.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001655 /* ProgramaDirectoryTrust.swift */; }; - A5001660 /* SurfacePool.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001661 /* SurfacePool.swift */; }; A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; }; A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; }; B9000002A1B2C3D4E5F60719 /* programa.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* programa.swift */; }; @@ -243,6 +274,12 @@ A5FF0017 /* SettingDefinition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingDefinition.swift; sourceTree = ""; }; A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; A5FF0015 /* ContentView+CommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+CommandPalette.swift"; sourceTree = ""; }; + NRCV00000000000000000006 /* TabItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItemView.swift; sourceTree = ""; }; + NRCV00000000000000000004 /* FileDropOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropOverlayView.swift; sourceTree = ""; }; + NRCV00000000000000000002 /* CommandPaletteSearchEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngine.swift; sourceTree = ""; }; + NRCV00000000000000000008 /* SidebarDragDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarDragDrop.swift; sourceTree = ""; }; + NRCV00000000000000000010 /* SidebarVisuals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarVisuals.swift; sourceTree = ""; }; + NRCV00000000000000000012 /* CommandPaletteController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteController.swift; sourceTree = ""; }; 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = ""; }; B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDragHandleView.swift; sourceTree = ""; }; A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = ""; }; @@ -256,6 +293,9 @@ A5FF0042 /* GhosttyTerminalView+Mouse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyTerminalView+Mouse.swift"; sourceTree = ""; }; A5FF0043 /* GhosttyTerminalView+DragDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyTerminalView+DragDrop.swift"; sourceTree = ""; }; A5FF0044 /* GhosttyTerminalView+Accessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyTerminalView+Accessibility.swift"; sourceTree = ""; }; + NRGV0002 /* GhosttyTerminalView+IME.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyTerminalView+IME.swift"; sourceTree = ""; }; + NRGV0004 /* GhosttyTerminalView+SwiftUIWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyTerminalView+SwiftUIWrapper.swift"; sourceTree = ""; }; + NRGV0006 /* GhosttyTerminalView+RenderStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyTerminalView+RenderStats.swift"; sourceTree = ""; }; A5001531 /* TerminalWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortal.swift; sourceTree = ""; }; A5001533 /* BrowserWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowPortal.swift; sourceTree = ""; }; A5FF0018 /* HostedViewPortalRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostedViewPortalRegistry.swift; sourceTree = ""; }; @@ -292,10 +332,33 @@ A5001418 /* MarkdownPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanel.swift; sourceTree = ""; }; A5001419 /* MarkdownPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanelView.swift; sourceTree = ""; }; A5001416 /* Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workspace.swift; sourceTree = ""; }; + NRWS00000000000000000027 /* Workspace+Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+Persistence.swift"; sourceTree = ""; }; + NRWS00000000000000000029 /* Workspace+Layout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+Layout.swift"; sourceTree = ""; }; + NRWS00000000000000000031 /* Workspace+Bonsplit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+Bonsplit.swift"; sourceTree = ""; }; A5FF0002 /* WorkspaceRemoteSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteSession.swift; sourceTree = ""; }; - A5FF0001 /* WorkspaceRemoteDaemon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteDaemon.swift; sourceTree = ""; }; + NRWS00000000000000000017 /* WorkspaceRemoteSessionController+ConnectionOrchestration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceRemoteSessionController+ConnectionOrchestration.swift"; sourceTree = ""; }; + NRWS00000000000000000019 /* WorkspaceRemoteSessionController+ProcessExecution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceRemoteSessionController+ProcessExecution.swift"; sourceTree = ""; }; + NRWS00000000000000000021 /* WorkspaceRemoteSessionController+DaemonInstall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceRemoteSessionController+DaemonInstall.swift"; sourceTree = ""; }; + NRWS00000000000000000023 /* WorkspaceRemoteSessionController+ScriptBuilders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceRemoteSessionController+ScriptBuilders.swift"; sourceTree = ""; }; + NRWS00000000000000000025 /* WorkspaceRemoteSessionController+PortScanning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceRemoteSessionController+PortScanning.swift"; sourceTree = ""; }; + NRWS00000000000000000005 /* WorkspaceRemoteDaemonPendingCallRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteDaemonPendingCallRegistry.swift; sourceTree = ""; }; + NRWS00000000000000000007 /* WorkspaceRemoteSSHBatchCommandBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteSSHBatchCommandBuilder.swift; sourceTree = ""; }; + NRWS00000000000000000009 /* WorkspaceRemoteDaemonRPCClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteDaemonRPCClient.swift; sourceTree = ""; }; + NRWS00000000000000000011 /* WorkspaceRemoteLoopbackHTTPRewriting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteLoopbackHTTPRewriting.swift; sourceTree = ""; }; + NRWS00000000000000000013 /* WorkspaceRemoteProxyBroker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteProxyBroker.swift; sourceTree = ""; }; + NRWS00000000000000000015 /* WorkspaceRemoteCLIRelayServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteCLIRelayServer.swift; sourceTree = ""; }; + NRWS00000000000000000001 /* RemoteSSHConnectionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSSHConnectionPolicy.swift; sourceTree = ""; }; + NRWS00000000000000000003 /* RemoteSCPUpload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSCPUpload.swift; sourceTree = ""; }; A5001417 /* WorkspaceContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentView.swift; sourceTree = ""; }; A5001090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + NRAD0001 /* MainWindowHostingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowHostingView.swift; sourceTree = ""; }; + NRAD0002 /* TypingProfiler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingProfiler.swift; sourceTree = ""; }; + NRAD0003 /* TerminalDirectoryOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalDirectoryOpener.swift; sourceTree = ""; }; + NRAD0004 /* VSCodeIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VSCodeIntegration.swift; sourceTree = ""; }; + NRAD0005 /* WorkspaceShortcutMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceShortcutMapper.swift; sourceTree = ""; }; + NRAD0006 /* CLIInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIInstaller.swift; sourceTree = ""; }; + NRAD0007 /* MenuBarIconRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarIconRenderer.swift; sourceTree = ""; }; + NRAD0008 /* WindowSwizzles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowSwizzles.swift; sourceTree = ""; }; A5FF0021 /* AppDelegate+UITestCmdClick.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+UITestCmdClick.swift"; sourceTree = ""; }; A5FF0022 /* AppDelegate+UITestStressWorkspaces.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+UITestStressWorkspaces.swift"; sourceTree = ""; }; A5FF0023 /* AppDelegate+UITestHarnesses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+UITestHarnesses.swift"; sourceTree = ""; }; @@ -326,7 +389,6 @@ A5001651 /* ProgramaConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramaConfig.swift; sourceTree = ""; }; A5001653 /* ProgramaConfigExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramaConfigExecutor.swift; sourceTree = ""; }; A5001655 /* ProgramaDirectoryTrust.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramaDirectoryTrust.swift; sourceTree = ""; }; - A5001661 /* SurfacePool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfacePool.swift; sourceTree = ""; }; A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = ""; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = ""; }; @@ -520,6 +582,12 @@ A5FF0017 /* SettingDefinition.swift */, A5001012 /* ContentView.swift */, A5FF0015 /* ContentView+CommandPalette.swift */, + NRCV00000000000000000006 /* TabItemView.swift */, + NRCV00000000000000000004 /* FileDropOverlayView.swift */, + NRCV00000000000000000002 /* CommandPaletteSearchEngine.swift */, + NRCV00000000000000000008 /* SidebarDragDrop.swift */, + NRCV00000000000000000010 /* SidebarVisuals.swift */, + NRCV00000000000000000012 /* CommandPaletteController.swift */, 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */, B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, A50012F0 /* Backport.swift */, @@ -533,8 +601,23 @@ A5FF0061 /* TabManager+GitMetadataPolling.swift */, A5001511 /* UITestRecorder.swift */, A5001416 /* Workspace.swift */, + NRWS00000000000000000027 /* Workspace+Persistence.swift */, + NRWS00000000000000000029 /* Workspace+Layout.swift */, + NRWS00000000000000000031 /* Workspace+Bonsplit.swift */, A5FF0002 /* WorkspaceRemoteSession.swift */, - A5FF0001 /* WorkspaceRemoteDaemon.swift */, + NRWS00000000000000000017 /* WorkspaceRemoteSessionController+ConnectionOrchestration.swift */, + NRWS00000000000000000019 /* WorkspaceRemoteSessionController+ProcessExecution.swift */, + NRWS00000000000000000021 /* WorkspaceRemoteSessionController+DaemonInstall.swift */, + NRWS00000000000000000023 /* WorkspaceRemoteSessionController+ScriptBuilders.swift */, + NRWS00000000000000000025 /* WorkspaceRemoteSessionController+PortScanning.swift */, + NRWS00000000000000000005 /* WorkspaceRemoteDaemonPendingCallRegistry.swift */, + NRWS00000000000000000007 /* WorkspaceRemoteSSHBatchCommandBuilder.swift */, + NRWS00000000000000000009 /* WorkspaceRemoteDaemonRPCClient.swift */, + NRWS00000000000000000011 /* WorkspaceRemoteLoopbackHTTPRewriting.swift */, + NRWS00000000000000000013 /* WorkspaceRemoteProxyBroker.swift */, + NRWS00000000000000000015 /* WorkspaceRemoteCLIRelayServer.swift */, + NRWS00000000000000000001 /* RemoteSSHConnectionPolicy.swift */, + NRWS00000000000000000003 /* RemoteSCPUpload.swift */, A5001417 /* WorkspaceContentView.swift */, A5001014 /* GhosttyConfig.swift */, A5001015 /* GhosttyTerminalView.swift */, @@ -542,6 +625,9 @@ A5FF0042 /* GhosttyTerminalView+Mouse.swift */, A5FF0043 /* GhosttyTerminalView+DragDrop.swift */, A5FF0044 /* GhosttyTerminalView+Accessibility.swift */, + NRGV0002 /* GhosttyTerminalView+IME.swift */, + NRGV0004 /* GhosttyTerminalView+SwiftUIWrapper.swift */, + NRGV0006 /* GhosttyTerminalView+RenderStats.swift */, A5001531 /* TerminalWindowPortal.swift */, A5001533 /* BrowserWindowPortal.swift */, A5FF0018 /* HostedViewPortalRegistry.swift */, @@ -562,6 +648,14 @@ A5001620 /* AppleScriptSupport.swift */, D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */, A5001090 /* AppDelegate.swift */, + NRAD0001 /* MainWindowHostingView.swift */, + NRAD0002 /* TypingProfiler.swift */, + NRAD0003 /* TerminalDirectoryOpener.swift */, + NRAD0004 /* VSCodeIntegration.swift */, + NRAD0005 /* WorkspaceShortcutMapper.swift */, + NRAD0006 /* CLIInstaller.swift */, + NRAD0007 /* MenuBarIconRenderer.swift */, + NRAD0008 /* WindowSwizzles.swift */, A5FF0021 /* AppDelegate+UITestCmdClick.swift */, A5FF0022 /* AppDelegate+UITestStressWorkspaces.swift */, A5FF0023 /* AppDelegate+UITestHarnesses.swift */, @@ -601,7 +695,6 @@ A5001651 /* ProgramaConfig.swift */, A5001653 /* ProgramaConfigExecutor.swift */, A5001655 /* ProgramaDirectoryTrust.swift */, - A5001661 /* SurfacePool.swift */, ); path = Sources; sourceTree = ""; @@ -858,6 +951,12 @@ A5FF0007 /* SettingDefinition.swift in Sources */, A5001002 /* ContentView.swift in Sources */, A5FF0005 /* ContentView+CommandPalette.swift in Sources */, + NRCV00000000000000000005 /* TabItemView.swift in Sources */, + NRCV00000000000000000003 /* FileDropOverlayView.swift in Sources */, + NRCV00000000000000000001 /* CommandPaletteSearchEngine.swift in Sources */, + NRCV00000000000000000007 /* SidebarDragDrop.swift in Sources */, + NRCV00000000000000000009 /* SidebarVisuals.swift in Sources */, + NRCV00000000000000000011 /* CommandPaletteController.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, A50012F1 /* Backport.swift in Sources */, @@ -871,8 +970,23 @@ A5FF0071 /* TabManager+GitMetadataPolling.swift in Sources */, A5001501 /* UITestRecorder.swift in Sources */, A5001406 /* Workspace.swift in Sources */, + NRWS00000000000000000028 /* Workspace+Persistence.swift in Sources */, + NRWS00000000000000000030 /* Workspace+Layout.swift in Sources */, + NRWS00000000000000000032 /* Workspace+Bonsplit.swift in Sources */, A5FF0012 /* WorkspaceRemoteSession.swift in Sources */, - A5FF0011 /* WorkspaceRemoteDaemon.swift in Sources */, + NRWS00000000000000000018 /* WorkspaceRemoteSessionController+ConnectionOrchestration.swift in Sources */, + NRWS00000000000000000020 /* WorkspaceRemoteSessionController+ProcessExecution.swift in Sources */, + NRWS00000000000000000022 /* WorkspaceRemoteSessionController+DaemonInstall.swift in Sources */, + NRWS00000000000000000024 /* WorkspaceRemoteSessionController+ScriptBuilders.swift in Sources */, + NRWS00000000000000000026 /* WorkspaceRemoteSessionController+PortScanning.swift in Sources */, + NRWS00000000000000000006 /* WorkspaceRemoteDaemonPendingCallRegistry.swift in Sources */, + NRWS00000000000000000008 /* WorkspaceRemoteSSHBatchCommandBuilder.swift in Sources */, + NRWS00000000000000000010 /* WorkspaceRemoteDaemonRPCClient.swift in Sources */, + NRWS00000000000000000012 /* WorkspaceRemoteLoopbackHTTPRewriting.swift in Sources */, + NRWS00000000000000000014 /* WorkspaceRemoteProxyBroker.swift in Sources */, + NRWS00000000000000000016 /* WorkspaceRemoteCLIRelayServer.swift in Sources */, + NRWS00000000000000000002 /* RemoteSSHConnectionPolicy.swift in Sources */, + NRWS00000000000000000004 /* RemoteSCPUpload.swift in Sources */, A5001407 /* WorkspaceContentView.swift in Sources */, A5001004 /* GhosttyConfig.swift in Sources */, A5001005 /* GhosttyTerminalView.swift in Sources */, @@ -880,6 +994,9 @@ A5FF0052 /* GhosttyTerminalView+Mouse.swift in Sources */, A5FF0053 /* GhosttyTerminalView+DragDrop.swift in Sources */, A5FF0054 /* GhosttyTerminalView+Accessibility.swift in Sources */, + NRGV0001 /* GhosttyTerminalView+IME.swift in Sources */, + NRGV0003 /* GhosttyTerminalView+SwiftUIWrapper.swift in Sources */, + NRGV0005 /* GhosttyTerminalView+RenderStats.swift in Sources */, A5001532 /* TerminalWindowPortal.swift in Sources */, A5001534 /* BrowserWindowPortal.swift in Sources */, A5FF0008 /* HostedViewPortalRegistry.swift in Sources */, @@ -899,6 +1016,14 @@ A5001226 /* SocketControlSettings.swift in Sources */, A5001621 /* AppleScriptSupport.swift in Sources */, A5001093 /* AppDelegate.swift in Sources */, + NRAD1001 /* MainWindowHostingView.swift in Sources */, + NRAD1002 /* TypingProfiler.swift in Sources */, + NRAD1003 /* TerminalDirectoryOpener.swift in Sources */, + NRAD1004 /* VSCodeIntegration.swift in Sources */, + NRAD1005 /* WorkspaceShortcutMapper.swift in Sources */, + NRAD1006 /* CLIInstaller.swift in Sources */, + NRAD1007 /* MenuBarIconRenderer.swift in Sources */, + NRAD1008 /* WindowSwizzles.swift in Sources */, A5FF0031 /* AppDelegate+UITestCmdClick.swift in Sources */, A5FF0032 /* AppDelegate+UITestStressWorkspaces.swift in Sources */, A5FF0033 /* AppDelegate+UITestHarnesses.swift in Sources */, @@ -938,7 +1063,6 @@ A5001650 /* ProgramaConfig.swift in Sources */, A5001652 /* ProgramaConfigExecutor.swift in Sources */, A5001654 /* ProgramaDirectoryTrust.swift in Sources */, - A5001660 /* SurfacePool.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 72285c20bed..e18d5199480 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -8,29 +8,7 @@ import Combine import ObjectiveC.runtime import Darwin -final class MainWindowHostingView: NSHostingView { - private let zeroSafeAreaLayoutGuide = NSLayoutGuide() - - override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero } - override var safeAreaRect: NSRect { bounds } - override var safeAreaLayoutGuide: NSLayoutGuide { zeroSafeAreaLayoutGuide } - - required init(rootView: Content) { - super.init(rootView: rootView) - addLayoutGuide(zeroSafeAreaLayoutGuide) - NSLayoutConstraint.activate([ - zeroSafeAreaLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor), - zeroSafeAreaLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor), - zeroSafeAreaLayoutGuide.topAnchor.constraint(equalTo: topAnchor), - zeroSafeAreaLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} private enum ProgramaThemeNotifications { static let reloadConfig = Notification.Name("com.darkroom.programa.themes.reload-config") @@ -70,11802 +48,8927 @@ func isCommandPaletteFocusStealingTerminalOrBrowserView(_ view: NSView) -> Bool return false } -#if DEBUG -enum ProgramaTypingTiming { - static let isEnabled: Bool = { - let environment = ProcessInfo.processInfo.environment - if environment["PROGRAMA_TYPING_TIMING_LOGS"] == "1" || environment["PROGRAMA_KEY_LATENCY_PROBE"] == "1" { - return true - } - let defaults = UserDefaults.standard - return defaults.bool(forKey: "programaTypingTimingLogs") || defaults.bool(forKey: "programaKeyLatencyProbe") - }() - static let isVerboseProbeEnabled: Bool = { - let environment = ProcessInfo.processInfo.environment - if environment["PROGRAMA_KEY_LATENCY_PROBE"] == "1" { - return true - } - return UserDefaults.standard.bool(forKey: "programaKeyLatencyProbe") - }() - private static let delayLogThresholdMs: Double = 6.0 - private static let durationLogThresholdMs: Double = 1.0 - - @inline(__always) - static func start() -> TimeInterval? { - guard isEnabled else { return nil } - return ProcessInfo.processInfo.systemUptime - } - - @inline(__always) - static func logEventDelay(path: String, event: NSEvent) { - guard isEnabled else { return } - 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 } - dlog("typing.delay path=\(path) delayMs=\(format(delayMs)) \(eventFields(event))") - } - - @inline(__always) - static func logDuration(path: String, startedAt: TimeInterval?, event: NSEvent? = nil, extra: String? = nil) { - ProgramaMainThreadTurnProfiler.endMeasure(path, startedAt: startedAt) - guard let startedAt else { return } - let elapsedMs = max(0, (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0) - let delayMs: Double? = { - guard let event, event.timestamp > 0 else { return nil } - return max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000.0) - }() - guard shouldLog(delayMs: delayMs, elapsedMs: elapsedMs) else { return } - var line = "typing.timing path=\(path) elapsedMs=\(format(elapsedMs))" - if let event { - line += " \(eventFields(event))" - if let delayMs { - line += " delayMs=\(format(delayMs))" - } - } - if let extra, !extra.isEmpty { - line += " \(extra)" - } - dlog(line) - } - @inline(__always) - static func logBreakdown( - path: String, - totalMs: Double, - event: NSEvent? = nil, - thresholdMs: Double = 2.0, - parts: [(String, Double)], - extra: String? = nil - ) { - guard isEnabled else { return } - let delayMs: Double? = { - guard let event, event.timestamp > 0 else { return nil } - return max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000.0) - }() - let hasSlowPart = parts.contains { $0.1 >= thresholdMs } - guard isVerboseProbeEnabled || totalMs >= thresholdMs || hasSlowPart || (delayMs ?? 0) >= delayLogThresholdMs else { - return - } - var line = "typing.phase path=\(path) totalMs=\(format(totalMs))" - if let event { - line += " \(eventFields(event))" - } - if let delayMs { - line += " delayMs=\(format(delayMs))" - } - for (name, value) in parts where isVerboseProbeEnabled || value >= 0.05 { - line += " \(name)=\(format(value))" - } - if let extra, !extra.isEmpty { - line += " \(extra)" - } - 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)" - } - @inline(__always) - private static func shouldLog(delayMs: Double?, elapsedMs: Double?) -> Bool { - if isVerboseProbeEnabled { - return true - } - if let delayMs, delayMs >= delayLogThresholdMs { - return true - } - if let elapsedMs, elapsedMs >= durationLogThresholdMs { - return true - } - return false - } - @inline(__always) - private static func format(_ value: Double) -> String { - String(format: "%.2f", value) + + +private extension NSScreen { + var programaDisplayID: UInt32? { + let key = NSDeviceDescriptionKey("NSScreenNumber") + guard let value = deviceDescription[key] as? NSNumber else { return nil } + return value.uint32Value } } -final class ProgramaMainRunLoopStallMonitor { - static let shared = ProgramaMainRunLoopStallMonitor() - - private let thresholdMs: Double = 8.0 - private var observer: CFRunLoopObserver? - private var installed = false - private var lastActivity: CFRunLoopActivity? - private var lastTimestamp: TimeInterval? +func browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: Bool, + flags: NSEvent.ModifierFlags, + chars: String +) -> Int? { + guard hasFocusedAddressBar else { return nil } + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control] + guard isCommandOrControlOnly else { return nil } + if chars == "n" { return 1 } + if chars == "p" { return -1 } + return nil +} - private init() {} +func browserOmnibarSelectionDeltaForArrowNavigation( + hasFocusedAddressBar: Bool, + flags: NSEvent.ModifierFlags, + keyCode: UInt16 +) -> Int? { + guard hasFocusedAddressBar else { return nil } + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + guard normalizedFlags == [] else { return nil } + switch keyCode { + case 125: return 1 + case 126: return -1 + default: return nil + } +} - func installIfNeeded() { - guard ProgramaTypingTiming.isEnabled else { return } - guard !installed else { return } +func browserOmnibarNormalizedModifierFlags(_ flags: NSEvent.ModifierFlags) -> NSEvent.ModifierFlags { + flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) +} - var context = CFRunLoopObserverContext( - version: 0, - info: Unmanaged.passUnretained(self).toOpaque(), - retain: nil, - release: nil, - copyDescription: nil - ) +func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool { + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + return normalizedFlags == [] || normalizedFlags == [.shift] +} - observer = CFRunLoopObserverCreate( - kCFAllocatorDefault, - CFRunLoopActivity.allActivities.rawValue, - true, - CFIndex.max, - { _, activity, info in - guard let info else { return } - let monitor = Unmanaged.fromOpaque(info).takeUnretainedValue() - monitor.handle(activity: activity) - }, - &context - ) +func browserResponderHasMarkedText(_ responder: NSResponder?) -> Bool { + guard let responder else { return false } - guard let observer else { return } - CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes) - installed = true + // During IME composition, Return/Enter belongs to the text system so the + // candidate list can commit or confirm the marked text. + if let textInputClient = responder as? NSTextInputClient { + if textInputClient.hasMarkedText() { return true } } - private func handle(activity: CFRunLoopActivity) { - let now = ProcessInfo.processInfo.systemUptime - defer { - lastActivity = activity - lastTimestamp = now - } - - guard let lastActivity, let lastTimestamp else { return } - let elapsedMs = max(0, (now - lastTimestamp) * 1000.0) - guard elapsedMs >= thresholdMs else { return } - if lastActivity == .beforeWaiting && activity == .afterWaiting { - return - } + if let textField = responder as? NSTextField, + let editor = textField.currentEditor() as? NSTextView { + if editor.hasMarkedText() { return true } + } - let mode = CFRunLoopCopyCurrentMode(CFRunLoopGetMain()).map { String(describing: $0) } ?? "nil" - let firstResponder = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let currentEvent = NSApp.currentEvent.map { - "eventType=\($0.type.rawValue) keyCode=\($0.keyCode) mods=\($0.modifierFlags.rawValue)" - } ?? "event=nil" - dlog( - "runloop.stall gapMs=\(String(format: "%.2f", elapsedMs)) prev=\(label(for: lastActivity)) " + - "next=\(label(for: activity)) mode=\(mode) firstResponder=\(firstResponder) \(currentEvent)" - ) + // WKWebView clears marked text before performKeyEquivalent fires, so the + // synchronous hasMarkedText() check above can return false even though an IME + // composition just ended on the same Enter keystroke. Check the JS bridge's + // composition timestamp to detect this race condition (#2626). + if let webView = responder.programaEnclosingProgramaWebView { + if webView.webViewIsComposing { return true } + let age = ProcessInfo.processInfo.systemUptime - webView.recentCompositionEndTimestamp + if age >= 0 && age < 0.15 { return true } } - private func label(for activity: CFRunLoopActivity) -> String { - switch activity { - case .entry: - return "entry" - case .beforeTimers: - return "beforeTimers" - case .beforeSources: - return "beforeSources" - case .beforeWaiting: - return "beforeWaiting" - case .afterWaiting: - return "afterWaiting" - case .exit: - return "exit" - default: - return "unknown(\(activity.rawValue))" + return false +} + +private extension NSResponder { + /// Walk the responder chain to find the enclosing ProgramaWebView. + var programaEnclosingProgramaWebView: ProgramaWebView? { + var current: NSResponder? = self + while let responder = current { + if let webView = responder as? ProgramaWebView { return webView } + current = responder.nextResponder } + return nil } } -final class ProgramaMainThreadTurnProfiler { - static let shared = ProgramaMainThreadTurnProfiler() +func shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: UInt16, + firstResponderIsBrowser: Bool, + firstResponderHasMarkedText: Bool = false, + flags: NSEvent.ModifierFlags +) -> Bool { + guard firstResponderIsBrowser else { return false } + guard !firstResponderHasMarkedText else { return false } + guard keyCode == 36 || keyCode == 76 else { return false } + // Keep browser Return forwarding narrow: only plain/Shift Return should be + // treated as submit-intent. Command-modified Return is reserved for app shortcuts + // like Toggle Pane Zoom (Cmd+Shift+Enter). + return browserOmnibarShouldSubmitOnReturn(flags: flags) +} - private struct BucketStats { - var count: Int = 0 - var totalMs: Double = 0 - var maxMs: Double = 0 +func shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16, + layoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) +) -> Bool { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + guard normalizedFlags == [.command, .control] else { return false } + let normalizedChars = chars.lowercased() + if normalizedChars == "f" { + return true } - - private let trackedThresholdMs: Double = 3.0 - private let countThreshold: Int = 16 - private var observer: CFRunLoopObserver? - private var installed = false - private var turnStart: TimeInterval? - private var buckets: [String: BucketStats] = [:] - - private init() {} - - @inline(__always) - static func endMeasure(_ bucket: String, startedAt: TimeInterval?) { - guard let startedAt, ProgramaTypingTiming.isEnabled, Thread.isMainThread else { return } - let elapsedMs = max(0, (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0) - shared.record(bucket: bucket, elapsedMs: elapsedMs, count: 1) + let charsAreControlSequence = !normalizedChars.isEmpty + && normalizedChars.unicodeScalars.allSatisfy { CharacterSet.controlCharacters.contains($0) } + if !normalizedChars.isEmpty && !charsAreControlSequence { + return false } - func installIfNeeded() { - guard ProgramaTypingTiming.isEnabled else { return } - guard !installed else { return } - - var context = CFRunLoopObserverContext( - version: 0, - info: Unmanaged.passUnretained(self).toOpaque(), - retain: nil, - release: nil, - copyDescription: nil - ) - - observer = CFRunLoopObserverCreate( - kCFAllocatorDefault, - CFRunLoopActivity.allActivities.rawValue, - true, - CFIndex.max, - { _, activity, info in - guard let info else { return } - let profiler = Unmanaged.fromOpaque(info).takeUnretainedValue() - profiler.handle(activity: activity) - }, - &context - ) - - guard let observer else { return } - CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes) - installed = true + // Fallback to layout translation only when characters are unavailable (for + // synthetic/key-equivalent paths that can report an empty string). + if let translatedCharacter = layoutCharacterProvider(keyCode, flags), !translatedCharacter.isEmpty { + return translatedCharacter == "f" } - private func handle(activity: CFRunLoopActivity) { - let now = ProcessInfo.processInfo.systemUptime - switch activity { - case .entry, .afterWaiting: - turnStart = now - buckets.removeAll(keepingCapacity: true) - case .beforeWaiting, .exit: - flushTurn(at: now, nextActivity: activity) - default: - break - } - } + // Keep ANSI fallback as a final safety net when layout translation is unavailable. + return keyCode == 3 +} + +func commandPaletteSelectionDeltaForKeyboardNavigation( + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16 +) -> Int? { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function]) + let normalizedChars = chars.lowercased() - private func record(bucket: String, elapsedMs: Double, count: Int) { - if turnStart == nil { - turnStart = ProcessInfo.processInfo.systemUptime + if normalizedFlags == [] { + switch keyCode { + case 125: return 1 // Down arrow + case 126: return -1 // Up arrow + default: break } - var stats = buckets[bucket, default: BucketStats()] - stats.count += count - stats.totalMs += elapsedMs - stats.maxMs = max(stats.maxMs, elapsedMs) - buckets[bucket] = stats } - private func flushTurn(at now: TimeInterval, nextActivity: CFRunLoopActivity) { - defer { - turnStart = nil - buckets.removeAll(keepingCapacity: true) - } - - guard let turnStart else { return } - guard !buckets.isEmpty else { return } - - let turnMs = max(0, (now - turnStart) * 1000.0) - let trackedMs = buckets.values.reduce(0) { $0 + $1.totalMs } - let totalCount = buckets.values.reduce(0) { $0 + $1.count } - guard trackedMs >= trackedThresholdMs || totalCount >= countThreshold else { return } - - let mode = CFRunLoopCopyCurrentMode(CFRunLoopGetMain()).map { String(describing: $0) } ?? "nil" - let firstResponder = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let eventSummary = NSApp.currentEvent.map { - "eventType=\($0.type.rawValue) keyCode=\($0.keyCode) mods=\($0.modifierFlags.rawValue)" - } ?? "event=nil" - let bucketSummary = buckets - .sorted { - if abs($0.value.totalMs - $1.value.totalMs) > 0.01 { - return $0.value.totalMs > $1.value.totalMs - } - return $0.value.count > $1.value.count - } - .prefix(8) - .map { key, value in - if value.totalMs > 0.05 || value.maxMs > 0.05 { - return "\(key)=\(value.count)/\(String(format: "%.2f", value.totalMs))/\(String(format: "%.2f", value.maxMs))" - } - return "\(key)=\(value.count)" - } - .joined(separator: " ") - - 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)" - ) + if normalizedFlags == [.control] { + // Control modifiers can surface as either printable chars or ASCII control chars. + // Keep Emacs-style next/previous navigation, but leave other control bindings + // (for example Ctrl+K text editing in the palette search field) to AppKit. + if keyCode == 45 || normalizedChars == "n" || normalizedChars == "\u{0e}" { return 1 } // Ctrl+N + if keyCode == 35 || normalizedChars == "p" || normalizedChars == "\u{10}" { return -1 } // Ctrl+P } - private func label(for activity: CFRunLoopActivity) -> String { - switch activity { - case .entry: - return "entry" - case .beforeTimers: - return "beforeTimers" - case .beforeSources: - return "beforeSources" - case .beforeWaiting: - return "beforeWaiting" - case .afterWaiting: - return "afterWaiting" - case .exit: - return "exit" - default: - return "unknown(\(activity.rawValue))" - } - } + return nil } -#endif -enum FinderServicePathResolver { - private static func canonicalDirectoryPath(_ path: String) -> String { - guard path.count > 1 else { return path } - var canonical = path - while canonical.count > 1 && canonical.hasSuffix("/") { - canonical.removeLast() - } - return canonical - } +func shouldRouteCommandPaletteSelectionNavigation( + delta: Int?, + isInteractive: Bool, + usesInlineTextHandling: Bool +) -> Bool { + guard delta != nil, isInteractive else { return false } + return !usesInlineTextHandling +} - private static func normalizedComparisonURL(_ url: URL) -> URL { - url.standardizedFileURL.resolvingSymlinksInPath() - } +func shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: Bool, + normalizedFlags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16 +) -> Bool { + guard isCommandPaletteVisible else { return false } - private static func isSameOrDescendant(_ url: URL, of rootURL: URL) -> Bool { - let urlPathComponents = normalizedComparisonURL(url).pathComponents - let rootPathComponents = normalizedComparisonURL(rootURL).pathComponents - guard urlPathComponents.count >= rootPathComponents.count else { return false } - return Array(urlPathComponents.prefix(rootPathComponents.count)) == rootPathComponents + // Escape dismisses the palette, and must not leak through to the + // underlying terminal or browser content. + if normalizedFlags.isEmpty, keyCode == 53 { + return true } - private static func resolvedDirectoryURL(from url: URL) -> URL { - let standardized = url.standardizedFileURL - if standardized.hasDirectoryPath { - return standardized - } - if let resourceValues = try? standardized.resourceValues(forKeys: [.isDirectoryKey]), - resourceValues.isDirectory == true { - return standardized - } - return standardized.deletingLastPathComponent() - } + guard normalizedFlags.contains(.command) else { return false } - static func orderedUniqueDirectories( - from pathURLs: [URL], - excludingDescendantsOf excludedRootURLs: [URL] = [] - ) -> [String] { - var seen: Set = [] - var directories: [String] = [] + let normalizedChars = chars.lowercased() - for url in pathURLs { - let directoryURL = resolvedDirectoryURL(from: url) - guard !excludedRootURLs.contains(where: { isSameOrDescendant(directoryURL, of: $0) }) else { - continue - } - let path = canonicalDirectoryPath(directoryURL.path(percentEncoded: false)) - guard !path.isEmpty else { continue } - if seen.insert(path).inserted { - directories.append(path) - } + if normalizedFlags == [.command] { + if normalizedChars == "a" + || normalizedChars == "c" + || normalizedChars == "v" + || normalizedChars == "x" + || normalizedChars == "z" + || normalizedChars == "y" { + return false } - return directories + switch keyCode { + case 51, 117, 123, 124: + return false + default: + break + } } -} -enum TerminalDirectoryOpenTarget: String, CaseIterable { - case androidStudio - case antigravity - case cursor - case finder - case ghostty - case intellij - case iterm2 - case terminal - case tower - case vscode - case vscodeInline - case warp - case windsurf - case xcode - case zed - - struct DetectionEnvironment { - let homeDirectoryPath: String - let fileExistsAtPath: (String) -> Bool - let isExecutableFileAtPath: (String) -> Bool - let applicationPathForName: (String) -> String? - - static let live = DetectionEnvironment( - homeDirectoryPath: FileManager.default.homeDirectoryForCurrentUser.path, - fileExistsAtPath: { FileManager.default.fileExists(atPath: $0) }, - isExecutableFileAtPath: { FileManager.default.isExecutableFile(atPath: $0) }, - applicationPathForName: { NSWorkspace.shared.fullPath(forApplication: $0) } - ) + if normalizedFlags == [.command, .shift], normalizedChars == "z" { + return false } - static var commandPaletteShortcutTargets: [Self] { - Array(allCases) - } + return true +} - static func availableTargets(in environment: DetectionEnvironment = .live) -> Set { - Set(commandPaletteShortcutTargets.filter { $0.isAvailable(in: environment) }) +func shouldSubmitCommandPaletteWithReturn( + keyCode: UInt16, + flags: NSEvent.ModifierFlags, + mode: String +) -> Bool { + guard keyCode == 36 || keyCode == 76 else { return false } + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + if normalizedFlags.isEmpty { + return true } - - var commandPaletteCommandId: String { - "palette.terminalOpenDirectory.\(rawValue)" + if normalizedFlags == [.shift] { + return mode != "workspace_description_input" } + return false +} - var commandPaletteTitle: String { - switch self { - case .androidStudio: - return String(localized: "menu.openInAndroidStudio", defaultValue: "Open Current Directory in Android Studio") - case .antigravity: - return String(localized: "menu.openInAntigravity", defaultValue: "Open Current Directory in Antigravity") - case .cursor: - return String(localized: "menu.openInCursor", defaultValue: "Open Current Directory in Cursor") - case .finder: - return String(localized: "menu.openInFinder", defaultValue: "Open Current Directory in Finder") - case .ghostty: - return String(localized: "menu.openInGhostty", defaultValue: "Open Current Directory in Ghostty") - case .intellij: - return String(localized: "menu.openInIntelliJ", defaultValue: "Open Current Directory in IntelliJ IDEA") - case .iterm2: - return String(localized: "menu.openInITerm2", defaultValue: "Open Current Directory in iTerm2") - case .terminal: - return String(localized: "menu.openInTerminal", defaultValue: "Open Current Directory in Terminal") - case .tower: - return String(localized: "menu.openInTower", defaultValue: "Open Current Directory in Tower") - case .vscode: - return String(localized: "menu.openInVSCodeDesktop", defaultValue: "Open Current Directory in VS Code") - case .vscodeInline: - return String(localized: "menu.openInVSCode", defaultValue: "Open Current Directory in VS Code (Inline)") - case .warp: - return String(localized: "menu.openInWarp", defaultValue: "Open Current Directory in Warp") - case .windsurf: - return String(localized: "menu.openInWindsurf", defaultValue: "Open Current Directory in Windsurf") - case .xcode: - return String(localized: "menu.openInXcode", defaultValue: "Open Current Directory in Xcode") - case .zed: - return String(localized: "menu.openInZed", defaultValue: "Open Current Directory in Zed") - } - } - - var commandPaletteKeywords: [String] { - let common = ["terminal", "directory", "open", "ide"] - switch self { - case .androidStudio: - return common + ["android", "studio"] - case .antigravity: - return common + ["antigravity"] - case .cursor: - return common + ["cursor"] - case .finder: - return common + ["finder", "file", "manager", "reveal"] - case .ghostty: - return common + ["ghostty", "terminal", "shell"] - case .intellij: - return common + ["intellij", "idea", "jetbrains"] - case .iterm2: - return common + ["iterm", "iterm2", "terminal", "shell"] - case .terminal: - return common + ["terminal", "shell"] - case .tower: - return common + ["tower", "git", "client"] - case .vscode: - return common + ["vs", "code", "visual", "studio", "desktop", "app"] - case .vscodeInline: - return common + ["vs", "code", "visual", "studio", "inline", "browser", "serve-web"] - case .warp: - return common + ["warp", "terminal", "shell"] - case .windsurf: - return common + ["windsurf"] - case .xcode: - return common + ["xcode", "apple"] - case .zed: - return common + ["zed"] - } - } - - func isAvailable(in environment: DetectionEnvironment = .live) -> Bool { - guard let applicationPath = applicationPath(in: environment) else { return false } - guard self == .vscodeInline else { return true } - return VSCodeCLILaunchConfigurationBuilder.launchConfiguration( - vscodeApplicationURL: URL(fileURLWithPath: applicationPath, isDirectory: true), - isExecutableAtPath: environment.isExecutableFileAtPath - ) != nil - } - - func applicationURL(in environment: DetectionEnvironment = .live) -> URL? { - guard let path = applicationPath(in: environment) else { return nil } - return URL(fileURLWithPath: path, isDirectory: true) - } - - private func applicationPath(in environment: DetectionEnvironment) -> String? { - for path in expandedCandidatePaths(in: environment) where environment.fileExistsAtPath(path) { - return path - } - - // Fall back to LaunchServices so apps outside the standard bundle paths - // still appear in the command palette. - for applicationName in applicationSearchNames { - guard let resolvedPath = environment.applicationPathForName(applicationName), - environment.fileExistsAtPath(resolvedPath) else { - continue - } - return resolvedPath - } - - return nil +func commandPaletteFieldEditorHasMarkedText(in window: NSWindow) -> Bool { + if let editor = window.firstResponder as? NSTextView { + return editor.hasMarkedText() } - - private func expandedCandidatePaths(in environment: DetectionEnvironment) -> [String] { - let globalPrefix = "/Applications/" - let userPrefix = "\(environment.homeDirectoryPath)/Applications/" - var expanded: [String] = [] - - for candidate in applicationBundlePathCandidates { - expanded.append(candidate) - if candidate.hasPrefix(globalPrefix) { - let suffix = String(candidate.dropFirst(globalPrefix.count)) - expanded.append(userPrefix + suffix) - } - } - - return uniquePreservingOrder(expanded) + if let textField = window.firstResponder as? NSTextField, + let editor = textField.currentEditor() as? NSTextView { + return editor.hasMarkedText() } + return false +} - private var applicationSearchNames: [String] { - uniquePreservingOrder( - applicationBundlePathCandidates.map { - URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent - } - ) +func shouldHandleCommandPaletteShortcutEvent( + _ event: NSEvent, + paletteWindow: NSWindow? +) -> Bool { + guard let paletteWindow else { return false } + if let eventWindow = event.window { + return eventWindow === paletteWindow } - - private var applicationBundlePathCandidates: [String] { - switch self { - case .androidStudio: - return ["/Applications/Android Studio.app"] - case .antigravity: - return ["/Applications/Antigravity.app"] - case .cursor: - return [ - "/Applications/Cursor.app", - "/Applications/Cursor Preview.app", - "/Applications/Cursor Nightly.app", - ] - case .finder: - return ["/System/Library/CoreServices/Finder.app"] - case .ghostty: - return ["/Applications/Ghostty.app"] - case .intellij: - return ["/Applications/IntelliJ IDEA.app"] - case .iterm2: - return [ - "/Applications/iTerm.app", - "/Applications/iTerm2.app", - ] - case .terminal: - return ["/System/Applications/Utilities/Terminal.app"] - case .tower: - return ["/Applications/Tower.app"] - case .vscode: - return [ - "/Applications/Visual Studio Code.app", - "/Applications/Code.app", - ] - case .vscodeInline: - return [ - "/Applications/Visual Studio Code.app", - "/Applications/Code.app", - ] - case .warp: - return ["/Applications/Warp.app"] - case .windsurf: - return ["/Applications/Windsurf.app"] - case .xcode: - return ["/Applications/Xcode.app"] - case .zed: - return [ - "/Applications/Zed.app", - "/Applications/Zed Preview.app", - "/Applications/Zed Nightly.app", - ] - } - } - - private func uniquePreservingOrder(_ paths: [String]) -> [String] { - var seen: Set = [] - var deduped: [String] = [] - for path in paths where seen.insert(path).inserted { - deduped.append(path) - } - return deduped + let eventWindowNumber = event.windowNumber + if eventWindowNumber > 0 { + return eventWindowNumber == paletteWindow.windowNumber + } + if let keyWindow = NSApp.keyWindow { + return keyWindow === paletteWindow } + return false } -enum VSCodeServeWebURLBuilder { - static func extractWebUIURL(from output: String) -> URL? { - let prefix = "Web UI available at " - for line in output.split(whereSeparator: \.isNewline).reversed() { - guard let range = line.range(of: prefix) else { continue } - let rawURL = line[range.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines) - guard !rawURL.isEmpty, let url = URL(string: rawURL) else { continue } - return url - } - return nil - } +enum BrowserZoomShortcutAction: Equatable { + case zoomIn + case zoomOut + case reset +} - static func openFolderURL(baseWebUIURL: URL, directoryPath: String) -> URL? { - var components = URLComponents(url: baseWebUIURL, resolvingAgainstBaseURL: false) - var queryItems = components?.queryItems ?? [] - queryItems.removeAll { $0.name == "folder" } - queryItems.append(URLQueryItem(name: "folder", value: directoryPath)) - components?.queryItems = queryItems - return components?.url - } +struct CommandPaletteDebugResultRow { + let commandId: String + let title: String + let shortcutHint: String? + let trailingLabel: String? + let score: Int } -struct VSCodeCLILaunchConfiguration { - let executableURL: URL - let argumentsPrefix: [String] - let environment: [String: String] +struct CommandPaletteDebugSnapshot { + let query: String + let mode: String + let results: [CommandPaletteDebugResultRow] + + static let empty = CommandPaletteDebugSnapshot(query: "", mode: "commands", results: []) } -enum VSCodeCLILaunchConfigurationBuilder { - static func launchConfiguration( - vscodeApplicationURL: URL, - baseEnvironment: [String: String] = ProcessInfo.processInfo.environment, - isExecutableAtPath: (String) -> Bool = { FileManager.default.isExecutableFile(atPath: $0) } - ) -> VSCodeCLILaunchConfiguration? { - let contentsURL = vscodeApplicationURL.appendingPathComponent("Contents", isDirectory: true) - let codeTunnelURL = contentsURL.appendingPathComponent("Resources/app/bin/code-tunnel", isDirectory: false) - guard isExecutableAtPath(codeTunnelURL.path) else { return nil } - - var environment = baseEnvironment - environment["ELECTRON_RUN_AS_NODE"] = "1" - environment.removeValue(forKey: "VSCODE_NODE_OPTIONS") - environment.removeValue(forKey: "VSCODE_NODE_REPL_EXTERNAL_MODULE") - if let nodeOptions = environment["NODE_OPTIONS"] { - environment["VSCODE_NODE_OPTIONS"] = nodeOptions - } - if let nodeReplExternalModule = environment["NODE_REPL_EXTERNAL_MODULE"] { - environment["VSCODE_NODE_REPL_EXTERNAL_MODULE"] = nodeReplExternalModule - } - environment.removeValue(forKey: "NODE_OPTIONS") - environment.removeValue(forKey: "NODE_REPL_EXTERNAL_MODULE") - environment["VSCODE_CLI_USE_FILE_KEYRING"] = "1" - - return VSCodeCLILaunchConfiguration( - executableURL: codeTunnelURL, - argumentsPrefix: [], - environment: environment - ) - } +/// Per-window command-palette state. Consolidates what used to be 7 parallel +/// `[UUID: _]` dictionaries (one dictionary keyed by main-window UUID instead), +/// so window teardown is a single `removeValue(forKey:)` instead of 7 duplicated +/// call sites. Refs #95. +struct CommandPaletteWindowState { + var isVisible = false + var pendingOpen = false + var recentRequestAt: TimeInterval? + var escapeSuppressed = false + var escapeSuppressionStartedAt: TimeInterval? + var selectionIndex = 0 + var snapshot: CommandPaletteDebugSnapshot = .empty } -final class VSCodeServeWebController { - static let shared = VSCodeServeWebController() - private static let serveWebStartupTimeoutSeconds: TimeInterval = 60 +func browserZoomShortcutAction( + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16, + literalChars: String? = nil +) -> BrowserZoomShortcutAction? { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function]) + let hasCommand = normalizedFlags.contains(.command) + let hasOnlyCommandAndOptionalShift = hasCommand && normalizedFlags.isDisjoint(with: [.control, .option]) - private let queue = DispatchQueue(label: "programa.vscode.serveWeb") - private let launchQueue = DispatchQueue(label: "programa.vscode.serveWeb.launch") - private let launchProcessOverride: ((URL, UInt64) -> (process: Process, url: URL)?)? - private var serveWebProcess: Process? - private var launchingProcess: Process? - private var connectionTokenFilesByProcessID: [ObjectIdentifier: URL] = [:] - private var serveWebURL: URL? - private var pendingCompletions: [(generation: UInt64, completion: (URL?) -> Void)] = [] - private var isLaunching = false - private var activeLaunchGeneration: UInt64? - private var lifecycleGeneration: UInt64 = 0 -#if DEBUG - private var testingTrackedProcesses: [Process] = [] -#endif + guard hasOnlyCommandAndOptionalShift else { return nil } + let keys = browserZoomShortcutKeyCandidates( + chars: chars, + literalChars: literalChars, + keyCode: keyCode + ) - private init(launchProcessOverride: ((URL, UInt64) -> (process: Process, url: URL)?)? = nil) { - self.launchProcessOverride = launchProcessOverride + if keys.contains("=") || keys.contains("+") || keyCode == 24 || keyCode == 69 { // kVK_ANSI_Equal / kVK_ANSI_KeypadPlus + return .zoomIn } -#if DEBUG - static func makeForTesting( - launchProcessOverride: @escaping (URL, UInt64) -> (process: Process, url: URL)? - ) -> VSCodeServeWebController { - VSCodeServeWebController(launchProcessOverride: launchProcessOverride) + if keys.contains("-") || keys.contains("_") || keyCode == 27 || keyCode == 78 { // kVK_ANSI_Minus / kVK_ANSI_KeypadMinus + return .zoomOut } - func trackConnectionTokenFileForTesting( - _ connectionTokenFileURL: URL, - setAsLaunchingProcess: Bool = false, - setAsServeWebProcess: Bool = false - ) { - let process = Process() - queue.sync { - if setAsLaunchingProcess { - self.launchingProcess = process - } - if setAsServeWebProcess { - self.serveWebProcess = process - } - if !setAsLaunchingProcess && !setAsServeWebProcess { - self.testingTrackedProcesses.append(process) - } - self.connectionTokenFilesByProcessID[ObjectIdentifier(process)] = connectionTokenFileURL - } + if keys.contains("0") || keyCode == 29 || keyCode == 82 { // kVK_ANSI_0 / kVK_ANSI_Keypad0 + return .reset } -#endif - func ensureServeWebURL(vscodeApplicationURL: URL, completion: @escaping (URL?) -> Void) { - queue.async { - if let process = self.serveWebProcess, - process.isRunning, - let url = self.serveWebURL { - DispatchQueue.main.async { - completion(url) - } - return - } - - let completionGeneration = self.lifecycleGeneration - self.pendingCompletions.append((generation: completionGeneration, completion: completion)) - guard !self.isLaunching else { return } - - self.isLaunching = true - let launchGeneration = completionGeneration - self.activeLaunchGeneration = launchGeneration + return nil +} - self.launchQueue.async { - let shouldLaunch = self.queue.sync { - self.lifecycleGeneration == launchGeneration - } - guard shouldLaunch else { - self.queue.async { - guard self.activeLaunchGeneration == launchGeneration else { return } - self.isLaunching = false - self.activeLaunchGeneration = nil - } - return - } - let launchResult = self.launchServeWebProcess( - vscodeApplicationURL: vscodeApplicationURL, - expectedGeneration: launchGeneration - ) - self.queue.async { - guard self.activeLaunchGeneration == launchGeneration else { - if let process = launchResult?.process, process.isRunning { - process.terminate() - } - return - } - self.isLaunching = false - self.activeLaunchGeneration = nil - - guard self.lifecycleGeneration == launchGeneration else { - if let launchedProcess = launchResult?.process, - self.launchingProcess === launchedProcess { - self.launchingProcess = nil - } - if let process = launchResult?.process, process.isRunning { - process.terminate() - } - return - } +func browserZoomShortcutKeyCandidates( + chars: String, + literalChars: String?, + keyCode: UInt16 +) -> Set { + var keys: Set = [chars.lowercased()] - if let launchResult { - self.launchingProcess = nil - self.serveWebProcess = launchResult.process - self.serveWebURL = launchResult.url - } else { - self.launchingProcess = nil - self.serveWebProcess = nil - self.serveWebURL = nil - } + if let literalChars, !literalChars.isEmpty { + keys.insert(literalChars.lowercased()) + } - var completions: [(URL?) -> Void] = [] - var remaining: [(generation: UInt64, completion: (URL?) -> Void)] = [] - for pending in self.pendingCompletions { - if pending.generation == launchGeneration { - completions.append(pending.completion) - } else { - remaining.append(pending) - } - } - self.pendingCompletions = remaining - let resolvedURL = self.serveWebURL - DispatchQueue.main.async { - completions.forEach { $0(resolvedURL) } - } - } - } - } + if let layoutChar = KeyboardLayout.character(forKeyCode: keyCode), !layoutChar.isEmpty { + keys.insert(layoutChar) } - func stop() { - let (processes, tokenFileURLs, completions): ([Process], [URL], [(URL?) -> Void]) = queue.sync { - self.lifecycleGeneration &+= 1 - self.isLaunching = false - self.activeLaunchGeneration = nil - var processes: [Process] = [] - if let process = self.serveWebProcess { - processes.append(process) - } - if let process = self.launchingProcess, - !processes.contains(where: { $0 === process }) { - processes.append(process) - } - self.serveWebProcess = nil - self.launchingProcess = nil -#if DEBUG - self.testingTrackedProcesses.removeAll() -#endif - var tokenFileURLs = processes.compactMap { - self.connectionTokenFilesByProcessID.removeValue(forKey: ObjectIdentifier($0)) - } - tokenFileURLs.append(contentsOf: self.connectionTokenFilesByProcessID.values) - self.connectionTokenFilesByProcessID.removeAll() - self.serveWebURL = nil - let completions = self.pendingCompletions.map(\.completion) - self.pendingCompletions.removeAll() - return (processes, tokenFileURLs, completions) - } + return keys +} - for tokenFileURL in tokenFileURLs where tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { - Self.removeConnectionTokenFile(at: tokenFileURL) - } +func shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: Bool, + hostedSize: CGSize, + hostedHiddenInHierarchy: Bool, + hostedAttachedToWindow: Bool +) -> Bool { + guard firstResponderIsWindow else { return false } + let tinyGeometry = hostedSize.width <= 1 || hostedSize.height <= 1 + return tinyGeometry || hostedHiddenInHierarchy || !hostedAttachedToWindow +} - for process in processes where process.isRunning { - process.terminate() - } +func shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: Bool, + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16, + literalChars: String? = nil +) -> Bool { + guard firstResponderIsGhostty else { return false } + return browserZoomShortcutAction( + flags: flags, + chars: chars, + keyCode: keyCode, + literalChars: literalChars + ) != nil +} - if !completions.isEmpty { - DispatchQueue.main.async { - completions.forEach { $0(nil) } - } - } +@discardableResult +func startOrFocusTerminalSearch( + _ terminalSurface: TerminalSurface, + searchFocusNotifier: @escaping (TerminalSurface) -> Void = { + NotificationCenter.default.post(name: .ghosttySearchFocus, object: $0) } - - func restart(vscodeApplicationURL: URL, completion: @escaping (URL?) -> Void) { - stop() - ensureServeWebURL(vscodeApplicationURL: vscodeApplicationURL, completion: completion) +) -> Bool { + if terminalSurface.searchState != nil { + searchFocusNotifier(terminalSurface) + return true } - private func launchServeWebProcess( - vscodeApplicationURL: URL, - expectedGeneration: UInt64 - ) -> (process: Process, url: URL)? { - if let launchProcessOverride { - return launchProcessOverride(vscodeApplicationURL, expectedGeneration) + if terminalSurface.performBindingAction("start_search") { + DispatchQueue.main.async { [weak terminalSurface] in + guard let terminalSurface, terminalSurface.searchState == nil else { return } + terminalSurface.searchState = TerminalSurface.SearchState() + searchFocusNotifier(terminalSurface) } + return true + } - guard let launchConfiguration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration( - vscodeApplicationURL: vscodeApplicationURL - ) else { return nil } + terminalSurface.searchState = TerminalSurface.SearchState() + searchFocusNotifier(terminalSurface) + return true +} - guard let connectionTokenFileURL = Self.makeConnectionTokenFile() else { - return nil - } +/// Let AppKit own native Cmd+` window cycling so key-window changes do not +/// re-enter our direct-to-menu shortcut path. +func shouldRouteCommandEquivalentDirectlyToMainMenu(_ event: NSEvent) -> Bool { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + guard flags.contains(.command) else { return false } - let process = Process() - process.executableURL = launchConfiguration.executableURL - // #21: reuse the port VS Code assigned on a previous run so the embedded browser - // keeps the same URL across restarts. ServeWebPortStore returns the persisted port - // only when it is still bindable, otherwise "0" (OS-assigned) — so a now-occupied - // port falls back gracefully instead of failing the launch. The --server-data-dir - // and persistent connection-token already fix Settings Sync / OAuth auth. - process.arguments = launchConfiguration.argumentsPrefix + [ - "serve-web", - "--accept-server-license-terms", - "--host", "127.0.0.1", - "--port", ServeWebPortStore.portArgument(persistedIn: Self.vscodeServerDataDir), - "--server-data-dir", Self.vscodeServerDataDir?.path ?? NSTemporaryDirectory(), - "--connection-token-file", connectionTokenFileURL.path, - ] - process.environment = launchConfiguration.environment - - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - let collector = ServeWebOutputCollector() - let outputReader: (FileHandle) -> Void = { fileHandle in - let data = fileHandle.availableData - guard !data.isEmpty else { return } - collector.append(data) - } - stdoutPipe.fileHandleForReading.readabilityHandler = outputReader - stderrPipe.fileHandleForReading.readabilityHandler = outputReader - - process.terminationHandler = { [weak self] terminatedProcess in - stdoutPipe.fileHandleForReading.readabilityHandler = nil - stderrPipe.fileHandleForReading.readabilityHandler = nil - Self.drainAvailableOutput(from: stdoutPipe.fileHandleForReading, collector: collector) - Self.drainAvailableOutput(from: stderrPipe.fileHandleForReading, collector: collector) - collector.markProcessExited() - self?.queue.async { - guard let self else { return } - if self.launchingProcess === terminatedProcess { - self.launchingProcess = nil - } - if self.serveWebProcess === terminatedProcess { - self.serveWebProcess = nil - self.serveWebURL = nil - } - if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue( - forKey: ObjectIdentifier(terminatedProcess) - ), tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { - Self.removeConnectionTokenFile(at: tokenFileURL) - } - } - } + let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) + if event.keyCode == 50, + normalizedFlags == [.command] || normalizedFlags == [.command, .shift] { + return false + } - let didStart: Bool = queue.sync { - guard self.lifecycleGeneration == expectedGeneration, - self.activeLaunchGeneration == expectedGeneration else { - return false - } - self.launchingProcess = process - self.connectionTokenFilesByProcessID[ObjectIdentifier(process)] = connectionTokenFileURL - do { - try process.run() - return true - } catch { - if self.launchingProcess === process { - self.launchingProcess = nil - } - if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue( - forKey: ObjectIdentifier(process) - ), tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { - Self.removeConnectionTokenFile(at: tokenFileURL) - } - return false - } - } - guard didStart else { - stdoutPipe.fileHandleForReading.readabilityHandler = nil - stderrPipe.fileHandleForReading.readabilityHandler = nil - if connectionTokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { - Self.removeConnectionTokenFile(at: connectionTokenFileURL) - } - return nil - } + return true +} - guard collector.waitForURL(timeoutSeconds: Self.serveWebStartupTimeoutSeconds), - let serveWebURL = collector.webUIURL else { - stdoutPipe.fileHandleForReading.readabilityHandler = nil - stderrPipe.fileHandleForReading.readabilityHandler = nil - if process.isRunning { - process.terminate() - } else { - queue.sync { - if self.launchingProcess === process { - self.launchingProcess = nil - } - if self.serveWebProcess === process { - self.serveWebProcess = nil - self.serveWebURL = nil - } - if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue( - forKey: ObjectIdentifier(process) - ), tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { - Self.removeConnectionTokenFile(at: tokenFileURL) - } - } - } - return nil - } +private enum BrowserFindCommandEquivalent { + case find + case findNext + case findPrevious + case hideFind + case useSelection - // #21: remember the assigned port so the next launch can request it again. - if let assignedPort = serveWebURL.port { - ServeWebPortStore.persist(port: assignedPort, in: Self.vscodeServerDataDir) + var keepsProgramaBrowserFindBarOwnershipWhenVisible: Bool { + switch self { + case .find, .findNext, .findPrevious, .hideFind: + return true + case .useSelection: + return false } - - return (process, serveWebURL) } +} - private static func drainAvailableOutput(from fileHandle: FileHandle, collector: ServeWebOutputCollector) { - while true { - let data = fileHandle.availableData - guard !data.isEmpty else { return } - collector.append(data) - } +private func programaIsLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool { + guard let responder else { return false } + let responderType = String(describing: type(of: responder)) + if responderType.contains("WKInspector") { + return true } - - /// Stable Application Support directory for VS Code serve-web state. - /// Mirrors the "programa" subdirectory convention used elsewhere in the app. - private static var vscodeServerDataDir: URL? { - FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) - .first? - .appendingPathComponent("programa", isDirectory: true) - .appendingPathComponent("vscode-server", isDirectory: true) + guard let view = responder as? NSView else { return false } + var node: NSView? = view + var hops = 0 + while let current = node, hops < 64 { + if String(describing: type(of: current)).contains("WKInspector") { + return true + } + node = current.superview + hops += 1 } + return false +} - private static func randomConnectionToken() -> String { - UUID().uuidString.replacingOccurrences(of: "-", with: "") - } +private func browserFindCommandEquivalent(for event: NSEvent) -> BrowserFindCommandEquivalent? { + let flags = event.modifierFlags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) - /// Returns a URL for the connection token file. Prefers a persistent file - /// under Application Support so the token (and the browser's vscode-tkn - /// cookie) survives Programa restarts (issue #21). Falls back to an - /// ephemeral temp-dir file when Application Support is unavailable. - private static func makeConnectionTokenFile() -> URL? { - if let persistentURL = vscodeServerDataDir? - .appendingPathComponent("connection-token", isDirectory: false) { - if let url = makePersistentConnectionTokenFile(at: persistentURL) { - return url - } + let normalizedChars = KeyboardLayout.normalizedCharacters(for: event).lowercased() + let hasSingleASCIIShortcutChar = + normalizedChars.count == 1 && normalizedChars.allSatisfy(\.isASCII) + let producedAnyASCIIShortcutChar = normalizedChars.contains(where: \.isASCII) + func matches(_ chars: String, keyCode: UInt16) -> Bool { + if hasSingleASCIIShortcutChar { + return normalizedChars == chars + } + if !producedAnyASCIIShortcutChar { + return event.keyCode == keyCode } - return makeEphemeralConnectionTokenFile() + return false } - private static func makePersistentConnectionTokenFile(at tokenFileURL: URL) -> URL? { - let dir = tokenFileURL.deletingLastPathComponent() - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - // Reuse an existing non-empty token so the browser cookie stays valid. - if let existingData = try? Data(contentsOf: tokenFileURL), !existingData.isEmpty { - return tokenFileURL + switch flags { + case [.command]: + if matches("e", keyCode: 14) { // kVK_ANSI_E + return .useSelection } - let token = randomConnectionToken() - guard let tokenData = token.data(using: .utf8) else { return nil } - let fd = open(tokenFileURL.path, O_WRONLY | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR) - guard fd >= 0 else { return nil } - defer { _ = close(fd) } - let wroteAllBytes = tokenData.withUnsafeBytes { rawBuffer in - guard let baseAddress = rawBuffer.baseAddress else { return false } - return write(fd, baseAddress, rawBuffer.count) == rawBuffer.count + if matches("f", keyCode: 3) { // kVK_ANSI_F + return .find } - guard wroteAllBytes else { - try? FileManager.default.removeItem(at: tokenFileURL) - return nil + if matches("g", keyCode: 5) { // kVK_ANSI_G + return .findNext } - return tokenFileURL - } - - private static func makeEphemeralConnectionTokenFile() -> URL? { - let token = randomConnectionToken() - let tokenFileName = "cmux-vscode-token-\(UUID().uuidString)" - let tokenFileURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent(tokenFileName, isDirectory: false) - guard let tokenData = token.data(using: .utf8) else { return nil } - let fileDescriptor = open(tokenFileURL.path, O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR) - guard fileDescriptor >= 0 else { return nil } - defer { _ = close(fileDescriptor) } - let wroteAllBytes = tokenData.withUnsafeBytes { rawBuffer in - guard let baseAddress = rawBuffer.baseAddress else { return false } - return write(fileDescriptor, baseAddress, rawBuffer.count) == rawBuffer.count + return nil + case [.command, .shift]: + if matches("f", keyCode: 3) { // kVK_ANSI_F + return .hideFind } - guard wroteAllBytes else { - removeConnectionTokenFile(at: tokenFileURL) - return nil + if matches("g", keyCode: 5) { // kVK_ANSI_G + return .findPrevious } - return tokenFileURL + return nil + default: + return nil } +} - private static func removeConnectionTokenFile(at url: URL) { - try? FileManager.default.removeItem(at: url) +/// 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, + owningWebView: ProgramaWebView? = nil +) -> Bool { + guard let shortcut = browserFindCommandEquivalent(for: event) else { + return false } -} -final class ServeWebOutputCollector { - private let lock = NSLock() - private let semaphore = DispatchSemaphore(value: 0) - private var outputBuffer = "" - private var resolvedURL: URL? - private var didSignal = false - - var webUIURL: URL? { - lock.lock() - defer { lock.unlock() } - return resolvedURL - } - - func append(_ data: Data) { - guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { return } - lock.lock() - defer { lock.unlock() } - guard resolvedURL == nil else { return } - outputBuffer.append(text) - while let newlineIndex = outputBuffer.firstIndex(where: \.isNewline) { - let line = String(outputBuffer[.. Bool { - if webUIURL != nil { return true } - _ = semaphore.wait(timeout: .now() + timeoutSeconds) - return webUIURL != nil - } + return true } -/// Persists the VS Code serve-web port across restarts (#21) so the embedded browser -/// keeps the same URL. The port `code serve-web` assigns is written under the serve-web -/// data dir and reused on the next launch — but only when it is still bindable, so a -/// now-occupied port falls back to an OS-assigned one instead of failing the launch. -enum ServeWebPortStore { - static let fileName = "serve-web-port" - - /// The `--port` argument for `code serve-web`: the persisted port when it is valid and - /// currently free, otherwise "0" (let the OS assign one). `isPortAvailable` is injectable - /// for testing; it defaults to a real loopback bind probe. - static func portArgument( - persistedIn directory: URL?, - isPortAvailable: (Int) -> Bool = ServeWebPortStore.isPortAvailable - ) -> String { - guard let url = portFileURL(in: directory), - let data = try? Data(contentsOf: url), - let raw = String(data: data, encoding: .utf8), - let port = parsePort(raw), - isPortAvailable(port) else { - return "0" - } - return String(port) +func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? { + guard let responder else { return nil } + if let ghosttyView = responder as? GhosttyNSView { + return ghosttyView } - /// Records the port VS Code assigned so the next launch can request it again. No-op for - /// out-of-range ports or when the data dir is unavailable. - static func persist(port: Int, in directory: URL?) { - guard isValidPort(port), let url = portFileURL(in: directory) else { return } - let dir = url.deletingLastPathComponent() - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - try? Data(String(port).utf8).write(to: url, options: .atomic) + if let view = responder as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: view) { + return ghosttyView } - /// Parses a stored port string, returning nil for malformed or out-of-range values. - static func parsePort(_ raw: String) -> Int? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard let port = Int(trimmed), isValidPort(port) else { return nil } - return port - } + if let textView = responder as? NSTextView { + if textView.isFieldEditor, + let ownerView = programaFieldEditorOwnerView(textView), + let ghosttyView = cmuxOwningGhosttyView(for: ownerView) { + return ghosttyView + } - /// True when a TCP socket can bind 127.0.0.1:port right now. - static func isPortAvailable(_ port: Int) -> Bool { - guard isValidPort(port) else { return false } - let fd = socket(AF_INET, SOCK_STREAM, 0) - guard fd >= 0 else { return false } - defer { close(fd) } - var reuse: Int32 = 1 - _ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout.size)) - var addr = sockaddr_in() - addr.sin_family = sa_family_t(AF_INET) - addr.sin_port = in_port_t(UInt16(port)).bigEndian - addr.sin_addr.s_addr = inet_addr("127.0.0.1") - let bound = withUnsafePointer(to: &addr) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in - bind(fd, sockaddrPointer, socklen_t(MemoryLayout.size)) - } + if !textView.isFieldEditor, + let delegateView = textView.delegate as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: delegateView) { + return ghosttyView } - return bound == 0 } - private static func isValidPort(_ port: Int) -> Bool { (1...65535).contains(port) } - - private static func portFileURL(in directory: URL?) -> URL? { - directory?.appendingPathComponent(fileName, isDirectory: false) + var current = responder.nextResponder + while let next = current { + if let ghosttyView = next as? GhosttyNSView { + return ghosttyView + } + if let view = next as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: view) { + return ghosttyView + } + current = next.nextResponder } + + return nil } -enum WorkspaceShortcutMapper { - /// Maps numbered workspace shortcuts to a zero-based workspace index. - /// 1...8 target fixed indices; 9 always targets the last workspace. - static func workspaceIndex(forDigit digit: Int, workspaceCount: Int) -> Int? { - guard workspaceCount > 0 else { return nil } - guard (1...9).contains(digit) else { return nil } +private func programaFieldEditorOwnerView(_ editor: NSTextView) -> NSView? { + guard editor.isFieldEditor else { return nil } - if digit == 9 { - return workspaceCount - 1 + var current = editor.nextResponder + while let next = current { + if let view = next as? NSView { + return view } - - let index = digit - 1 - return index < workspaceCount ? index : nil + current = next.nextResponder } - /// Returns the primary digit badge to display for a workspace row. - /// Picks the lowest digit that maps to that row index. - static func digitForWorkspace(at index: Int, workspaceCount: Int) -> Int? { - guard index >= 0 && index < workspaceCount else { return nil } - for digit in 1...9 { - if workspaceIndex(forDigit: digit, workspaceCount: workspaceCount) == index { - return digit - } - } - return nil - } + return editor.superview } -struct ProgramaCLIPathInstaller { - struct InstallOutcome { - let usedAdministratorPrivileges: Bool - let destinationURL: URL - let sourceURL: URL - } - - struct UninstallOutcome { - let usedAdministratorPrivileges: Bool - let destinationURL: URL - let removedExistingEntry: Bool - } - - enum InstallerError: LocalizedError { - case bundledCLIMissing(expectedPath: String) - case destinationParentNotDirectory(path: String) - case destinationIsDirectory(path: String) - case installVerificationFailed(path: String) - case uninstallVerificationFailed(path: String) - case privilegedCommandFailed(message: String) - - var errorDescription: String? { - switch self { - case .bundledCLIMissing(let expectedPath): - return "Bundled Programa CLI was not found at \(expectedPath)." - case .destinationParentNotDirectory(let path): - return "Expected \(path) to be a directory." - case .destinationIsDirectory(let path): - return "\(path) is a directory. Remove or rename it and try again." - case .installVerificationFailed(let path): - return "Installed symlink at \(path) did not point to the bundled programa CLI." - case .uninstallVerificationFailed(let path): - return "Failed to remove \(path)." - case .privilegedCommandFailed(let message): - return "Administrator action failed: \(message)" - } - } - } - - typealias PrivilegedInstallHandler = (_ sourceURL: URL, _ destinationURL: URL) throws -> Void - typealias PrivilegedUninstallHandler = (_ destinationURL: URL) throws -> Void - - let fileManager: FileManager - let destinationURL: URL - private let bundledCLIURLProvider: () -> URL? - private let expectedBundledCLIPath: String - private let privilegedInstaller: PrivilegedInstallHandler - private let privilegedUninstaller: PrivilegedUninstallHandler - - init( - fileManager: FileManager = .default, - destinationURL: URL = URL(fileURLWithPath: "/usr/local/bin/programa"), - bundledCLIURLProvider: @escaping () -> URL? = { - ProgramaCLIPathInstaller.defaultBundledCLIURL() - }, - expectedBundledCLIPath: String = ProgramaCLIPathInstaller.defaultBundledCLIExpectedPath(), - privilegedInstaller: PrivilegedInstallHandler? = nil, - privilegedUninstaller: PrivilegedUninstallHandler? = nil - ) { - self.fileManager = fileManager - self.destinationURL = destinationURL - self.bundledCLIURLProvider = bundledCLIURLProvider - self.expectedBundledCLIPath = expectedBundledCLIPath - self.privilegedInstaller = privilegedInstaller ?? Self.installWithAdministratorPrivileges(sourceURL:destinationURL:) - self.privilegedUninstaller = privilegedUninstaller ?? Self.uninstallWithAdministratorPrivileges(destinationURL:) - } - - var destinationPath: String { - destinationURL.path - } - - func install() throws -> InstallOutcome { - let sourceURL = try resolveBundledCLIURL() - do { - try installWithoutAdministratorPrivileges(sourceURL: sourceURL) - return InstallOutcome( - usedAdministratorPrivileges: false, - destinationURL: destinationURL, - sourceURL: sourceURL - ) - } catch { - guard Self.isPermissionDenied(error) else { throw error } - try ensureDestinationIsNotDirectory() - try privilegedInstaller(sourceURL, destinationURL) - try verifyInstalledSymlinkTarget(sourceURL: sourceURL) - return InstallOutcome( - usedAdministratorPrivileges: true, - destinationURL: destinationURL, - sourceURL: sourceURL - ) - } +private func cmuxOwningGhosttyView(for view: NSView) -> GhosttyNSView? { + if let ghosttyView = view as? GhosttyNSView { + return ghosttyView } - func uninstall() throws -> UninstallOutcome { - do { - let removedExistingEntry = try uninstallWithoutAdministratorPrivileges() - return UninstallOutcome( - usedAdministratorPrivileges: false, - destinationURL: destinationURL, - removedExistingEntry: removedExistingEntry - ) - } catch { - guard Self.isPermissionDenied(error) else { throw error } - try ensureDestinationIsNotDirectory() - let removedExistingEntry = destinationEntryExists() - try privilegedUninstaller(destinationURL) - if destinationEntryExists() { - throw InstallerError.uninstallVerificationFailed(path: destinationURL.path) - } - return UninstallOutcome( - usedAdministratorPrivileges: true, - destinationURL: destinationURL, - removedExistingEntry: removedExistingEntry - ) + var current: NSView? = view.superview + while let candidate = current { + if let ghosttyView = candidate as? GhosttyNSView { + return ghosttyView } + current = candidate.superview } - func isInstalled() -> Bool { - guard let sourceURL = bundledCLIURLProvider()?.standardizedFileURL else { return false } - guard let installedTargetURL = symlinkDestinationURL() else { return false } - return installedTargetURL == sourceURL - } - - private func resolveBundledCLIURL() throws -> URL { - guard let sourceURL = bundledCLIURLProvider()?.standardizedFileURL else { - throw InstallerError.bundledCLIMissing(expectedPath: expectedBundledCLIPath) - } + return nil +} - var isDirectory: ObjCBool = false - guard fileManager.fileExists(atPath: sourceURL.path, isDirectory: &isDirectory), !isDirectory.boolValue else { - throw InstallerError.bundledCLIMissing(expectedPath: sourceURL.path) - } - return sourceURL - } +#if DEBUG +func browserZoomShortcutTraceCandidate( + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16, + literalChars: String? = nil +) -> Bool { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function]) + guard normalizedFlags.contains(.command) else { return false } - private func installWithoutAdministratorPrivileges(sourceURL: URL) throws { - try ensureDestinationParentDirectoryExists() - try ensureDestinationIsNotDirectory() - if destinationEntryExists() { - try fileManager.removeItem(at: destinationURL) - } - try fileManager.createSymbolicLink(at: destinationURL, withDestinationURL: sourceURL) - try verifyInstalledSymlinkTarget(sourceURL: sourceURL) + let keys = browserZoomShortcutKeyCandidates( + chars: chars, + literalChars: literalChars, + keyCode: keyCode + ) + if keys.contains("=") || keys.contains("+") || keys.contains("-") || keys.contains("_") || keys.contains("0") { + return true } - - @discardableResult - private func uninstallWithoutAdministratorPrivileges() throws -> Bool { - try ensureDestinationIsNotDirectory() - let existed = destinationEntryExists() - if existed { - try fileManager.removeItem(at: destinationURL) - } - if destinationEntryExists() { - throw InstallerError.uninstallVerificationFailed(path: destinationURL.path) - } - return existed + switch keyCode { + case 24, 27, 29, 69, 78, 82: // ANSI and keypad zoom keys + return true + default: + return false } +} - /// Check if the destination path has any filesystem entry (including dangling symlinks). - /// `FileManager.fileExists` follows symlinks, so a dangling symlink returns false. - private func destinationEntryExists() -> Bool { - (try? fileManager.attributesOfItem(atPath: destinationURL.path)) != nil - } +func browserZoomShortcutTraceFlagsString(_ flags: NSEvent.ModifierFlags) -> String { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function]) + var parts: [String] = [] + if normalizedFlags.contains(.command) { parts.append("Cmd") } + if normalizedFlags.contains(.shift) { parts.append("Shift") } + if normalizedFlags.contains(.option) { parts.append("Opt") } + if normalizedFlags.contains(.control) { parts.append("Ctrl") } + return parts.isEmpty ? "none" : parts.joined(separator: "+") +} - private func verifyInstalledSymlinkTarget(sourceURL: URL) throws { - guard let installedTargetURL = symlinkDestinationURL(), - installedTargetURL == sourceURL.standardizedFileURL else { - throw InstallerError.installVerificationFailed(path: destinationURL.path) - } +func browserZoomShortcutTraceActionString(_ action: BrowserZoomShortcutAction?) -> String { + guard let action else { return "none" } + switch action { + case .zoomIn: return "zoomIn" + case .zoomOut: return "zoomOut" + case .reset: return "reset" } +} +#endif - private func symlinkDestinationURL() -> URL? { - guard fileManager.fileExists(atPath: destinationURL.path) else { return nil } - guard let destinationPath = try? fileManager.destinationOfSymbolicLink(atPath: destinationURL.path) else { - return nil +func shouldSuppressWindowMoveForFolderDrag(hitView: NSView?) -> Bool { + var candidate = hitView + while let view = candidate { + if view is DraggableFolderNSView { + return true } - return URL( - fileURLWithPath: destinationPath, - relativeTo: destinationURL.deletingLastPathComponent() - ).standardizedFileURL + candidate = view.superview } + return false +} - private func ensureDestinationParentDirectoryExists() throws { - let parentURL = destinationURL.deletingLastPathComponent() - var isDirectory: ObjCBool = false - if fileManager.fileExists(atPath: parentURL.path, isDirectory: &isDirectory) { - guard isDirectory.boolValue else { - throw InstallerError.destinationParentNotDirectory(path: parentURL.path) - } - return - } - try fileManager.createDirectory(at: parentURL, withIntermediateDirectories: true) +func shouldSuppressWindowMoveForFolderDrag(window: NSWindow, event: NSEvent) -> Bool { + guard event.type == .leftMouseDown, + window.isMovable, + let contentView = window.contentView else { + return false } - private func ensureDestinationIsNotDirectory() throws { - guard let values = try resourceValuesIfFileExists( - at: destinationURL, - keys: [.isDirectoryKey, .isSymbolicLinkKey] - ) else { - return - } - - if values.isDirectory == true, values.isSymbolicLink != true { - throw InstallerError.destinationIsDirectory(path: destinationURL.path) - } - } + let contentPoint = contentView.convert(event.locationInWindow, from: nil) + let hitView = contentView.hitTest(contentPoint) + return shouldSuppressWindowMoveForFolderDrag(hitView: hitView) +} - private func resourceValuesIfFileExists( - at url: URL, - keys: Set - ) throws -> URLResourceValues? { - do { - return try url.resourceValues(forKeys: keys) - } catch { - let nsError = error as NSError - if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError { - return nil - } - if nsError.domain == NSPOSIXErrorDomain, - POSIXErrorCode(rawValue: Int32(nsError.code)) == .ENOENT { - return nil - } - throw error - } - } +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation { + nonisolated(unsafe) static var shared: AppDelegate? - private static func defaultBundledCLIURL(bundle: Bundle = .main) -> URL? { - bundle.resourceURL?.appendingPathComponent("bin/programa", isDirectory: false) - } + private static let cachedIsRunningUnderXCTest = detectRunningUnderXCTest(ProcessInfo.processInfo.environment) - private static func defaultBundledCLIExpectedPath(bundle: Bundle = .main) -> String { - bundle.bundleURL - .appendingPathComponent("Contents/Resources/bin/programa", isDirectory: false) - .path + private var isRunningUnderXCTestCached: Bool { + Self.cachedIsRunningUnderXCTest } - private static func installWithAdministratorPrivileges(sourceURL: URL, destinationURL: URL) throws { - let destinationPath = destinationURL.path - let parentPath = destinationURL.deletingLastPathComponent().path - let command = "/bin/mkdir -p \(shellQuoted(parentPath)) && " + - "/bin/rm -f \(shellQuoted(destinationPath)) && " + - "/bin/ln -s \(shellQuoted(sourceURL.path)) \(shellQuoted(destinationPath))" - try runPrivilegedShellCommand(command) - } + private static func detectRunningUnderXCTest(_ env: [String: String]) -> Bool { + if env["XCTestConfigurationFilePath"] != nil { return true } + if env["XCTestBundlePath"] != nil { return true } + if env["XCTestSessionIdentifier"] != nil { return true } + if env["XCInjectBundle"] != nil { return true } + if env["XCInjectBundleInto"] != nil { return true } + if env["DYLD_INSERT_LIBRARIES"]?.contains("libXCTest") == true { return true } + if env.keys.contains(where: { $0.hasPrefix("PROGRAMA_UI_TEST_") }) { return true } + return false + } - private static func uninstallWithAdministratorPrivileges(destinationURL: URL) throws { - let command = "/bin/rm -f \(shellQuoted(destinationURL.path))" - try runPrivilegedShellCommand(command) + private func isRunningUnderXCTest(_ env: [String: String]) -> Bool { + // On some macOS/Xcode setups, the app-under-test process doesn't get + // `XCTestConfigurationFilePath`. Use a broader set of signals so UI tests + // can reliably skip heavyweight startup work and bring up a window. + Self.detectRunningUnderXCTest(env) } - private static func runPrivilegedShellCommand(_ command: String) throws { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") - process.arguments = [ - "-e", "on run argv", - "-e", "do shell script (item 1 of argv) with administrator privileges", - "-e", "end run", - command - ] - let stdout = Pipe() - let stderr = Pipe() - process.standardOutput = stdout - process.standardError = stderr - try process.run() - process.waitUntilExit() - - guard process.terminationStatus == 0 else { - let stderrText = String( - data: stderr.fileHandleForReading.readDataToEndOfFile(), - encoding: .utf8 - )?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let stdoutText = String( - data: stdout.fileHandleForReading.readDataToEndOfFile(), - encoding: .utf8 - )?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let details = stderrText.isEmpty ? stdoutText : stderrText - let message = details.isEmpty - ? "osascript exited with status \(process.terminationStatus)." - : details - throw InstallerError.privilegedCommandFailed(message: message) - } - } - - private static func shellQuoted(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" - } - - private static func isPermissionDenied(_ error: Error) -> Bool { - isPermissionDenied(error as NSError) - } - - private static func isPermissionDenied(_ error: NSError) -> Bool { - if error.domain == NSPOSIXErrorDomain, - let code = POSIXErrorCode(rawValue: Int32(error.code)), - code == .EACCES || code == .EPERM || code == .EROFS { - return true - } + final class MainWindowContext { + let windowId: UUID + let tabManager: TabManager + let sidebarState: SidebarState + let sidebarSelectionState: SidebarSelectionState + weak var window: NSWindow? - if error.domain == NSCocoaErrorDomain { - switch error.code { - case NSFileWriteNoPermissionError, NSFileReadNoPermissionError, NSFileWriteVolumeReadOnlyError: - return true - default: - break - } + init( + windowId: UUID, + tabManager: TabManager, + sidebarState: SidebarState, + sidebarSelectionState: SidebarSelectionState, + window: NSWindow? + ) { + self.windowId = windowId + self.tabManager = tabManager + self.sidebarState = sidebarState + self.sidebarSelectionState = sidebarSelectionState + self.window = window } + } - if let underlying = error.userInfo[NSUnderlyingErrorKey] as? NSError { - return isPermissionDenied(underlying) - } + private final class MainWindowController: NSWindowController, NSWindowDelegate { + var onClose: (() -> Void)? - return false + func windowWillClose(_ notification: Notification) { + onClose?() + } } -} -private extension NSScreen { - var programaDisplayID: UInt32? { - let key = NSDeviceDescriptionKey("NSScreenNumber") - guard let value = deviceDescription[key] as? NSNumber else { return nil } - return value.uint32Value + struct ScriptableMainWindowState { + let windowId: UUID + let tabManager: TabManager + let window: NSWindow? } -} - -func browserOmnibarSelectionDeltaForCommandNavigation( - hasFocusedAddressBar: Bool, - flags: NSEvent.ModifierFlags, - chars: String -) -> Int? { - guard hasFocusedAddressBar else { return nil } - let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) - let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control] - guard isCommandOrControlOnly else { return nil } - if chars == "n" { return 1 } - if chars == "p" { return -1 } - return nil -} -func browserOmnibarSelectionDeltaForArrowNavigation( - hasFocusedAddressBar: Bool, - flags: NSEvent.ModifierFlags, - keyCode: UInt16 -) -> Int? { - guard hasFocusedAddressBar else { return nil } - let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) - guard normalizedFlags == [] else { return nil } - switch keyCode { - case 125: return 1 - case 126: return -1 - default: return nil + struct SessionDisplayGeometry { + let displayID: UInt32? + let frame: CGRect + let visibleFrame: CGRect } -} - -func browserOmnibarNormalizedModifierFlags(_ flags: NSEvent.ModifierFlags) -> NSEvent.ModifierFlags { - flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) -} -func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool { - let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) - return normalizedFlags == [] || normalizedFlags == [.shift] -} + struct PersistedWindowGeometry: Codable, Sendable { + let version: Int + let frame: SessionRectSnapshot + let display: SessionDisplaySnapshot? + } -func browserResponderHasMarkedText(_ responder: NSResponder?) -> Bool { - guard let responder else { return false } + nonisolated static let persistedWindowGeometrySchemaVersion = 2 + private nonisolated static let persistedWindowGeometryDefaultsKey = "programa.session.lastWindowGeometry.v2" + private nonisolated static let legacyPersistedWindowGeometryDefaultsKeys = [ + "programa.session.lastWindowGeometry.v1" + ] - // During IME composition, Return/Enter belongs to the text system so the - // candidate list can commit or confirm the marked text. - if let textInputClient = responder as? NSTextInputClient { - if textInputClient.hasMarkedText() { return true } + weak var tabManager: TabManager? + weak var notificationStore: TerminalNotificationStore? + weak var sidebarState: SidebarState? + weak var fullscreenControlsViewModel: TitlebarControlsViewModel? + weak var sidebarSelectionState: SidebarSelectionState? + var shortcutLayoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) + private var workspaceObserver: NSObjectProtocol? + private var lifecycleSnapshotObservers: [NSObjectProtocol] = [] + private var windowKeyObserver: NSObjectProtocol? + private var shortcutMonitor: Any? + private var shortcutDefaultsObserver: NSObjectProtocol? + private var menuBarVisibilityObserver: NSObjectProtocol? + private var splitButtonTooltipRefreshScheduled = false + private struct PendingConfiguredShortcutChord { + let firstStroke: ShortcutStroke + let windowNumber: Int? } + private var pendingConfiguredShortcutChord: PendingConfiguredShortcutChord? + private var activeConfiguredShortcutChordPrefixForCurrentEvent: ShortcutStroke? + private var configuredShortcutChordActions: [KeyboardShortcutSettings.Action] = [] + private var ghosttyConfigObserver: NSObjectProtocol? + var ghosttyGotoSplitLeftShortcut: StoredShortcut? + var ghosttyGotoSplitRightShortcut: StoredShortcut? + var ghosttyGotoSplitUpShortcut: StoredShortcut? + var ghosttyGotoSplitDownShortcut: StoredShortcut? + private var browserAddressBarFocusedPanelId: UUID? + private var browserOmnibarRepeatStartWorkItem: DispatchWorkItem? + private var browserOmnibarRepeatTickWorkItem: DispatchWorkItem? + private var browserOmnibarRepeatKeyCode: UInt16? + private var browserOmnibarRepeatDelta: Int = 0 + private var browserAddressBarFocusObserver: NSObjectProtocol? + private var browserAddressBarBlurObserver: NSObjectProtocol? + private let updateController = UpdateController() + private lazy var titlebarAccessoryController = UpdateTitlebarAccessoryController(viewModel: updateViewModel) + private let windowDecorationsController = WindowDecorationsController() + private var menuBarExtraController: MenuBarExtraController? + private static let serviceErrorNoPath = NSString(string: String(localized: "error.clipboardFolderPath", defaultValue: "Could not load any folder path from the clipboard.")) + private static let didInstallWindowKeyEquivalentSwizzle: Void = { + let targetClass: AnyClass = NSWindow.self + let originalSelector = #selector(NSWindow.performKeyEquivalent(with:)) + let swizzledSelector = #selector(NSWindow.programa_performKeyEquivalent(with:)) + guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), + let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { + return + } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() + private static let didInstallWindowFirstResponderSwizzle: Void = { + let targetClass: AnyClass = NSWindow.self + let originalSelector = #selector(NSWindow.makeFirstResponder(_:)) + let swizzledSelector = #selector(NSWindow.programa_makeFirstResponder(_:)) + guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), + let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { + return + } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() + private static let didInstallWindowSendEventSwizzle: Void = { + let targetClass: AnyClass = NSWindow.self + let originalSelector = #selector(NSWindow.sendEvent(_:)) + let swizzledSelector = #selector(NSWindow.programa_sendEvent(_:)) + guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), + let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { + return + } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() + private static let didInstallApplicationSendEventSwizzle: Void = { + let targetClass: AnyClass = NSApplication.self + let originalSelector = #selector(NSApplication.sendEvent(_:)) + let swizzledSelector = #selector(NSApplication.programa_applicationSendEvent(_:)) + guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), + let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { + return + } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() - if let textField = responder as? NSTextField, - let editor = textField.currentEditor() as? NSTextView { - if editor.hasMarkedText() { return true } +#if DEBUG + var didSetupJumpUnreadUITest = false + var jumpUnreadFocusExpectation: (tabId: UUID, surfaceId: UUID)? + var jumpUnreadFocusObserver: NSObjectProtocol? + var didSetupTerminalCmdClickUITest = false + var didSetupGotoSplitUITest = false + var didSetupBonsplitTabDragUITest = false + var terminalCmdClickUITestPoller: DispatchSourceTimer? + var bonsplitTabDragUITestRecorder: DispatchSourceTimer? + var gotoSplitUITestRecorder: DispatchSourceTimer? + var gotoSplitUITestObservers: [NSObjectProtocol] = [] + var didSetupMultiWindowNotificationsUITest = false + var didSetupDisplayResolutionUITestDiagnostics = false + var displayResolutionUITestObservers: [NSObjectProtocol] = [] + private struct UITestRenderDiagnosticsSnapshot { + let panelId: UUID + let drawCount: Int + let presentCount: Int + let lastPresentTime: Double + let windowVisible: Bool + let appIsActive: Bool + let desiredFocus: Bool + let isFirstResponder: Bool } + var debugCloseMainWindowConfirmationHandler: ((NSWindow) -> Bool)? + var debugCreateMainWindowSourceIsNativeFullScreenOverride: Bool? + // Keep debug-only windows alive when tests intentionally inject key mismatches. + private var debugDetachedContextWindows: [NSWindow] = [] - // WKWebView clears marked text before performKeyEquivalent fires, so the - // synchronous hasMarkedText() check above can return false even though an IME - // composition just ended on the same Enter keystroke. Check the JS bridge's - // composition timestamp to detect this race condition (#2626). - if let webView = responder.programaEnclosingProgramaWebView { - if webView.webViewIsComposing { return true } - let age = ProcessInfo.processInfo.systemUptime - webView.recentCompositionEndTimestamp - if age >= 0 && age < 0.15 { return true } + private func childExitKeyboardProbePath() -> String? { + let env = ProcessInfo.processInfo.environment + guard env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_SETUP"] == "1", + let path = env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_PATH"], + !path.isEmpty else { + return nil + } + return path } - return false -} - -private extension NSResponder { - /// Walk the responder chain to find the enclosing ProgramaWebView. - var programaEnclosingProgramaWebView: ProgramaWebView? { - var current: NSResponder? = self - while let responder = current { - if let webView = responder as? ProgramaWebView { return webView } - current = responder.nextResponder - } - return nil + private func childExitKeyboardProbeHex(_ value: String?) -> String { + guard let value else { return "" } + return value.unicodeScalars + .map { String(format: "%04X", $0.value) } + .joined(separator: ",") } -} -func shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: UInt16, - firstResponderIsBrowser: Bool, - firstResponderHasMarkedText: Bool = false, - flags: NSEvent.ModifierFlags -) -> Bool { - guard firstResponderIsBrowser else { return false } - guard !firstResponderHasMarkedText else { return false } - guard keyCode == 36 || keyCode == 76 else { return false } - // Keep browser Return forwarding narrow: only plain/Shift Return should be - // treated as submit-intent. Command-modified Return is reserved for app shortcuts - // like Toggle Pane Zoom (Cmd+Shift+Enter). - return browserOmnibarShouldSubmitOnReturn(flags: flags) -} - -func shouldToggleMainWindowFullScreenForCommandControlFShortcut( - flags: NSEvent.ModifierFlags, - chars: String, - keyCode: UInt16, - layoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) -) -> Bool { - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) - guard normalizedFlags == [.command, .control] else { return false } - let normalizedChars = chars.lowercased() - if normalizedChars == "f" { - return true - } - let charsAreControlSequence = !normalizedChars.isEmpty - && normalizedChars.unicodeScalars.allSatisfy { CharacterSet.controlCharacters.contains($0) } - if !normalizedChars.isEmpty && !charsAreControlSequence { - return false + private func writeChildExitKeyboardProbe(_ updates: [String: String], increments: [String: Int] = [:]) { + guard let path = childExitKeyboardProbePath() else { return } + var payload: [String: String] = { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { + return [:] + } + return object + }() + for (key, by) in increments { + let current = Int(payload[key] ?? "") ?? 0 + payload[key] = String(current + by) + } + for (key, value) in updates { + payload[key] = value + } + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } + try? data.write(to: URL(fileURLWithPath: path), options: .atomic) } +#endif - // Fallback to layout translation only when characters are unavailable (for - // synthetic/key-equivalent paths that can report an empty string). - if let translatedCharacter = layoutCharacterProvider(keyCode, flags), !translatedCharacter.isEmpty { - return translatedCharacter == "f" + var mainWindowContexts: [ObjectIdentifier: MainWindowContext] = [:] + private var mainWindowControllers: [MainWindowController] = [] + + /// Tracks the cascade point for new windows, matching Ghostty's upstream algorithm. + /// Reset to `.zero` so the first window seeds the point from its own position. + private var lastCascadePoint = NSPoint.zero + private var startupSessionSnapshot: AppSessionSnapshot? + private var didPrepareStartupSessionSnapshot = false + private var didAttemptStartupSessionRestore = false + private var isApplyingStartupSessionRestore = false + private var sessionAutosaveTimer: DispatchSourceTimer? + private var sessionAutosaveTickInFlight = false + private var sessionAutosaveDeferredRetryPending = false + private let sessionPersistenceQueue = DispatchQueue( + label: "com.cmuxterm.app.sessionPersistence", + qos: .utility + ) + private nonisolated static let launchServicesRegistrationQueue = DispatchQueue( + label: "com.cmuxterm.app.launchServicesRegistration", + qos: .utility + ) + private nonisolated static func enqueueLaunchServicesRegistrationWork(_ work: @escaping @Sendable () -> Void) { + launchServicesRegistrationQueue.async(execute: work) } + private var lastSessionAutosaveFingerprint: Int? + private var lastSessionAutosavePersistedAt: Date = .distantPast + private var lastTypingActivityAt: TimeInterval = 0 + private var didHandleExplicitOpenIntentAtStartup = 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. + private var isQuitWarningConfirmed = false + private var didInstallLifecycleSnapshotObservers = false + private var didDisableSuddenTermination = false + private var commandPaletteStateByWindowId: [UUID: CommandPaletteWindowState] = [:] + private static let commandPaletteRequestGraceInterval: TimeInterval = 1.25 + private static let commandPalettePendingOpenMaxAge: TimeInterval = 8.0 + private static let sessionAutosaveTypingQuietPeriod: TimeInterval = 0.65 - // Keep ANSI fallback as a final safety net when layout translation is unavailable. - return keyCode == 3 -} + var updateViewModel: UpdateViewModel { + updateController.viewModel + } -func commandPaletteSelectionDeltaForKeyboardNavigation( - flags: NSEvent.ModifierFlags, - chars: String, - keyCode: UInt16 -) -> Int? { - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) - let normalizedChars = chars.lowercased() +#if DEBUG + private func pointerString(_ object: AnyObject?) -> String { + guard let object else { return "nil" } + return String(describing: Unmanaged.passUnretained(object).toOpaque()) + } - if normalizedFlags == [] { - switch keyCode { - case 125: return 1 // Down arrow - case 126: return -1 // Up arrow - default: break - } + private func summarizeContextForWorkspaceRouting(_ context: MainWindowContext?) -> String { + guard let context else { return "nil" } + let window = context.window ?? windowForMainWindowId(context.windowId) + let windowNumber = window?.windowNumber ?? -1 + let key = window?.isKeyWindow == true ? 1 : 0 + let main = window?.isMainWindow == true ? 1 : 0 + let visible = window?.isVisible == true ? 1 : 0 + let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(8)) } ?? "nil" + return "wid=\(context.windowId.uuidString.prefix(8)) win=\(windowNumber) key=\(key) main=\(main) vis=\(visible) tabs=\(context.tabManager.tabs.count) sel=\(selected) tm=\(pointerString(context.tabManager))" } - if normalizedFlags == [.control] { - // Control modifiers can surface as either printable chars or ASCII control chars. - // Keep Emacs-style next/previous navigation, but leave other control bindings - // (for example Ctrl+K text editing in the palette search field) to AppKit. - if keyCode == 45 || normalizedChars == "n" || normalizedChars == "\u{0e}" { return 1 } // Ctrl+N - if keyCode == 35 || normalizedChars == "p" || normalizedChars == "\u{10}" { return -1 } // Ctrl+P + private func summarizeAllContextsForWorkspaceRouting() -> String { + guard !mainWindowContexts.isEmpty else { return "" } + return mainWindowContexts.values + .map { summarizeContextForWorkspaceRouting($0) } + .joined(separator: " | ") } - return nil -} + private func logWorkspaceCreationRouting( + phase: String, + source: String, + reason: String, + event: NSEvent?, + chosenContext: MainWindowContext?, + workspaceId: UUID? = nil, + workingDirectory: String? = nil + ) { + let eventWindowNumber = event?.window?.windowNumber ?? -1 + let eventNumber = event?.windowNumber ?? -1 + let eventChars = event?.charactersIgnoringModifiers ?? "" + let eventKeyCode = event.map { String($0.keyCode) } ?? "nil" + let keyWindowNumber = NSApp.keyWindow?.windowNumber ?? -1 + let mainWindowNumber = NSApp.mainWindow?.windowNumber ?? -1 + let ws = workspaceId.map { String($0.uuidString.prefix(8)) } ?? "nil" + let wd = workingDirectory.map { String($0.prefix(120)) } ?? "-" + FocusLogStore.shared.append( + "cmdn.route phase=\(phase) src=\(source) reason=\(reason) eventWin=\(eventWindowNumber) eventNum=\(eventNumber) keyCode=\(eventKeyCode) chars=\(eventChars) keyWin=\(keyWindowNumber) mainWin=\(mainWindowNumber) activeTM=\(pointerString(tabManager)) chosen={\(summarizeContextForWorkspaceRouting(chosenContext))} ws=\(ws) wd=\(wd) contexts=[\(summarizeAllContextsForWorkspaceRouting())]" + ) + } +#endif -func shouldRouteCommandPaletteSelectionNavigation( - delta: Int?, - isInteractive: Bool, - usesInlineTextHandling: Bool -) -> Bool { - guard delta != nil, isInteractive else { return false } - return !usesInlineTextHandling -} + override init() { + super.init() + Self.shared = self + } -func shouldConsumeShortcutWhileCommandPaletteVisible( - isCommandPaletteVisible: Bool, - normalizedFlags: NSEvent.ModifierFlags, - chars: String, - keyCode: UInt16 -) -> Bool { - guard isCommandPaletteVisible else { return false } + func application(_ application: NSApplication, open urls: [URL]) { + let directories = externalOpenDirectories(from: urls) + guard !directories.isEmpty else { return } - // Escape dismisses the palette, and must not leak through to the - // underlying terminal or browser content. - if normalizedFlags.isEmpty, keyCode == 53 { - return true + prepareForExplicitOpenIntentAtStartup() + for directory in directories { + openWorkspaceForExternalDirectory( + workingDirectory: directory, + debugSource: "application.openURLs" + ) + } } - guard normalizedFlags.contains(.command) else { return false } + func applicationDidFinishLaunching(_ notification: Notification) { + let env = ProcessInfo.processInfo.environment + let isRunningUnderXCTest = isRunningUnderXCTest(env) - let normalizedChars = chars.lowercased() + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(handleThemesReloadNotification(_:)), + name: ProgramaThemeNotifications.reloadConfig, + object: nil, + suspensionBehavior: .deliverImmediately + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleReactGrabDidCopySelection(_:)), + name: .reactGrabDidCopySelection, + object: nil + ) - if normalizedFlags == [.command] { - if normalizedChars == "a" - || normalizedChars == "c" - || normalizedChars == "v" - || normalizedChars == "x" - || normalizedChars == "z" - || normalizedChars == "y" { - return false +#if DEBUG + // UI tests run on a shared VM user profile, so persisted shortcuts can drift and make + // key-equivalent routing flaky. Force defaults for deterministic tests. + if isRunningUnderXCTest { + KeyboardShortcutSettings.resetAll() } +#endif - switch keyCode { - case 51, 117, 123, 124: - return false - default: - break +#if DEBUG + writeUITestDiagnosticsIfNeeded(stage: "didFinishLaunching") + ProgramaMainRunLoopStallMonitor.shared.installIfNeeded() + ProgramaMainThreadTurnProfiler.shared.installIfNeeded() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + self?.writeUITestDiagnosticsIfNeeded(stage: "after1s") } - } - - if normalizedFlags == [.command, .shift], normalizedChars == "z" { - return false - } +#endif - return true -} + let forceDuplicateLaunchObserver = env["PROGRAMA_UI_TEST_ENABLE_DUPLICATE_LAUNCH_OBSERVER"] == "1" -func shouldSubmitCommandPaletteWithReturn( - keyCode: UInt16, - flags: NSEvent.ModifierFlags, - mode: String -) -> Bool { - guard keyCode == 36 || keyCode == 76 else { return false } - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) - if normalizedFlags.isEmpty { - return true - } - if normalizedFlags == [.shift] { - return mode != "workspace_description_input" - } - return false -} - -func commandPaletteFieldEditorHasMarkedText(in window: NSWindow) -> Bool { - if let editor = window.firstResponder as? NSTextView { - return editor.hasMarkedText() - } - if let textField = window.firstResponder as? NSTextField, - let editor = textField.currentEditor() as? NSTextView { - return editor.hasMarkedText() - } - return false -} - -func shouldHandleCommandPaletteShortcutEvent( - _ event: NSEvent, - paletteWindow: NSWindow? -) -> Bool { - guard let paletteWindow else { return false } - if let eventWindow = event.window { - return eventWindow === paletteWindow - } - let eventWindowNumber = event.windowNumber - if eventWindowNumber > 0 { - return eventWindowNumber == paletteWindow.windowNumber - } - if let keyWindow = NSApp.keyWindow { - return keyWindow === paletteWindow - } - return false -} - -enum BrowserZoomShortcutAction: Equatable { - case zoomIn - case zoomOut - case reset -} - -struct CommandPaletteDebugResultRow { - let commandId: String - let title: String - let shortcutHint: String? - let trailingLabel: String? - let score: Int -} - -struct CommandPaletteDebugSnapshot { - let query: String - let mode: String - let results: [CommandPaletteDebugResultRow] - - static let empty = CommandPaletteDebugSnapshot(query: "", mode: "commands", results: []) -} - -func browserZoomShortcutAction( - flags: NSEvent.ModifierFlags, - chars: String, - keyCode: UInt16, - literalChars: String? = nil -) -> BrowserZoomShortcutAction? { - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) - let hasCommand = normalizedFlags.contains(.command) - let hasOnlyCommandAndOptionalShift = hasCommand && normalizedFlags.isDisjoint(with: [.control, .option]) - - guard hasOnlyCommandAndOptionalShift else { return nil } - let keys = browserZoomShortcutKeyCandidates( - chars: chars, - literalChars: literalChars, - keyCode: keyCode - ) - - if keys.contains("=") || keys.contains("+") || keyCode == 24 || keyCode == 69 { // kVK_ANSI_Equal / kVK_ANSI_KeypadPlus - return .zoomIn - } - - if keys.contains("-") || keys.contains("_") || keyCode == 27 || keyCode == 78 { // kVK_ANSI_Minus / kVK_ANSI_KeypadMinus - return .zoomOut - } - - if keys.contains("0") || keyCode == 29 || keyCode == 82 { // kVK_ANSI_0 / kVK_ANSI_Keypad0 - return .reset - } - - return nil -} - -func browserZoomShortcutKeyCandidates( - chars: String, - literalChars: String?, - keyCode: UInt16 -) -> Set { - var keys: Set = [chars.lowercased()] - - if let literalChars, !literalChars.isEmpty { - keys.insert(literalChars.lowercased()) - } - - if let layoutChar = KeyboardLayout.character(forKeyCode: keyCode), !layoutChar.isEmpty { - keys.insert(layoutChar) - } - - return keys -} - -func shouldSuppressSplitShortcutForTransientTerminalFocusInputs( - firstResponderIsWindow: Bool, - hostedSize: CGSize, - hostedHiddenInHierarchy: Bool, - hostedAttachedToWindow: Bool -) -> Bool { - guard firstResponderIsWindow else { return false } - let tinyGeometry = hostedSize.width <= 1 || hostedSize.height <= 1 - return tinyGeometry || hostedHiddenInHierarchy || !hostedAttachedToWindow -} - -func shouldRouteTerminalFontZoomShortcutToGhostty( - firstResponderIsGhostty: Bool, - flags: NSEvent.ModifierFlags, - chars: String, - keyCode: UInt16, - literalChars: String? = nil -) -> Bool { - guard firstResponderIsGhostty else { return false } - return browserZoomShortcutAction( - flags: flags, - chars: chars, - keyCode: keyCode, - literalChars: literalChars - ) != nil -} - -@discardableResult -func startOrFocusTerminalSearch( - _ terminalSurface: TerminalSurface, - searchFocusNotifier: @escaping (TerminalSurface) -> Void = { - NotificationCenter.default.post(name: .ghosttySearchFocus, object: $0) - } -) -> Bool { - if terminalSurface.searchState != nil { - searchFocusNotifier(terminalSurface) - return true - } - - if terminalSurface.performBindingAction("start_search") { - DispatchQueue.main.async { [weak terminalSurface] in - guard let terminalSurface, terminalSurface.searchState == nil else { return } - terminalSurface.searchState = TerminalSurface.SearchState() - searchFocusNotifier(terminalSurface) - } - return true - } - - terminalSurface.searchState = TerminalSurface.SearchState() - searchFocusNotifier(terminalSurface) - return true -} - -/// Let AppKit own native Cmd+` window cycling so key-window changes do not -/// re-enter our direct-to-menu shortcut path. -func shouldRouteCommandEquivalentDirectlyToMainMenu(_ event: NSEvent) -> Bool { - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - guard flags.contains(.command) else { return false } - - let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) - if event.keyCode == 50, - normalizedFlags == [.command] || normalizedFlags == [.command, .shift] { - return false - } - - return true -} - -private enum BrowserFindCommandEquivalent { - case find - case findNext - case findPrevious - case hideFind - case useSelection - - var keepsProgramaBrowserFindBarOwnershipWhenVisible: Bool { - switch self { - case .find, .findNext, .findPrevious, .hideFind: - return true - case .useSelection: - return false - } - } -} - -private func programaIsLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool { - guard let responder else { return false } - let responderType = String(describing: type(of: responder)) - if responderType.contains("WKInspector") { - return true - } - guard let view = responder as? NSView else { return false } - var node: NSView? = view - var hops = 0 - while let current = node, hops < 64 { - if String(describing: type(of: current)).contains("WKInspector") { - return true - } - node = current.superview - hops += 1 - } - return false -} - -private func browserFindCommandEquivalent(for event: NSEvent) -> BrowserFindCommandEquivalent? { - let flags = event.modifierFlags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) - - let normalizedChars = KeyboardLayout.normalizedCharacters(for: event).lowercased() - let hasSingleASCIIShortcutChar = - normalizedChars.count == 1 && normalizedChars.allSatisfy(\.isASCII) - let producedAnyASCIIShortcutChar = normalizedChars.contains(where: \.isASCII) - func matches(_ chars: String, keyCode: UInt16) -> Bool { - if hasSingleASCIIShortcutChar { - return normalizedChars == chars - } - if !producedAnyASCIIShortcutChar { - return event.keyCode == keyCode - } - return false - } - - switch flags { - case [.command]: - if matches("e", keyCode: 14) { // kVK_ANSI_E - return .useSelection - } - if matches("f", keyCode: 3) { // kVK_ANSI_F - return .find - } - if matches("g", keyCode: 5) { // kVK_ANSI_G - return .findNext - } - return nil - case [.command, .shift]: - if matches("f", keyCode: 3) { // kVK_ANSI_F - return .hideFind - } - if matches("g", keyCode: 5) { // kVK_ANSI_G - return .findPrevious - } - return nil - default: - return nil - } -} - -/// 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, - owningWebView: ProgramaWebView? = nil -) -> Bool { - guard let shortcut = browserFindCommandEquivalent(for: event) else { - return false - } - - if programaIsLikelyWebInspectorResponder(responder) { - return false - } - - if shortcut.keepsProgramaBrowserFindBarOwnershipWhenVisible, - let owningWebView { - let browserFindBarIsVisible = MainActor.assumeIsolated { - AppDelegate.shared?.browserFindBarIsVisible(for: owningWebView) == true - } - if browserFindBarIsVisible { - return false - } - } - - return true -} - -func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? { - guard let responder else { return nil } - if let ghosttyView = responder as? GhosttyNSView { - return ghosttyView - } - - if let view = responder as? NSView, - let ghosttyView = cmuxOwningGhosttyView(for: view) { - return ghosttyView - } - - if let textView = responder as? NSTextView { - if textView.isFieldEditor, - let ownerView = programaFieldEditorOwnerView(textView), - 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 - while let next = current { - if let ghosttyView = next as? GhosttyNSView { - return ghosttyView - } - if let view = next as? NSView, - let ghosttyView = cmuxOwningGhosttyView(for: view) { - return ghosttyView - } - current = next.nextResponder - } - - return nil -} - -private func programaFieldEditorOwnerView(_ editor: NSTextView) -> NSView? { - guard editor.isFieldEditor else { return nil } - - var current = editor.nextResponder - while let next = current { - if let view = next as? NSView { - return view - } - current = next.nextResponder - } - - return editor.superview -} - -private func cmuxOwningGhosttyView(for view: NSView) -> GhosttyNSView? { - if let ghosttyView = view as? GhosttyNSView { - return ghosttyView - } - - var current: NSView? = view.superview - while let candidate = current { - if let ghosttyView = candidate as? GhosttyNSView { - return ghosttyView - } - current = candidate.superview - } - - return nil -} - -#if DEBUG -func browserZoomShortcutTraceCandidate( - flags: NSEvent.ModifierFlags, - chars: String, - keyCode: UInt16, - literalChars: String? = nil -) -> Bool { - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) - guard normalizedFlags.contains(.command) else { return false } - - let keys = browserZoomShortcutKeyCandidates( - chars: chars, - literalChars: literalChars, - keyCode: keyCode - ) - if keys.contains("=") || keys.contains("+") || keys.contains("-") || keys.contains("_") || keys.contains("0") { - return true - } - switch keyCode { - case 24, 27, 29, 69, 78, 82: // ANSI and keypad zoom keys - return true - default: - return false - } -} - -func browserZoomShortcutTraceFlagsString(_ flags: NSEvent.ModifierFlags) -> String { - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) - var parts: [String] = [] - if normalizedFlags.contains(.command) { parts.append("Cmd") } - if normalizedFlags.contains(.shift) { parts.append("Shift") } - if normalizedFlags.contains(.option) { parts.append("Opt") } - if normalizedFlags.contains(.control) { parts.append("Ctrl") } - return parts.isEmpty ? "none" : parts.joined(separator: "+") -} - -func browserZoomShortcutTraceActionString(_ action: BrowserZoomShortcutAction?) -> String { - guard let action else { return "none" } - switch action { - case .zoomIn: return "zoomIn" - case .zoomOut: return "zoomOut" - case .reset: return "reset" - } -} -#endif - -func shouldSuppressWindowMoveForFolderDrag(hitView: NSView?) -> Bool { - var candidate = hitView - while let view = candidate { - if view is DraggableFolderNSView { - return true - } - candidate = view.superview - } - return false -} - -func shouldSuppressWindowMoveForFolderDrag(window: NSWindow, event: NSEvent) -> Bool { - guard event.type == .leftMouseDown, - window.isMovable, - let contentView = window.contentView else { - return false - } - - let contentPoint = contentView.convert(event.locationInWindow, from: nil) - let hitView = contentView.hitTest(contentPoint) - return shouldSuppressWindowMoveForFolderDrag(hitView: hitView) -} - -@MainActor -final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation { - nonisolated(unsafe) static var shared: AppDelegate? - - private static let cachedIsRunningUnderXCTest = detectRunningUnderXCTest(ProcessInfo.processInfo.environment) - - private var isRunningUnderXCTestCached: Bool { - Self.cachedIsRunningUnderXCTest - } - - private static func detectRunningUnderXCTest(_ env: [String: String]) -> Bool { - if env["XCTestConfigurationFilePath"] != nil { return true } - if env["XCTestBundlePath"] != nil { return true } - if env["XCTestSessionIdentifier"] != nil { return true } - if env["XCInjectBundle"] != nil { return true } - if env["XCInjectBundleInto"] != nil { return true } - if env["DYLD_INSERT_LIBRARIES"]?.contains("libXCTest") == true { return true } - if env.keys.contains(where: { $0.hasPrefix("PROGRAMA_UI_TEST_") }) { return true } - return false - } - - private func isRunningUnderXCTest(_ env: [String: String]) -> Bool { - // On some macOS/Xcode setups, the app-under-test process doesn't get - // `XCTestConfigurationFilePath`. Use a broader set of signals so UI tests - // can reliably skip heavyweight startup work and bring up a window. - Self.detectRunningUnderXCTest(env) - } - - final class MainWindowContext { - let windowId: UUID - let tabManager: TabManager - let sidebarState: SidebarState - let sidebarSelectionState: SidebarSelectionState - weak var window: NSWindow? - - init( - windowId: UUID, - tabManager: TabManager, - sidebarState: SidebarState, - sidebarSelectionState: SidebarSelectionState, - window: NSWindow? - ) { - self.windowId = windowId - self.tabManager = tabManager - self.sidebarState = sidebarState - self.sidebarSelectionState = sidebarSelectionState - self.window = window - } - } - - private final class MainWindowController: NSWindowController, NSWindowDelegate { - var onClose: (() -> Void)? - - func windowWillClose(_ notification: Notification) { - onClose?() - } - } - - struct ScriptableMainWindowState { - let windowId: UUID - let tabManager: TabManager - let window: NSWindow? - } - - struct SessionDisplayGeometry { - let displayID: UInt32? - let frame: CGRect - let visibleFrame: CGRect - } - - struct PersistedWindowGeometry: Codable, Sendable { - let version: Int - let frame: SessionRectSnapshot - let display: SessionDisplaySnapshot? - } - - nonisolated static let persistedWindowGeometrySchemaVersion = 2 - private nonisolated static let persistedWindowGeometryDefaultsKey = "programa.session.lastWindowGeometry.v2" - private nonisolated static let legacyPersistedWindowGeometryDefaultsKeys = [ - "programa.session.lastWindowGeometry.v1" - ] - - weak var tabManager: TabManager? - weak var notificationStore: TerminalNotificationStore? - weak var sidebarState: SidebarState? - weak var fullscreenControlsViewModel: TitlebarControlsViewModel? - weak var sidebarSelectionState: SidebarSelectionState? - var shortcutLayoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) - private var workspaceObserver: NSObjectProtocol? - private var lifecycleSnapshotObservers: [NSObjectProtocol] = [] - private var windowKeyObserver: NSObjectProtocol? - private var shortcutMonitor: Any? - private var shortcutDefaultsObserver: NSObjectProtocol? - private var menuBarVisibilityObserver: NSObjectProtocol? - private var splitButtonTooltipRefreshScheduled = false - private struct PendingConfiguredShortcutChord { - let firstStroke: ShortcutStroke - let windowNumber: Int? - } - private var pendingConfiguredShortcutChord: PendingConfiguredShortcutChord? - private var activeConfiguredShortcutChordPrefixForCurrentEvent: ShortcutStroke? - private var configuredShortcutChordActions: [KeyboardShortcutSettings.Action] = [] - private var ghosttyConfigObserver: NSObjectProtocol? - var ghosttyGotoSplitLeftShortcut: StoredShortcut? - var ghosttyGotoSplitRightShortcut: StoredShortcut? - var ghosttyGotoSplitUpShortcut: StoredShortcut? - var ghosttyGotoSplitDownShortcut: StoredShortcut? - private var browserAddressBarFocusedPanelId: UUID? - private var browserOmnibarRepeatStartWorkItem: DispatchWorkItem? - private var browserOmnibarRepeatTickWorkItem: DispatchWorkItem? - private var browserOmnibarRepeatKeyCode: UInt16? - private var browserOmnibarRepeatDelta: Int = 0 - private var browserAddressBarFocusObserver: NSObjectProtocol? - private var browserAddressBarBlurObserver: NSObjectProtocol? - private let updateController = UpdateController() - private lazy var titlebarAccessoryController = UpdateTitlebarAccessoryController(viewModel: updateViewModel) - private let windowDecorationsController = WindowDecorationsController() - private var menuBarExtraController: MenuBarExtraController? - private static let serviceErrorNoPath = NSString(string: String(localized: "error.clipboardFolderPath", defaultValue: "Could not load any folder path from the clipboard.")) - private static let didInstallWindowKeyEquivalentSwizzle: Void = { - let targetClass: AnyClass = NSWindow.self - let originalSelector = #selector(NSWindow.performKeyEquivalent(with:)) - let swizzledSelector = #selector(NSWindow.programa_performKeyEquivalent(with:)) - guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), - let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { - return - } - method_exchangeImplementations(originalMethod, swizzledMethod) - }() - private static let didInstallWindowFirstResponderSwizzle: Void = { - let targetClass: AnyClass = NSWindow.self - let originalSelector = #selector(NSWindow.makeFirstResponder(_:)) - let swizzledSelector = #selector(NSWindow.programa_makeFirstResponder(_:)) - guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), - let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { - return - } - method_exchangeImplementations(originalMethod, swizzledMethod) - }() - private static let didInstallWindowSendEventSwizzle: Void = { - let targetClass: AnyClass = NSWindow.self - let originalSelector = #selector(NSWindow.sendEvent(_:)) - let swizzledSelector = #selector(NSWindow.programa_sendEvent(_:)) - guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), - let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { - return - } - method_exchangeImplementations(originalMethod, swizzledMethod) - }() - private static let didInstallApplicationSendEventSwizzle: Void = { - let targetClass: AnyClass = NSApplication.self - let originalSelector = #selector(NSApplication.sendEvent(_:)) - let swizzledSelector = #selector(NSApplication.programa_applicationSendEvent(_:)) - guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), - let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { - return - } - method_exchangeImplementations(originalMethod, swizzledMethod) - }() - -#if DEBUG - var didSetupJumpUnreadUITest = false - var jumpUnreadFocusExpectation: (tabId: UUID, surfaceId: UUID)? - var jumpUnreadFocusObserver: NSObjectProtocol? - var didSetupTerminalCmdClickUITest = false - var didSetupGotoSplitUITest = false - var didSetupBonsplitTabDragUITest = false - var terminalCmdClickUITestPoller: DispatchSourceTimer? - var bonsplitTabDragUITestRecorder: DispatchSourceTimer? - var gotoSplitUITestRecorder: DispatchSourceTimer? - var gotoSplitUITestObservers: [NSObjectProtocol] = [] - var didSetupMultiWindowNotificationsUITest = false - var didSetupDisplayResolutionUITestDiagnostics = false - var displayResolutionUITestObservers: [NSObjectProtocol] = [] - private struct UITestRenderDiagnosticsSnapshot { - let panelId: UUID - let drawCount: Int - let presentCount: Int - let lastPresentTime: Double - let windowVisible: Bool - let appIsActive: Bool - let desiredFocus: Bool - let isFirstResponder: Bool - } - var debugCloseMainWindowConfirmationHandler: ((NSWindow) -> Bool)? - var debugCreateMainWindowSourceIsNativeFullScreenOverride: Bool? - // Keep debug-only windows alive when tests intentionally inject key mismatches. - private var debugDetachedContextWindows: [NSWindow] = [] - - private func childExitKeyboardProbePath() -> String? { - let env = ProcessInfo.processInfo.environment - guard env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_SETUP"] == "1", - let path = env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_PATH"], - !path.isEmpty else { - return nil - } - return path - } - - private func childExitKeyboardProbeHex(_ value: String?) -> String { - guard let value else { return "" } - return value.unicodeScalars - .map { String(format: "%04X", $0.value) } - .joined(separator: ",") - } - - private func writeChildExitKeyboardProbe(_ updates: [String: String], increments: [String: Int] = [:]) { - guard let path = childExitKeyboardProbePath() else { return } - var payload: [String: String] = { - guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { - return [:] - } - return object - }() - for (key, by) in increments { - let current = Int(payload[key] ?? "") ?? 0 - payload[key] = String(current + by) - } - for (key, value) in updates { - payload[key] = value - } - guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } - try? data.write(to: URL(fileURLWithPath: path), options: .atomic) - } -#endif - - var mainWindowContexts: [ObjectIdentifier: MainWindowContext] = [:] - private var mainWindowControllers: [MainWindowController] = [] - - /// Tracks the cascade point for new windows, matching Ghostty's upstream algorithm. - /// Reset to `.zero` so the first window seeds the point from its own position. - private var lastCascadePoint = NSPoint.zero - private var startupSessionSnapshot: AppSessionSnapshot? - private var didPrepareStartupSessionSnapshot = false - private var didAttemptStartupSessionRestore = false - private var isApplyingStartupSessionRestore = false - private var sessionAutosaveTimer: DispatchSourceTimer? - private var sessionAutosaveTickInFlight = false - private var sessionAutosaveDeferredRetryPending = false - private let sessionPersistenceQueue = DispatchQueue( - label: "com.cmuxterm.app.sessionPersistence", - qos: .utility - ) - private nonisolated static let launchServicesRegistrationQueue = DispatchQueue( - label: "com.cmuxterm.app.launchServicesRegistration", - qos: .utility - ) - private nonisolated static func enqueueLaunchServicesRegistrationWork(_ work: @escaping @Sendable () -> Void) { - launchServicesRegistrationQueue.async(execute: work) - } - private var lastSessionAutosaveFingerprint: Int? - private var lastSessionAutosavePersistedAt: Date = .distantPast - private var lastTypingActivityAt: TimeInterval = 0 - private var didHandleExplicitOpenIntentAtStartup = 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. - private var isQuitWarningConfirmed = false - private var didInstallLifecycleSnapshotObservers = false - private var didDisableSuddenTermination = false - private var commandPaletteVisibilityByWindowId: [UUID: Bool] = [:] - private var commandPalettePendingOpenByWindowId: [UUID: Bool] = [:] - private var commandPaletteRecentRequestAtByWindowId: [UUID: TimeInterval] = [:] - private var commandPaletteEscapeSuppressionByWindowId: Set = [] - private var commandPaletteEscapeSuppressionStartedAtByWindowId: [UUID: TimeInterval] = [:] - private var commandPaletteSelectionByWindowId: [UUID: Int] = [:] - private var commandPaletteSnapshotByWindowId: [UUID: CommandPaletteDebugSnapshot] = [:] - private static let commandPaletteRequestGraceInterval: TimeInterval = 1.25 - private static let commandPalettePendingOpenMaxAge: TimeInterval = 8.0 - private static let sessionAutosaveTypingQuietPeriod: TimeInterval = 0.65 - - var updateViewModel: UpdateViewModel { - updateController.viewModel - } - -#if DEBUG - private func pointerString(_ object: AnyObject?) -> String { - guard let object else { return "nil" } - return String(describing: Unmanaged.passUnretained(object).toOpaque()) - } - - private func summarizeContextForWorkspaceRouting(_ context: MainWindowContext?) -> String { - guard let context else { return "nil" } - let window = context.window ?? windowForMainWindowId(context.windowId) - let windowNumber = window?.windowNumber ?? -1 - let key = window?.isKeyWindow == true ? 1 : 0 - let main = window?.isMainWindow == true ? 1 : 0 - let visible = window?.isVisible == true ? 1 : 0 - let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(8)) } ?? "nil" - return "wid=\(context.windowId.uuidString.prefix(8)) win=\(windowNumber) key=\(key) main=\(main) vis=\(visible) tabs=\(context.tabManager.tabs.count) sel=\(selected) tm=\(pointerString(context.tabManager))" - } - - private func summarizeAllContextsForWorkspaceRouting() -> String { - guard !mainWindowContexts.isEmpty else { return "" } - return mainWindowContexts.values - .map { summarizeContextForWorkspaceRouting($0) } - .joined(separator: " | ") - } - - private func logWorkspaceCreationRouting( - phase: String, - source: String, - reason: String, - event: NSEvent?, - chosenContext: MainWindowContext?, - workspaceId: UUID? = nil, - workingDirectory: String? = nil - ) { - let eventWindowNumber = event?.window?.windowNumber ?? -1 - let eventNumber = event?.windowNumber ?? -1 - let eventChars = event?.charactersIgnoringModifiers ?? "" - let eventKeyCode = event.map { String($0.keyCode) } ?? "nil" - let keyWindowNumber = NSApp.keyWindow?.windowNumber ?? -1 - let mainWindowNumber = NSApp.mainWindow?.windowNumber ?? -1 - let ws = workspaceId.map { String($0.uuidString.prefix(8)) } ?? "nil" - let wd = workingDirectory.map { String($0.prefix(120)) } ?? "-" - FocusLogStore.shared.append( - "cmdn.route phase=\(phase) src=\(source) reason=\(reason) eventWin=\(eventWindowNumber) eventNum=\(eventNumber) keyCode=\(eventKeyCode) chars=\(eventChars) keyWin=\(keyWindowNumber) mainWin=\(mainWindowNumber) activeTM=\(pointerString(tabManager)) chosen={\(summarizeContextForWorkspaceRouting(chosenContext))} ws=\(ws) wd=\(wd) contexts=[\(summarizeAllContextsForWorkspaceRouting())]" - ) - } -#endif - - override init() { - super.init() - Self.shared = self - } - - func application(_ application: NSApplication, open urls: [URL]) { - let directories = externalOpenDirectories(from: urls) - guard !directories.isEmpty else { return } - - prepareForExplicitOpenIntentAtStartup() - for directory in directories { - openWorkspaceForExternalDirectory( - workingDirectory: directory, - debugSource: "application.openURLs" - ) - } - } - - func applicationDidFinishLaunching(_ notification: Notification) { - let env = ProcessInfo.processInfo.environment - let isRunningUnderXCTest = isRunningUnderXCTest(env) - - DistributedNotificationCenter.default().addObserver( - self, - selector: #selector(handleThemesReloadNotification(_:)), - name: ProgramaThemeNotifications.reloadConfig, - object: nil, - suspensionBehavior: .deliverImmediately - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(handleReactGrabDidCopySelection(_:)), - name: .reactGrabDidCopySelection, - object: nil - ) - -#if DEBUG - // UI tests run on a shared VM user profile, so persisted shortcuts can drift and make - // key-equivalent routing flaky. Force defaults for deterministic tests. - if isRunningUnderXCTest { - KeyboardShortcutSettings.resetAll() - } -#endif - -#if DEBUG - writeUITestDiagnosticsIfNeeded(stage: "didFinishLaunching") - ProgramaMainRunLoopStallMonitor.shared.installIfNeeded() - ProgramaMainThreadTurnProfiler.shared.installIfNeeded() - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - self?.writeUITestDiagnosticsIfNeeded(stage: "after1s") - } -#endif - - let forceDuplicateLaunchObserver = env["PROGRAMA_UI_TEST_ENABLE_DUPLICATE_LAUNCH_OBSERVER"] == "1" - - // UI tests frequently time out waiting for the main window if we do heavyweight - // LaunchServices registration / single-instance enforcement synchronously at startup. - // Skip these during XCTest (the app-under-test) so the window can appear quickly. - if !isRunningUnderXCTest { - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.scheduleLaunchServicesBundleRegistration() - self.enforceSingleInstance() - self.observeDuplicateLaunches() - } - } else if forceDuplicateLaunchObserver { - // Some UI regressions specifically exercise launch-observer behavior while still - // running under XCTest. Allow an explicit opt-in for those cases only. - DispatchQueue.main.async { [weak self] in - self?.observeDuplicateLaunches() - } - } - NSWindow.allowsAutomaticWindowTabbing = false - disableNativeTabbingShortcut() - ensureApplicationIcon() - if !isRunningUnderXCTest { - configureUserNotifications() - installMenuBarVisibilityObserver() - syncMenuBarExtraVisibility() - updateController.startUpdaterIfNeeded() - } - titlebarAccessoryController.start() - windowDecorationsController.start() - installMainWindowKeyObserver() - refreshGhosttyGotoSplitShortcuts() - installGhosttyConfigObserver() - installWindowResponderSwizzles() - installBrowserAddressBarFocusObservers() - installShortcutMonitor() - installShortcutDefaultsObserver() - NSApp.servicesProvider = self -#if DEBUG - UpdateTestSupport.applyIfNeeded(to: updateController.viewModel) - if env["PROGRAMA_UI_TEST_MODE"] == "1" { - let trigger = env["PROGRAMA_UI_TEST_TRIGGER_UPDATE_CHECK"] ?? "" - let feed = env["PROGRAMA_UI_TEST_FEED_URL"] ?? "" - UpdateLogStore.shared.append("ui test env: trigger=\(trigger) feed=\(feed)") - } - if env["PROGRAMA_UI_TEST_TRIGGER_UPDATE_CHECK"] == "1" { - UpdateLogStore.shared.append("ui test trigger update check detected") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in - guard let self else { return } - let windowIds = NSApp.windows.map { $0.identifier?.rawValue ?? "" } - UpdateLogStore.shared.append("ui test windows: count=\(NSApp.windows.count) ids=\(windowIds.joined(separator: ","))") - if UpdateTestSupport.performMockFeedCheckIfNeeded(on: self.updateController.viewModel) { - return - } - self.checkForUpdates(nil) - } - } - - // In UI tests, `WindowGroup` occasionally fails to materialize a window quickly on the VM. - // If there are no windows shortly after launch, force-create one so XCUITest can proceed. - if isRunningUnderXCTest { - if let rawShow = env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_SHOW"] { - UserDefaults.standard.set( - rawShow == "1", - forKey: BrowserImportHintSettings.showOnBlankTabsKey - ) - } - if let rawDismissed = env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_DISMISSED"] { - UserDefaults.standard.set( - rawDismissed == "1", - forKey: BrowserImportHintSettings.dismissedKey - ) - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in - guard let self else { return } - if NSApp.windows.isEmpty { - self.openNewMainWindow(nil) - } - self.moveUITestWindowToTargetDisplayIfNeeded() - NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) - // On headless CI runners, activate() silently fails (no GUI session). - // Force windows visible so the terminal surface starts rendering. - for window in NSApp.windows { - window.orderFrontRegardless() - } - self.writeUITestDiagnosticsIfNeeded(stage: "afterForceWindow") - } - if env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_OPEN_BLANK_BROWSER"] == "1" { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in - guard let self else { return } - _ = self.openBrowserAndFocusAddressBar(insertAtEnd: true) - } - } - if env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_OPEN_SETTINGS"] == "1" { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.55) { [weak self] in - self?.openPreferencesWindow( - debugSource: "uiTest.browserImportHint", - navigationTarget: .browser - ) - } - } - if env["PROGRAMA_UI_TEST_BROWSER_IMPORT_AUTO_OPEN"] == "1" { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { - BrowserDataImportCoordinator.shared.presentImportDialog() - } - } - } -#endif - } - -#if DEBUG - func writeUITestDiagnosticsIfNeeded(stage: String) { - let env = ProcessInfo.processInfo.environment - guard let path = env["PROGRAMA_UI_TEST_DIAGNOSTICS_PATH"], !path.isEmpty else { return } - - var payload = loadUITestDiagnostics(at: path) - let isRunningUnderXCTest = isRunningUnderXCTest(env) - - let windows = NSApp.windows - let ids = windows.map { $0.identifier?.rawValue ?? "" }.joined(separator: ",") - let vis = windows.map { $0.isVisible ? "1" : "0" }.joined(separator: ",") - let screenIDs = windows.map { $0.screen?.programaDisplayID.map(String.init) ?? "" }.joined(separator: ",") - let targetDisplayID = env["PROGRAMA_UI_TEST_TARGET_DISPLAY_ID"] ?? "" - - payload["stage"] = stage - payload["pid"] = String(ProcessInfo.processInfo.processIdentifier) - payload["bundleId"] = Bundle.main.bundleIdentifier ?? "" - payload["isRunningUnderXCTest"] = isRunningUnderXCTest ? "1" : "0" - payload["windowsCount"] = String(windows.count) - payload["windowIdentifiers"] = ids - payload["windowVisibleFlags"] = vis - payload["windowScreenDisplayIDs"] = screenIDs - payload["uiTestTargetDisplayID"] = targetDisplayID - if let rawDisplayID = UInt32(targetDisplayID) { - let screenPresent = NSScreen.screens.contains(where: { $0.programaDisplayID == rawDisplayID }) - let movedWindow = windows.contains(where: { $0.screen?.programaDisplayID == rawDisplayID }) - payload["targetDisplayPresent"] = screenPresent ? "1" : "0" - payload["targetDisplayMoveSucceeded"] = movedWindow ? "1" : "0" - } - appendUITestRenderDiagnosticsIfNeeded(&payload, environment: env) - appendUITestSocketDiagnosticsIfNeeded(&payload, environment: env) - - guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } - try? data.write(to: URL(fileURLWithPath: path), options: .atomic) - } - - private func loadUITestDiagnostics(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 [:] - } - return object - } - - private func appendUITestSocketDiagnosticsIfNeeded( - _ payload: inout [String: String], - environment env: [String: String] - ) { - guard env["PROGRAMA_UI_TEST_SOCKET_SANITY"] == "1" else { return } - - guard let config = socketListenerConfigurationIfEnabled() else { - payload["socketExpectedPath"] = env["PROGRAMA_SOCKET_PATH"] ?? "" - payload["socketMode"] = "off" - payload["socketReady"] = "0" - payload["socketPingResponse"] = "" - payload["socketIsRunning"] = "0" - payload["socketAcceptLoopAlive"] = "0" - payload["socketPathMatches"] = "0" - payload["socketPathExists"] = "0" - payload["socketFailureSignals"] = "socket_disabled" - return - } - - let socketPath = TerminalController.shared.activeSocketPath(preferredPath: config.path) - let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: socketPath) - let pingResponse = health.isHealthy - ? TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0) - : nil - let isReady = health.isHealthy && pingResponse == "PONG" - var failureSignals = health.failureSignals - if health.isHealthy && pingResponse != "PONG" { - failureSignals.append("ping_timeout") - } - - payload["socketExpectedPath"] = socketPath - payload["socketMode"] = config.mode.rawValue - payload["socketReady"] = isReady ? "1" : "0" - payload["socketPingResponse"] = pingResponse ?? "" - payload["socketIsRunning"] = health.isRunning ? "1" : "0" - payload["socketAcceptLoopAlive"] = health.acceptLoopAlive ? "1" : "0" - payload["socketPathMatches"] = health.socketPathMatches ? "1" : "0" - payload["socketPathExists"] = health.socketPathExists ? "1" : "0" - payload["socketFailureSignals"] = failureSignals.joined(separator: ",") - } - - private func appendUITestRenderDiagnosticsIfNeeded( - _ payload: inout [String: String], - environment env: [String: String] - ) { - guard env["PROGRAMA_UI_TEST_DISPLAY_RENDER_STATS"] == "1" else { return } - - guard let renderState = currentUITestRenderDiagnostics() else { - payload["renderStatsAvailable"] = "0" - payload["renderPanelId"] = "" - payload["renderDrawCount"] = "" - payload["renderPresentCount"] = "" - payload["renderLastPresentTime"] = "" - payload["renderWindowVisible"] = "" - payload["renderAppIsActive"] = "" - payload["renderDesiredFocus"] = "" - payload["renderIsFirstResponder"] = "" - payload["renderDiagnosticsUpdatedAt"] = String(format: "%.6f", ProcessInfo.processInfo.systemUptime) - return - } - - payload["renderStatsAvailable"] = "1" - payload["renderPanelId"] = renderState.panelId.uuidString - payload["renderDrawCount"] = String(renderState.drawCount) - payload["renderPresentCount"] = String(renderState.presentCount) - payload["renderLastPresentTime"] = String(format: "%.6f", renderState.lastPresentTime) - payload["renderWindowVisible"] = renderState.windowVisible ? "1" : "0" - payload["renderAppIsActive"] = renderState.appIsActive ? "1" : "0" - payload["renderDesiredFocus"] = renderState.desiredFocus ? "1" : "0" - payload["renderIsFirstResponder"] = renderState.isFirstResponder ? "1" : "0" - payload["renderDiagnosticsUpdatedAt"] = String(format: "%.6f", ProcessInfo.processInfo.systemUptime) - } - - private func currentUITestRenderDiagnostics() -> UITestRenderDiagnosticsSnapshot? { - guard let tabManager, - let tabId = tabManager.selectedTabId, - let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else { - return nil - } - - let terminalPanel: TerminalPanel? = { - if let focusedPanelId = workspace.focusedPanelId, - let terminalPanel = workspace.terminalPanel(for: focusedPanelId) { - return terminalPanel - } - if let focusedTerminalPanel = workspace.focusedTerminalPanel { - return focusedTerminalPanel - } - return workspace.panels.values.compactMap { $0 as? TerminalPanel }.first - }() - - guard let terminalPanel else { return nil } - let stats = terminalPanel.hostedView.debugRenderStats() - return UITestRenderDiagnosticsSnapshot( - panelId: terminalPanel.id, - drawCount: stats.drawCount, - presentCount: stats.presentCount, - lastPresentTime: stats.lastPresentTime, - windowVisible: stats.windowOcclusionVisible, - appIsActive: stats.appIsActive, - desiredFocus: stats.desiredFocus, - isFirstResponder: stats.isFirstResponder - ) - } - - private func moveUITestWindowToTargetDisplayIfNeeded(attempt: Int = 0) { - let env = ProcessInfo.processInfo.environment - guard let rawDisplayID = env["PROGRAMA_UI_TEST_TARGET_DISPLAY_ID"], - let targetDisplayID = UInt32(rawDisplayID) else { - return - } - - guard let screen = NSScreen.screens.first(where: { $0.programaDisplayID == targetDisplayID }) else { - if attempt < 20 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in - self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1) - } - } - self.writeUITestDiagnosticsIfNeeded(stage: "targetDisplayMissing") - return - } - - guard let window = NSApp.windows.first else { - if attempt < 20 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in - self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1) - } - } - self.writeUITestDiagnosticsIfNeeded(stage: "targetDisplayNoWindow") - return - } - - let visibleFrame = screen.visibleFrame - let width = min(window.frame.width, max(visibleFrame.width - 80, 480)) - let height = min(window.frame.height, max(visibleFrame.height - 80, 360)) - let frame = NSRect( - x: visibleFrame.midX - (width / 2), - y: visibleFrame.midY - (height / 2), - width: width, - height: height - ).integral - - window.setFrame(frame, display: true, animate: false) - window.makeKeyAndOrderFront(nil) - window.orderFrontRegardless() - if window.screen?.programaDisplayID != targetDisplayID, attempt < 20 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in - self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1) - } - return - } - self.writeUITestDiagnosticsIfNeeded(stage: "afterMoveToTargetDisplay") - } -#endif - - func applicationDidBecomeActive(_ notification: Notification) { - guard let notificationStore else { return } - notificationStore.handleApplicationDidBecomeActive() - guard let tabManager else { return } - guard let tabId = tabManager.selectedTabId else { return } - let surfaceId = tabManager.focusedSurfaceId(for: tabId) - guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return } - - if let surfaceId, - let tab = tabManager.tabs.first(where: { $0.id == tabId }) { - tab.triggerNotificationFocusFlash(panelId: surfaceId, requiresSplit: false, shouldFocus: false) - } - notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) - } - - func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { - isTerminatingApp = true - _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) - - // Tagged DEV builds are ephemeral, skip quit confirmation entirely. - if SocketControlSettings.isTaggedDevBuild() { - return .terminateNow - } - - // If the user already confirmed via the Cmd+Q shortcut warning dialog - // (handleQuitShortcutWarning), skip the check to avoid a second alert. - if isQuitWarningConfirmed { - return .terminateNow - } - - // Respect the "Warn Before Quit" setting even when Cmd+Q arrives via - // the Cmd+Tab app switcher, bypassing handleCustomShortcut. - guard QuitWarningSettings.isEnabled() else { - return .terminateNow - } - - // Show the same confirmation dialog used by the Cmd+Q shortcut path, - // then reply asynchronously so we can return .terminateLater now. - DispatchQueue.main.async { - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = String(localized: "dialog.quitPrograma.title", defaultValue: "Quit Programa?") - alert.informativeText = String(localized: "dialog.quitPrograma.message", defaultValue: "This will close all windows and workspaces.") - alert.addButton(withTitle: String(localized: "dialog.quitPrograma.quit", defaultValue: "Quit")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) - alert.showsSuppressionButton = true - alert.suppressionButton?.title = String(localized: "dialog.dontWarnCmdQ", defaultValue: "Don't warn again for Cmd+Q") - - let response = alert.runModal() - if alert.suppressionButton?.state == .on { - QuitWarningSettings.setEnabled(false) - } - - let shouldQuit = response == .alertFirstButtonReturn - if shouldQuit { - self.isQuitWarningConfirmed = true - } else { - // Reset so that the next quit attempt can show the dialog again. - self.isTerminatingApp = false - } - NSApp.reply(toApplicationShouldTerminate: shouldQuit) - } - return .terminateLater - } - - func applicationWillTerminate(_ notification: Notification) { - isTerminatingApp = true - _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) - stopSessionAutosaveTimer() - TerminalController.shared.stop() - VSCodeServeWebController.shared.stop() - BrowserProfileStore.shared.flushPendingSaves() - notificationStore?.clearAll() - SurfacePool.shared.teardownAll() - enableSuddenTerminationIfNeeded() - } - - func applicationWillResignActive(_ notification: Notification) { - guard !isTerminatingApp else { return } - clearConfiguredShortcutChordState() - _ = saveSessionSnapshot(includeScrollback: false) - } - - func persistSessionForUpdateRelaunch() { - isTerminatingApp = true - _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) - } - - func configure(tabManager: TabManager, notificationStore: TerminalNotificationStore, sidebarState: SidebarState) { - self.tabManager = tabManager - self.notificationStore = notificationStore - self.sidebarState = sidebarState - disableSuddenTerminationIfNeeded() - installLifecycleSnapshotObserversIfNeeded() - prepareStartupSessionSnapshotIfNeeded() - startSessionAutosaveTimerIfNeeded() -#if DEBUG - setupJumpUnreadUITestIfNeeded() - setupTerminalCmdClickUITestIfNeeded() - setupGotoSplitUITestIfNeeded() - setupBonsplitTabDragUITestIfNeeded() - 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) { - 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 - } - - - private func prepareStartupSessionSnapshotIfNeeded() { - guard !didPrepareStartupSessionSnapshot else { return } - didPrepareStartupSessionSnapshot = true - guard SessionRestorePolicy.shouldAttemptRestore() else { return } - Self.removeLegacyPersistedWindowGeometry() - startupSessionSnapshot = SessionPersistenceStore.load() - } - - private func persistedWindowGeometry( - defaults: UserDefaults = .standard - ) -> PersistedWindowGeometry? { - Self.removeLegacyPersistedWindowGeometry(defaults: defaults) - guard let data = defaults.data(forKey: Self.persistedWindowGeometryDefaultsKey) else { - return nil - } - guard let payload = Self.decodedPersistedWindowGeometryData(data) else { - defaults.removeObject(forKey: Self.persistedWindowGeometryDefaultsKey) - return nil - } - return payload - } - - private func persistWindowGeometry( - frame: SessionRectSnapshot?, - display: SessionDisplaySnapshot?, - defaults: UserDefaults = .standard - ) { - Self.removeLegacyPersistedWindowGeometry(defaults: defaults) - guard let data = Self.encodedPersistedWindowGeometryData(frame: frame, display: display) else { - return - } - defaults.set(data, forKey: Self.persistedWindowGeometryDefaultsKey) - } - - private nonisolated static func encodedPersistedWindowGeometryData( - frame: SessionRectSnapshot?, - display: SessionDisplaySnapshot? - ) -> Data? { - guard let frame else { return nil } - let payload = PersistedWindowGeometry( - version: persistedWindowGeometrySchemaVersion, - frame: frame, - display: display - ) - return try? JSONEncoder().encode(payload) - } - - nonisolated static func decodedPersistedWindowGeometryData(_ data: Data) -> PersistedWindowGeometry? { - guard let payload = try? JSONDecoder().decode(PersistedWindowGeometry.self, from: data), - payload.version == persistedWindowGeometrySchemaVersion else { - return nil - } - return payload - } - - private nonisolated static func removeLegacyPersistedWindowGeometry( - defaults: UserDefaults = .standard - ) { - legacyPersistedWindowGeometryDefaultsKeys.forEach { defaults.removeObject(forKey: $0) } - } - - private func persistWindowGeometry(from window: NSWindow?) { - guard let window else { return } - persistWindowGeometry( - frame: SessionRectSnapshot(window.frame), - display: displaySnapshot(for: window) - ) - } - - private func currentDisplayGeometries() -> ( - available: [SessionDisplayGeometry], - fallback: SessionDisplayGeometry? - ) { - let available = NSScreen.screens.map { screen in - SessionDisplayGeometry( - displayID: screen.programaDisplayID, - frame: screen.frame, - visibleFrame: screen.visibleFrame - ) - } - let fallback = (NSScreen.main ?? NSScreen.screens.first).map { screen in - SessionDisplayGeometry( - displayID: screen.programaDisplayID, - frame: screen.frame, - visibleFrame: screen.visibleFrame - ) - } - return (available, fallback) - } - - private func attemptStartupSessionRestoreIfNeeded(primaryWindow: NSWindow) { - guard !didAttemptStartupSessionRestore else { return } - didAttemptStartupSessionRestore = true - guard !didHandleExplicitOpenIntentAtStartup else { return } - guard let primaryContext = contextForMainTerminalWindow(primaryWindow) else { return } - - let startupSnapshot = startupSessionSnapshot - let primaryWindowSnapshot = startupSnapshot?.windows.first - if let primaryWindowSnapshot { - isApplyingStartupSessionRestore = true -#if DEBUG - dlog( - "session.restore.start windows=\(startupSnapshot?.windows.count ?? 0) " + - "primaryFrame={\(debugSessionRectDescription(primaryWindowSnapshot.frame))} " + - "primaryDisplay={\(debugSessionDisplayDescription(primaryWindowSnapshot.display))}" - ) -#endif - applySessionWindowSnapshot( - primaryWindowSnapshot, - to: primaryContext, - window: primaryWindow - ) - } else { - let displays = currentDisplayGeometries() - let fallbackGeometry = persistedWindowGeometry() - if let restoredFrame = Self.resolvedStartupPrimaryWindowFrame( - primarySnapshot: nil, - fallbackFrame: fallbackGeometry?.frame, - fallbackDisplaySnapshot: fallbackGeometry?.display, - availableDisplays: displays.available, - fallbackDisplay: displays.fallback - ) { - primaryWindow.setFrame(restoredFrame, display: true) - } - } - - if let startupSnapshot { - let additionalWindows = Array(startupSnapshot - .windows - .dropFirst() - .prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1))) -#if DEBUG - for (index, windowSnapshot) in additionalWindows.enumerated() { - dlog( - "session.restore.enqueueAdditional idx=\(index + 1) " + - "frame={\(debugSessionRectDescription(windowSnapshot.frame))} " + - "display={\(debugSessionDisplayDescription(windowSnapshot.display))}" - ) - } -#endif - if !additionalWindows.isEmpty { - DispatchQueue.main.async { [weak self] in - guard let self else { return } - for windowSnapshot in additionalWindows { - _ = self.createMainWindow(sessionWindowSnapshot: windowSnapshot) - } - self.completeStartupSessionRestore() - } - } else { - completeStartupSessionRestore() - } - } - } - - private func completeStartupSessionRestore() { - startupSessionSnapshot = nil - isApplyingStartupSessionRestore = false - _ = saveSessionSnapshot(includeScrollback: false) - } - - private func applySessionWindowSnapshot( - _ snapshot: SessionWindowSnapshot, - to context: MainWindowContext, - window: NSWindow? - ) { -#if DEBUG - dlog( - "session.restore.apply window=\(context.windowId.uuidString.prefix(8)) " + - "liveWin=\(window?.windowNumber ?? -1) " + - "snapshotFrame={\(debugSessionRectDescription(snapshot.frame))} " + - "snapshotDisplay={\(debugSessionDisplayDescription(snapshot.display))}" - ) -#endif - context.tabManager.restoreSessionSnapshot(snapshot.tabManager) - context.sidebarState.isVisible = snapshot.sidebar.isVisible - context.sidebarState.persistedWidth = CGFloat( - SessionPersistencePolicy.sanitizedSidebarWidth(snapshot.sidebar.width) - ) - context.sidebarSelectionState.selection = snapshot.sidebar.selection.sidebarSelection - - if let restoredFrame = resolvedWindowFrame(from: snapshot), let window { - window.setFrame(restoredFrame, display: true) -#if DEBUG - dlog( - "session.restore.frameApplied window=\(context.windowId.uuidString.prefix(8)) " + - "applied={\(debugNSRectDescription(window.frame))}" - ) -#endif - } - } - - private func resolvedWindowFrame(from snapshot: SessionWindowSnapshot?) -> NSRect? { - let displays = currentDisplayGeometries() - return Self.resolvedWindowFrame( - from: snapshot?.frame, - display: snapshot?.display, - availableDisplays: displays.available, - fallbackDisplay: displays.fallback - ) - } - - nonisolated static func resolvedStartupPrimaryWindowFrame( - primarySnapshot: SessionWindowSnapshot?, - fallbackFrame: SessionRectSnapshot?, - fallbackDisplaySnapshot: SessionDisplaySnapshot?, - availableDisplays: [SessionDisplayGeometry], - fallbackDisplay: SessionDisplayGeometry? - ) -> CGRect? { - if let primary = resolvedWindowFrame( - from: primarySnapshot?.frame, - display: primarySnapshot?.display, - availableDisplays: availableDisplays, - fallbackDisplay: fallbackDisplay - ) { - return primary - } - - return resolvedWindowFrame( - from: fallbackFrame, - display: fallbackDisplaySnapshot, - availableDisplays: availableDisplays, - fallbackDisplay: fallbackDisplay - ) - } - - nonisolated static func resolvedWindowFrame( - from frameSnapshot: SessionRectSnapshot?, - display displaySnapshot: SessionDisplaySnapshot?, - availableDisplays: [SessionDisplayGeometry], - fallbackDisplay: SessionDisplayGeometry? - ) -> CGRect? { - guard let frameSnapshot else { return nil } - let frame = frameSnapshot.cgRect - guard frame.width.isFinite, - frame.height.isFinite, - frame.origin.x.isFinite, - frame.origin.y.isFinite else { - return nil - } - - let minWidth = CGFloat(SessionPersistencePolicy.minimumWindowWidth) - let minHeight = CGFloat(SessionPersistencePolicy.minimumWindowHeight) - guard frame.width >= minWidth, - frame.height >= minHeight else { - return nil - } - - guard !availableDisplays.isEmpty else { return frame } - - if let targetDisplay = display(for: displaySnapshot, in: availableDisplays) { - if shouldPreserveExactFrame( - frame: frame, - displaySnapshot: displaySnapshot, - targetDisplay: targetDisplay - ) { - return frame - } - return resolvedWindowFrame( - frame: frame, - displaySnapshot: displaySnapshot, - targetDisplay: targetDisplay, - minWidth: minWidth, - minHeight: minHeight - ) - } - - if let intersectingDisplay = availableDisplays.first(where: { $0.visibleFrame.intersects(frame) }) { - return clampFrame( - frame, - within: intersectingDisplay.visibleFrame, - minWidth: minWidth, - minHeight: minHeight - ) - } - - guard let fallbackDisplay else { return frame } - if let sourceReference = displaySnapshot?.visibleFrame?.cgRect ?? displaySnapshot?.frame?.cgRect { - return remappedFrame( - frame, - from: sourceReference, - to: fallbackDisplay.visibleFrame, - minWidth: minWidth, - minHeight: minHeight - ) - } - - return centeredFrame( - frame, - in: fallbackDisplay.visibleFrame, - minWidth: minWidth, - minHeight: minHeight - ) - } - - private nonisolated static func resolvedWindowFrame( - frame: CGRect, - displaySnapshot: SessionDisplaySnapshot?, - targetDisplay: SessionDisplayGeometry, - minWidth: CGFloat, - minHeight: CGFloat - ) -> CGRect { - if targetDisplay.visibleFrame.intersects(frame) { - // Preserve the user's exact frame when enough of the top of the window - // remains reachable on-screen; only clamp when the saved frame would - // reopen with an inaccessible titlebar/top strip. - if shouldPreserveAccessibleFrame( - frame: frame, - targetDisplay: targetDisplay - ) { - return frame - } - return clampFrame( - frame, - within: targetDisplay.visibleFrame, - minWidth: minWidth, - minHeight: minHeight - ) - } - - if let sourceReference = displaySnapshot?.visibleFrame?.cgRect ?? displaySnapshot?.frame?.cgRect { - return remappedFrame( - frame, - from: sourceReference, - to: targetDisplay.visibleFrame, - minWidth: minWidth, - minHeight: minHeight - ) - } - - return centeredFrame( - frame, - in: targetDisplay.visibleFrame, - minWidth: minWidth, - minHeight: minHeight - ) - } - - private nonisolated static func shouldPreserveAccessibleFrame( - frame: CGRect, - targetDisplay: SessionDisplayGeometry, - minimumVisibleTopStripWidth: CGFloat = 120, - topStripHeight: CGFloat = 64, - minimumVisibleTopStripHeight: CGFloat = 24 - ) -> Bool { - let standardizedFrame = frame.standardized - guard standardizedFrame.width.isFinite, - standardizedFrame.height.isFinite, - standardizedFrame.width > 0, - standardizedFrame.height > 0, - standardizedFrame.intersects(targetDisplay.frame) else { - return false - } - - let stripHeight = min(topStripHeight, standardizedFrame.height) - let topStrip = CGRect( - x: standardizedFrame.minX, - y: standardizedFrame.maxY - stripHeight, - width: standardizedFrame.width, - height: stripHeight - ) - let visibleTopStrip = topStrip.intersection(targetDisplay.visibleFrame) - guard !visibleTopStrip.isNull else { return false } - - let requiredWidth = min(minimumVisibleTopStripWidth, standardizedFrame.width) - let requiredHeight = min(minimumVisibleTopStripHeight, stripHeight) - return visibleTopStrip.width >= requiredWidth - && visibleTopStrip.height >= requiredHeight - } - - private nonisolated static func display( - for snapshot: SessionDisplaySnapshot?, - in displays: [SessionDisplayGeometry] - ) -> SessionDisplayGeometry? { - guard let snapshot else { return nil } - if let displayID = snapshot.displayID, - let exact = displays.first(where: { $0.displayID == displayID }) { - return exact - } - - guard let referenceRect = (snapshot.visibleFrame ?? snapshot.frame)?.cgRect else { - return nil - } - - let overlaps = displays.map { display -> (display: SessionDisplayGeometry, area: CGFloat) in - (display, intersectionArea(referenceRect, display.visibleFrame)) - } - if let bestOverlap = overlaps.max(by: { $0.area < $1.area }), bestOverlap.area > 0 { - return bestOverlap.display - } - - let referenceCenter = CGPoint(x: referenceRect.midX, y: referenceRect.midY) - return displays.min { lhs, rhs in - let lhsDistance = distanceSquared(lhs.visibleFrame, referenceCenter) - let rhsDistance = distanceSquared(rhs.visibleFrame, referenceCenter) - return lhsDistance < rhsDistance - } - } - - private nonisolated static func remappedFrame( - _ frame: CGRect, - from sourceRect: CGRect, - to targetRect: CGRect, - minWidth: CGFloat, - minHeight: CGFloat - ) -> CGRect { - let source = sourceRect.standardized - let target = targetRect.standardized - guard source.width.isFinite, - source.height.isFinite, - source.width > 1, - source.height > 1, - target.width.isFinite, - target.height.isFinite, - target.width > 0, - target.height > 0 else { - return centeredFrame(frame, in: targetRect, minWidth: minWidth, minHeight: minHeight) - } - - let relativeX = (frame.minX - source.minX) / source.width - let relativeY = (frame.minY - source.minY) / source.height - let relativeWidth = frame.width / source.width - let relativeHeight = frame.height / source.height - - let remapped = CGRect( - x: target.minX + (relativeX * target.width), - y: target.minY + (relativeY * target.height), - width: target.width * relativeWidth, - height: target.height * relativeHeight - ) - return clampFrame(remapped, within: target, minWidth: minWidth, minHeight: minHeight) - } - - private nonisolated static func centeredFrame( - _ frame: CGRect, - in visibleFrame: CGRect, - minWidth: CGFloat, - minHeight: CGFloat - ) -> CGRect { - let centered = CGRect( - x: visibleFrame.midX - (frame.width / 2), - y: visibleFrame.midY - (frame.height / 2), - width: frame.width, - height: frame.height - ) - return clampFrame(centered, within: visibleFrame, minWidth: minWidth, minHeight: minHeight) - } - - private nonisolated static func clampFrame( - _ frame: CGRect, - within visibleFrame: CGRect, - minWidth: CGFloat, - minHeight: CGFloat - ) -> CGRect { - guard visibleFrame.width.isFinite, - visibleFrame.height.isFinite, - visibleFrame.width > 0, - visibleFrame.height > 0 else { - return frame - } - - let maxWidth = max(visibleFrame.width, 1) - let maxHeight = max(visibleFrame.height, 1) - let widthFloor = min(minWidth, maxWidth) - let heightFloor = min(minHeight, maxHeight) - - let width = min(max(frame.width, widthFloor), maxWidth) - let height = min(max(frame.height, heightFloor), maxHeight) - let maxX = visibleFrame.maxX - width - let maxY = visibleFrame.maxY - height - let x = min(max(frame.minX, visibleFrame.minX), maxX) - let y = min(max(frame.minY, visibleFrame.minY), maxY) - - return CGRect(x: x, y: y, width: width, height: height) - } - - private nonisolated static func intersectionArea(_ lhs: CGRect, _ rhs: CGRect) -> CGFloat { - let intersection = lhs.intersection(rhs) - guard !intersection.isNull else { return 0 } - return max(0, intersection.width) * max(0, intersection.height) - } - - private nonisolated static func distanceSquared(_ rect: CGRect, _ point: CGPoint) -> CGFloat { - let dx = rect.midX - point.x - let dy = rect.midY - point.y - return (dx * dx) + (dy * dy) - } - - private nonisolated static func shouldPreserveExactFrame( - frame: CGRect, - displaySnapshot: SessionDisplaySnapshot?, - targetDisplay: SessionDisplayGeometry - ) -> Bool { - guard let displaySnapshot else { return false } - guard let snapshotDisplayID = displaySnapshot.displayID, - let targetDisplayID = targetDisplay.displayID, - snapshotDisplayID == targetDisplayID else { - return false - } - - let visibleMatches = displaySnapshot.visibleFrame.map { - rectApproximatelyEqual($0.cgRect, targetDisplay.visibleFrame) - } ?? false - let frameMatches = displaySnapshot.frame.map { - rectApproximatelyEqual($0.cgRect, targetDisplay.frame) - } ?? false - guard visibleMatches || frameMatches else { return false } - - return frame.width.isFinite - && frame.height.isFinite - && frame.origin.x.isFinite - && frame.origin.y.isFinite - } - - private nonisolated static func rectApproximatelyEqual( - _ lhs: CGRect, - _ rhs: CGRect, - tolerance: CGFloat = 1 - ) -> Bool { - let lhsStd = lhs.standardized - let rhsStd = rhs.standardized - return abs(lhsStd.origin.x - rhsStd.origin.x) <= tolerance - && abs(lhsStd.origin.y - rhsStd.origin.y) <= tolerance - && abs(lhsStd.size.width - rhsStd.size.width) <= tolerance - && abs(lhsStd.size.height - rhsStd.size.height) <= tolerance - } - - private func displaySnapshot(for window: NSWindow?) -> SessionDisplaySnapshot? { - guard let window else { return nil } - let screen = window.screen - ?? NSScreen.screens.first(where: { $0.frame.intersects(window.frame) }) - guard let screen else { return nil } - - return SessionDisplaySnapshot( - displayID: screen.programaDisplayID, - frame: SessionRectSnapshot(screen.frame), - visibleFrame: SessionRectSnapshot(screen.visibleFrame) - ) - } - - private func startSessionAutosaveTimerIfNeeded() { - guard sessionAutosaveTimer == nil else { return } - let env = ProcessInfo.processInfo.environment - guard !isRunningUnderXCTest(env) else { return } - - let timer = DispatchSource.makeTimerSource(queue: .main) - let interval = SessionPersistencePolicy.autosaveInterval - timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1)) - timer.setEventHandler { [weak self] in - guard let self, - Self.shouldRunSessionAutosaveTick(isTerminatingApp: self.isTerminatingApp) else { - return - } - self.runSessionAutosaveTick(source: "timer") - } - sessionAutosaveTimer = timer - timer.resume() - } - - private func stopSessionAutosaveTimer() { - sessionAutosaveTimer?.cancel() - sessionAutosaveTimer = nil - sessionAutosaveTickInFlight = false - sessionAutosaveDeferredRetryPending = false - } - - private func installLifecycleSnapshotObserversIfNeeded() { - guard !didInstallLifecycleSnapshotObservers else { return } - didInstallLifecycleSnapshotObservers = true - - let workspaceCenter = NSWorkspace.shared.notificationCenter - let powerOffObserver = workspaceCenter.addObserver( - forName: NSWorkspace.willPowerOffNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - guard let self else { return } - self.isTerminatingApp = true - _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) - } - } - lifecycleSnapshotObservers.append(powerOffObserver) - - let sessionResignObserver = workspaceCenter.addObserver( - forName: NSWorkspace.sessionDidResignActiveNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - guard let self else { return } - if self.isTerminatingApp { - _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) - } else { - _ = self.saveSessionSnapshot(includeScrollback: false) - } - } - } - lifecycleSnapshotObservers.append(sessionResignObserver) - - let didWakeObserver = workspaceCenter.addObserver( - forName: NSWorkspace.didWakeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.restartSocketListenerIfEnabled(source: "workspace.didWake") - } - } - lifecycleSnapshotObservers.append(didWakeObserver) - } - - func socketListenerConfigurationIfEnabled() -> (mode: SocketControlMode, path: String)? { - let raw = UserDefaults.standard.string(forKey: SocketControlSettings.appStorageKey) - ?? SocketControlSettings.defaultMode.rawValue - let userMode = SocketControlSettings.migrateMode(raw) - let mode = SocketControlSettings.effectiveMode(userMode: userMode) - guard mode != .off else { return nil } - return (mode: mode, path: SocketControlSettings.socketPath()) - } - - func restartSocketListenerIfEnabled(source: String) { - guard let tabManager, - let config = socketListenerConfigurationIfEnabled() else { return } - let restartPath = TerminalController.shared.activeSocketPath(preferredPath: config.path) - TerminalController.shared.stop() - TerminalController.shared.start(tabManager: tabManager, socketPath: restartPath, accessMode: config.mode) - } - - private func disableSuddenTerminationIfNeeded() { - guard !didDisableSuddenTermination else { return } - ProcessInfo.processInfo.disableSuddenTermination() - didDisableSuddenTermination = true - } - - private func enableSuddenTerminationIfNeeded() { - guard didDisableSuddenTermination else { return } - ProcessInfo.processInfo.enableSuddenTermination() - didDisableSuddenTermination = false - } - - private func sessionAutosaveFingerprint(includeScrollback: Bool) -> Int? { - guard !includeScrollback else { return nil } - - var hasher = Hasher() - let contexts = mainWindowContexts.values.sorted { lhs, rhs in - lhs.windowId.uuidString < rhs.windowId.uuidString - } - hasher.combine(contexts.count) - - for context in contexts.prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) { - hasher.combine(context.windowId) - hasher.combine(context.tabManager.sessionAutosaveFingerprint()) - hasher.combine(context.sidebarState.isVisible) - hasher.combine( - Int(SessionPersistencePolicy.sanitizedSidebarWidth(Double(context.sidebarState.persistedWidth)).rounded()) - ) - - switch context.sidebarSelectionState.selection { - case .tabs: - hasher.combine(0) - case .notifications: - hasher.combine(1) - } - - if let window = context.window ?? windowForMainWindowId(context.windowId) { - Self.hashFrame(window.frame, into: &hasher) - } else { - hasher.combine(-1) - } - } - - return hasher.finalize() - } - - @discardableResult - private func saveSessionSnapshot(includeScrollback: Bool, removeWhenEmpty: Bool = false) -> Bool { - if Self.shouldSkipSessionSaveDuringStartupRestore( - isApplyingStartupSessionRestore: isApplyingStartupSessionRestore, - includeScrollback: includeScrollback - ) { -#if DEBUG - dlog("session.save.skipped reason=startup_restore_in_progress includeScrollback=0") -#endif - return false - } - - let writeSynchronously = Self.shouldWriteSessionSnapshotSynchronously( - isTerminatingApp: isTerminatingApp, - includeScrollback: includeScrollback - ) -#if DEBUG - let timingStart = ProgramaTypingTiming.start() - defer { - ProgramaTypingTiming.logDuration( - path: "session.saveSnapshot", - startedAt: timingStart, - extra: "includeScrollback=\(includeScrollback ? 1 : 0) removeWhenEmpty=\(removeWhenEmpty ? 1 : 0) sync=\(writeSynchronously ? 1 : 0)" - ) - } -#endif - - guard let snapshot = buildSessionSnapshot(includeScrollback: includeScrollback) else { - persistSessionSnapshot( - nil, - removeWhenEmpty: removeWhenEmpty, - persistedGeometryData: nil, - synchronously: writeSynchronously - ) - return false - } - - let persistedGeometryData = snapshot.windows.first.flatMap { primaryWindow in - Self.encodedPersistedWindowGeometryData( - frame: primaryWindow.frame, - display: primaryWindow.display - ) - } - -#if DEBUG - debugLogSessionSaveSnapshot(snapshot, includeScrollback: includeScrollback) -#endif - persistSessionSnapshot( - snapshot, - removeWhenEmpty: false, - persistedGeometryData: persistedGeometryData, - synchronously: writeSynchronously - ) - return true - } - - nonisolated static func shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: Bool) -> Bool { - !isTerminatingApp - } - - nonisolated static func shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister( - isTerminatingApp: Bool - ) -> Bool { - !isTerminatingApp - } - - nonisolated static func shouldSkipSessionSaveDuringStartupRestore( - isApplyingStartupSessionRestore: Bool, - includeScrollback: Bool - ) -> Bool { - isApplyingStartupSessionRestore && !includeScrollback - } - - nonisolated static func shouldRunSessionAutosaveTick(isTerminatingApp: Bool) -> Bool { - !isTerminatingApp - } - - private func remainingSessionAutosaveTypingQuietPeriod( - nowUptime: TimeInterval = ProcessInfo.processInfo.systemUptime - ) -> TimeInterval? { - guard lastTypingActivityAt > 0 else { return nil } - let elapsed = nowUptime - lastTypingActivityAt - guard elapsed < Self.sessionAutosaveTypingQuietPeriod else { return nil } - return Self.sessionAutosaveTypingQuietPeriod - elapsed - } - - private func scheduleDeferredSessionAutosaveRetry(after delay: TimeInterval) { - guard delay.isFinite, delay > 0 else { return } - guard !sessionAutosaveDeferredRetryPending else { return } - sessionAutosaveDeferredRetryPending = true - sessionPersistenceQueue.asyncAfter(deadline: .now() + delay) { [weak self] in - Task { @MainActor [weak self] in - guard let self else { return } - self.sessionAutosaveDeferredRetryPending = false - self.runSessionAutosaveTick(source: "typingQuietRetry") - } - } - } - - private func runSessionAutosaveTick(source: String) { - guard Self.shouldRunSessionAutosaveTick(isTerminatingApp: isTerminatingApp) else { return } - guard !sessionAutosaveTickInFlight else { return } - if let remainingQuietPeriod = remainingSessionAutosaveTypingQuietPeriod() { -#if DEBUG - dlog( - "session.save.skipped reason=typing_recent includeScrollback=0 source=\(source) " + - "retryMs=\(Int((remainingQuietPeriod * 1000).rounded()))" - ) -#endif - scheduleDeferredSessionAutosaveRetry(after: remainingQuietPeriod) - return - } - - sessionAutosaveTickInFlight = true -#if DEBUG - let timingStart = ProgramaTypingTiming.start() - let phaseStart = ProcessInfo.processInfo.systemUptime - var fingerprintMs: Double = 0 - var saveMs: Double = 0 - defer { - sessionAutosaveTickInFlight = false - let totalMs = (ProcessInfo.processInfo.systemUptime - phaseStart) * 1000.0 - ProgramaTypingTiming.logBreakdown( - path: "session.autosaveTick.phase", - totalMs: totalMs, - thresholdMs: 2.0, - parts: [ - ("fingerprintMs", fingerprintMs), - ("saveMs", saveMs), - ], - extra: "source=\(source)" - ) - ProgramaTypingTiming.logDuration( - path: "session.autosaveTick", - startedAt: timingStart, - extra: "source=\(source)" - ) - } -#else - defer { sessionAutosaveTickInFlight = false } -#endif - - let now = Date() -#if DEBUG - let fingerprintStart = ProcessInfo.processInfo.systemUptime -#endif - let autosaveFingerprint = sessionAutosaveFingerprint(includeScrollback: false) -#if DEBUG - fingerprintMs = (ProcessInfo.processInfo.systemUptime - fingerprintStart) * 1000.0 -#endif - if Self.shouldSkipSessionAutosaveForUnchangedFingerprint( - isTerminatingApp: isTerminatingApp, - includeScrollback: false, - previousFingerprint: lastSessionAutosaveFingerprint, - currentFingerprint: autosaveFingerprint, - lastPersistedAt: lastSessionAutosavePersistedAt, - now: now - ) { -#if DEBUG - dlog( - "session.save.skipped reason=unchanged_autosave_fingerprint includeScrollback=0 source=\(source)" - ) -#endif - return - } - -#if DEBUG - let saveStart = ProcessInfo.processInfo.systemUptime -#endif - _ = saveSessionSnapshot(includeScrollback: false) -#if DEBUG - saveMs = (ProcessInfo.processInfo.systemUptime - saveStart) * 1000.0 -#endif - updateSessionAutosaveSaveState( - includeScrollback: false, - persistedAt: now, - fingerprint: autosaveFingerprint - ) - } - - fileprivate func recordTypingActivity() { - lastTypingActivityAt = ProcessInfo.processInfo.systemUptime - } - - nonisolated static func shouldWriteSessionSnapshotSynchronously( - isTerminatingApp: Bool, - includeScrollback: Bool - ) -> Bool { - isTerminatingApp && includeScrollback - } - - nonisolated static func shouldSkipSessionAutosaveForUnchangedFingerprint( - isTerminatingApp: Bool, - includeScrollback: Bool, - previousFingerprint: Int?, - currentFingerprint: Int?, - lastPersistedAt: Date, - now: Date, - maximumAutosaveSkippableInterval: TimeInterval = 60 - ) -> Bool { - guard !isTerminatingApp, - !includeScrollback, - let previousFingerprint, - let currentFingerprint, - previousFingerprint == currentFingerprint else { - return false - } - - return now.timeIntervalSince(lastPersistedAt) < maximumAutosaveSkippableInterval - } - - private func updateSessionAutosaveSaveState( - includeScrollback: Bool, - persistedAt: Date, - fingerprint: Int? - ) { - guard !isTerminatingApp, !includeScrollback else { return } - lastSessionAutosaveFingerprint = fingerprint - lastSessionAutosavePersistedAt = persistedAt - } - - private nonisolated static func hashFrame(_ frame: NSRect, into hasher: inout Hasher) { - let standardized = frame.standardized - let quantized = [ - standardized.origin.x, - standardized.origin.y, - standardized.size.width, - standardized.size.height, - ].map { Int(($0 * 2).rounded()) } - quantized.forEach { hasher.combine($0) } - } - - private func persistSessionSnapshot( - _ snapshot: AppSessionSnapshot?, - removeWhenEmpty: Bool, - persistedGeometryData: Data?, - synchronously: Bool - ) { - guard snapshot != nil || removeWhenEmpty || persistedGeometryData != nil else { return } - - let writeBlock = { - Self.removeLegacyPersistedWindowGeometry() - if let persistedGeometryData { - UserDefaults.standard.set( - persistedGeometryData, - forKey: Self.persistedWindowGeometryDefaultsKey - ) - } - if let snapshot { - _ = SessionPersistenceStore.save(snapshot) - } else if removeWhenEmpty { - SessionPersistenceStore.removeSnapshot() - } - } - - if synchronously { - writeBlock() - } else { - sessionPersistenceQueue.async(execute: writeBlock) - } - } - - 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 - let rhsIsKey = rhsWindow?.isKeyWindow ?? false - if lhsIsKey != rhsIsKey { - return lhsIsKey && !rhsIsKey + // UI tests frequently time out waiting for the main window if we do heavyweight + // LaunchServices registration / single-instance enforcement synchronously at startup. + // Skip these during XCTest (the app-under-test) so the window can appear quickly. + if !isRunningUnderXCTest { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.scheduleLaunchServicesBundleRegistration() + self.enforceSingleInstance() + self.observeDuplicateLaunches() } - return lhs.windowId.uuidString < rhs.windowId.uuidString - } - - guard !contexts.isEmpty else { return nil } - - let windows: [SessionWindowSnapshot] = contexts - .prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) - .map { context in - let window = context.window ?? windowForMainWindowId(context.windowId) - return SessionWindowSnapshot( - frame: window.map { SessionRectSnapshot($0.frame) }, - display: displaySnapshot(for: window), - tabManager: context.tabManager.sessionSnapshot(includeScrollback: includeScrollback), - sidebar: SessionSidebarSnapshot( - isVisible: context.sidebarState.isVisible, - selection: SessionSidebarSelection(selection: context.sidebarSelectionState.selection), - width: SessionPersistencePolicy.sanitizedSidebarWidth(Double(context.sidebarState.persistedWidth)) - ) - ) + } else if forceDuplicateLaunchObserver { + // Some UI regressions specifically exercise launch-observer behavior while still + // running under XCTest. Allow an explicit opt-in for those cases only. + DispatchQueue.main.async { [weak self] in + self?.observeDuplicateLaunches() } - - guard !windows.isEmpty else { return nil } - return AppSessionSnapshot( - version: SessionSnapshotSchema.currentVersion, - createdAt: Date().timeIntervalSince1970, - windows: windows - ) - } - -#if DEBUG - private func debugLogSessionSaveSnapshot( - _ snapshot: AppSessionSnapshot, - includeScrollback: Bool - ) { - 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" - dlog( - "session.save.window idx=\(index) " + - "frame={\(debugSessionRectDescription(windowSnapshot.frame))} " + - "display={\(debugSessionDisplayDescription(windowSnapshot.display))} " + - "workspaces=\(workspaceCount) selected=\(selectedWorkspace)" - ) } - } - - private func debugSessionRectDescription(_ rect: SessionRectSnapshot?) -> String { - guard let rect else { return "nil" } - return "x=\(debugSessionNumber(rect.x)) y=\(debugSessionNumber(rect.y)) " + - "w=\(debugSessionNumber(rect.width)) h=\(debugSessionNumber(rect.height))" - } - - private func debugNSRectDescription(_ rect: NSRect?) -> String { - guard let rect else { return "nil" } - return "x=\(debugSessionNumber(Double(rect.origin.x))) " + - "y=\(debugSessionNumber(Double(rect.origin.y))) " + - "w=\(debugSessionNumber(Double(rect.size.width))) " + - "h=\(debugSessionNumber(Double(rect.size.height)))" - } - - private func debugSessionDisplayDescription(_ display: SessionDisplaySnapshot?) -> String { - guard let display else { return "nil" } - let displayIdText = display.displayID.map(String.init) ?? "nil" - return "id=\(displayIdText) " + - "frame={\(debugSessionRectDescription(display.frame))} " + - "visible={\(debugSessionRectDescription(display.visibleFrame))}" - } - - private func debugSessionNumber(_ value: Double) -> String { - String(format: "%.1f", value) - } -#endif - - private func notifyMainWindowContextsDidChange() { - NotificationCenter.default.post(name: .mainWindowContextsDidChange, object: self) - } - - /// Register a terminal window with the AppDelegate so menu commands and socket control - /// can target whichever window is currently active. - func registerMainWindow( - _ window: NSWindow, - windowId: UUID, - tabManager: TabManager, - sidebarState: SidebarState, - sidebarSelectionState: SidebarSelectionState - ) { - tabManager.window = window - - let key = ObjectIdentifier(window) - #if DEBUG - let priorManagerToken = debugManagerToken(self.tabManager) - #endif - if let existing = mainWindowContexts[key] { - existing.window = window - } else if let existing = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { - existing.window = window - reindexMainWindowContextIfNeeded(existing, for: window) - } else { - mainWindowContexts[key] = MainWindowContext( - windowId: windowId, - tabManager: tabManager, - sidebarState: sidebarState, - sidebarSelectionState: sidebarSelectionState, - window: window - ) - NotificationCenter.default.addObserver( - forName: NSWindow.willCloseNotification, - object: window, - queue: .main - ) { [weak self] note in - guard let self, let closing = note.object as? NSWindow else { return } - self.unregisterMainWindow(closing) - } + NSWindow.allowsAutomaticWindowTabbing = false + disableNativeTabbingShortcut() + ensureApplicationIcon() + if !isRunningUnderXCTest { + configureUserNotifications() + installMenuBarVisibilityObserver() + syncMenuBarExtraVisibility() + updateController.startUpdaterIfNeeded() } - commandPaletteVisibilityByWindowId[windowId] = false - commandPaletteSelectionByWindowId[windowId] = 0 - commandPaletteSnapshotByWindowId[windowId] = .empty - + titlebarAccessoryController.start() + windowDecorationsController.start() + installMainWindowKeyObserver() + refreshGhosttyGotoSplitShortcuts() + installGhosttyConfigObserver() + installWindowResponderSwizzles() + installBrowserAddressBarFocusObservers() + installShortcutMonitor() + installShortcutDefaultsObserver() + NSApp.servicesProvider = self #if DEBUG - dlog( - "mainWindow.register windowId=\(String(windowId.uuidString.prefix(8))) window={\(debugWindowToken(window))} manager=\(debugManagerToken(tabManager)) priorActiveMgr=\(priorManagerToken) \(debugShortcutRouteSnapshot())" - ) -#endif - notifyMainWindowContextsDidChange() - if window.isKeyWindow { - setActiveMainWindow(window) - } - - attemptStartupSessionRestoreIfNeeded(primaryWindow: window) - if !isTerminatingApp { - _ = saveSessionSnapshot(includeScrollback: false) - } - } - - struct MainWindowSummary { - let windowId: UUID - let isKeyWindow: Bool - let isVisible: Bool - let workspaceCount: Int - let selectedWorkspaceId: UUID? - } - - struct WindowMoveTarget: Identifiable { - let windowId: UUID - let label: String - let tabManager: TabManager - let isCurrentWindow: Bool - - var id: UUID { windowId } - } - - struct WorkspaceMoveTarget: Identifiable { - let windowId: UUID - let workspaceId: UUID - let windowLabel: String - let workspaceTitle: String - let tabManager: TabManager - let isCurrentWindow: Bool - - var id: String { "\(windowId.uuidString):\(workspaceId.uuidString)" } - var label: String { - isCurrentWindow ? workspaceTitle : "\(workspaceTitle) (\(windowLabel))" - } - } - - func listMainWindowSummaries() -> [MainWindowSummary] { - let contexts = Array(mainWindowContexts.values) - return contexts.map { ctx in - let window = ctx.window ?? windowForMainWindowId(ctx.windowId) - return MainWindowSummary( - windowId: ctx.windowId, - isKeyWindow: window?.isKeyWindow ?? false, - isVisible: window?.isVisible ?? false, - workspaceCount: ctx.tabManager.tabs.count, - selectedWorkspaceId: ctx.tabManager.selectedTabId - ) + UpdateTestSupport.applyIfNeeded(to: updateController.viewModel) + if env["PROGRAMA_UI_TEST_MODE"] == "1" { + let trigger = env["PROGRAMA_UI_TEST_TRIGGER_UPDATE_CHECK"] ?? "" + let feed = env["PROGRAMA_UI_TEST_FEED_URL"] ?? "" + UpdateLogStore.shared.append("ui test env: trigger=\(trigger) feed=\(feed)") } - } - - func windowMoveTargets(referenceWindowId: UUID?) -> [WindowMoveTarget] { - let orderedSummaries = orderedMainWindowSummaries(referenceWindowId: referenceWindowId) - let labels = windowLabelsById(orderedSummaries: orderedSummaries, referenceWindowId: referenceWindowId) - return orderedSummaries.compactMap { summary in - guard let manager = tabManagerFor(windowId: summary.windowId) else { return nil } - let label = labels[summary.windowId] ?? "Window" - return WindowMoveTarget( - windowId: summary.windowId, - label: label, - tabManager: manager, - isCurrentWindow: summary.windowId == referenceWindowId - ) + if env["PROGRAMA_UI_TEST_TRIGGER_UPDATE_CHECK"] == "1" { + UpdateLogStore.shared.append("ui test trigger update check detected") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + guard let self else { return } + let windowIds = NSApp.windows.map { $0.identifier?.rawValue ?? "" } + UpdateLogStore.shared.append("ui test windows: count=\(NSApp.windows.count) ids=\(windowIds.joined(separator: ","))") + if UpdateTestSupport.performMockFeedCheckIfNeeded(on: self.updateController.viewModel) { + return + } + self.checkForUpdates(nil) + } } - } - - func workspaceMoveTargets(excludingWorkspaceId: UUID? = nil, referenceWindowId: UUID?) -> [WorkspaceMoveTarget] { - let orderedSummaries = orderedMainWindowSummaries(referenceWindowId: referenceWindowId) - let labels = windowLabelsById(orderedSummaries: orderedSummaries, referenceWindowId: referenceWindowId) - - var targets: [WorkspaceMoveTarget] = [] - targets.reserveCapacity(orderedSummaries.reduce(0) { partial, summary in - partial + summary.workspaceCount - }) - for summary in orderedSummaries { - guard let manager = tabManagerFor(windowId: summary.windowId) else { continue } - let windowLabel = labels[summary.windowId] ?? "Window" - let isCurrentWindow = summary.windowId == referenceWindowId - for workspace in manager.tabs { - if workspace.id == excludingWorkspaceId { - continue + // In UI tests, `WindowGroup` occasionally fails to materialize a window quickly on the VM. + // If there are no windows shortly after launch, force-create one so XCUITest can proceed. + if isRunningUnderXCTest { + if let rawShow = env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_SHOW"] { + UserDefaults.standard.set( + rawShow == "1", + forKey: BrowserImportHintSettings.showOnBlankTabsKey + ) + } + if let rawDismissed = env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_DISMISSED"] { + UserDefaults.standard.set( + rawDismissed == "1", + forKey: BrowserImportHintSettings.dismissedKey + ) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + guard let self else { return } + if NSApp.windows.isEmpty { + self.openNewMainWindow(nil) } - targets.append( - WorkspaceMoveTarget( - windowId: summary.windowId, - workspaceId: workspace.id, - windowLabel: windowLabel, - workspaceTitle: workspaceDisplayName(workspace), - tabManager: manager, - isCurrentWindow: isCurrentWindow + self.moveUITestWindowToTargetDisplayIfNeeded() + NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + // On headless CI runners, activate() silently fails (no GUI session). + // Force windows visible so the terminal surface starts rendering. + for window in NSApp.windows { + window.orderFrontRegardless() + } + self.writeUITestDiagnosticsIfNeeded(stage: "afterForceWindow") + } + if env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_OPEN_BLANK_BROWSER"] == "1" { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in + guard let self else { return } + _ = self.openBrowserAndFocusAddressBar(insertAtEnd: true) + } + } + if env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_OPEN_SETTINGS"] == "1" { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.55) { [weak self] in + self?.openPreferencesWindow( + debugSource: "uiTest.browserImportHint", + navigationTarget: .browser ) - ) + } + } + if env["PROGRAMA_UI_TEST_BROWSER_IMPORT_AUTO_OPEN"] == "1" { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + BrowserDataImportCoordinator.shared.presentImportDialog() + } } } - - return targets +#endif } - @discardableResult - func moveWorkspaceToWindow(workspaceId: UUID, windowId: UUID, focus: Bool = true) -> Bool { - guard let sourceManager = tabManagerFor(tabId: workspaceId), - let destinationManager = tabManagerFor(windowId: windowId) else { - return false - } +#if DEBUG + func writeUITestDiagnosticsIfNeeded(stage: String) { + let env = ProcessInfo.processInfo.environment + guard let path = env["PROGRAMA_UI_TEST_DIAGNOSTICS_PATH"], !path.isEmpty else { return } - if sourceManager === destinationManager { - if focus { - destinationManager.focusTab(workspaceId, suppressFlash: true) - _ = focusMainWindow(windowId: windowId) - TerminalController.shared.setActiveTabManager(destinationManager) - } - return true + var payload = loadUITestDiagnostics(at: path) + let isRunningUnderXCTest = isRunningUnderXCTest(env) + + let windows = NSApp.windows + let ids = windows.map { $0.identifier?.rawValue ?? "" }.joined(separator: ",") + let vis = windows.map { $0.isVisible ? "1" : "0" }.joined(separator: ",") + let screenIDs = windows.map { $0.screen?.programaDisplayID.map(String.init) ?? "" }.joined(separator: ",") + let targetDisplayID = env["PROGRAMA_UI_TEST_TARGET_DISPLAY_ID"] ?? "" + + payload["stage"] = stage + payload["pid"] = String(ProcessInfo.processInfo.processIdentifier) + payload["bundleId"] = Bundle.main.bundleIdentifier ?? "" + payload["isRunningUnderXCTest"] = isRunningUnderXCTest ? "1" : "0" + payload["windowsCount"] = String(windows.count) + payload["windowIdentifiers"] = ids + payload["windowVisibleFlags"] = vis + payload["windowScreenDisplayIDs"] = screenIDs + payload["uiTestTargetDisplayID"] = targetDisplayID + if let rawDisplayID = UInt32(targetDisplayID) { + let screenPresent = NSScreen.screens.contains(where: { $0.programaDisplayID == rawDisplayID }) + let movedWindow = windows.contains(where: { $0.screen?.programaDisplayID == rawDisplayID }) + payload["targetDisplayPresent"] = screenPresent ? "1" : "0" + payload["targetDisplayMoveSucceeded"] = movedWindow ? "1" : "0" } + appendUITestRenderDiagnosticsIfNeeded(&payload, environment: env) + appendUITestSocketDiagnosticsIfNeeded(&payload, environment: env) - guard let workspace = sourceManager.detachWorkspace(tabId: workspaceId) else { return false } - destinationManager.attachWorkspace(workspace, select: focus) + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } + try? data.write(to: URL(fileURLWithPath: path), options: .atomic) + } - if focus { - _ = focusMainWindow(windowId: windowId) - TerminalController.shared.setActiveTabManager(destinationManager) + private func loadUITestDiagnostics(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 [:] } - return true + return object } - @discardableResult - func moveWorkspaceToNewWindow(workspaceId: UUID, focus: Bool = true) -> UUID? { - let windowId = createMainWindow() - guard let destinationManager = tabManagerFor(windowId: windowId) else { return nil } - let bootstrapWorkspaceId = destinationManager.tabs.first?.id + private func appendUITestSocketDiagnosticsIfNeeded( + _ payload: inout [String: String], + environment env: [String: String] + ) { + guard env["PROGRAMA_UI_TEST_SOCKET_SANITY"] == "1" else { return } - guard moveWorkspaceToWindow(workspaceId: workspaceId, windowId: windowId, focus: focus) else { - _ = closeMainWindow(windowId: windowId) - return nil + guard let config = socketListenerConfigurationIfEnabled() else { + payload["socketExpectedPath"] = env["PROGRAMA_SOCKET_PATH"] ?? "" + payload["socketMode"] = "off" + payload["socketReady"] = "0" + payload["socketPingResponse"] = "" + payload["socketIsRunning"] = "0" + payload["socketAcceptLoopAlive"] = "0" + payload["socketPathMatches"] = "0" + payload["socketPathExists"] = "0" + payload["socketFailureSignals"] = "socket_disabled" + return } - // Remove the bootstrap workspace from the new window once the moved workspace arrives. - if let bootstrapWorkspaceId, - bootstrapWorkspaceId != workspaceId, - let bootstrapWorkspace = destinationManager.tabs.first(where: { $0.id == bootstrapWorkspaceId }), - destinationManager.tabs.count > 1 { - destinationManager.closeWorkspace(bootstrapWorkspace) + let socketPath = TerminalController.shared.activeSocketPath(preferredPath: config.path) + let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: socketPath) + let pingResponse = health.isHealthy + ? TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0) + : nil + let isReady = health.isHealthy && pingResponse == "PONG" + var failureSignals = health.failureSignals + if health.isHealthy && pingResponse != "PONG" { + failureSignals.append("ping_timeout") } - return windowId - } - func locateBonsplitSurface(tabId: UUID) -> (windowId: UUID, workspaceId: UUID, panelId: UUID, tabManager: TabManager)? { - let bonsplitTabId = TabID(uuid: tabId) - for context in mainWindowContexts.values { - for workspace in context.tabManager.tabs { - if let panelId = workspace.panelIdFromSurfaceId(bonsplitTabId) { - return (context.windowId, workspace.id, panelId, context.tabManager) - } - } - } - return nil + payload["socketExpectedPath"] = socketPath + payload["socketMode"] = config.mode.rawValue + payload["socketReady"] = isReady ? "1" : "0" + payload["socketPingResponse"] = pingResponse ?? "" + payload["socketIsRunning"] = health.isRunning ? "1" : "0" + payload["socketAcceptLoopAlive"] = health.acceptLoopAlive ? "1" : "0" + payload["socketPathMatches"] = health.socketPathMatches ? "1" : "0" + payload["socketPathExists"] = health.socketPathExists ? "1" : "0" + payload["socketFailureSignals"] = failureSignals.joined(separator: ",") } - @discardableResult - func moveSurface( - panelId: UUID, - toWorkspace targetWorkspaceId: UUID, - targetPane: PaneID? = nil, - targetIndex: Int? = nil, - splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? = nil, - focus: Bool = true, - focusWindow: Bool = true - ) -> Bool { -#if DEBUG - let moveStart = ProcessInfo.processInfo.systemUptime - let splitLabel = splitTarget.map { split in - "\(split.orientation.rawValue):\(split.insertFirst ? 1 : 0)" - } ?? "none" - func elapsedMs(since start: TimeInterval) -> String { - let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 - return String(format: "%.2f", ms) - } - 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 - guard let source = locateSurface(surfaceId: panelId) else { -#if DEBUG - dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourcePanelNotFound elapsedMs=\(elapsedMs(since: moveStart))") -#endif - return false - } - guard let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }) else { -#if DEBUG - dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourceWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))") -#endif - return false - } - guard let destinationManager = tabManagerFor(tabId: targetWorkspaceId) else { -#if DEBUG - dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationManagerMissing elapsedMs=\(elapsedMs(since: moveStart))") -#endif - return false - } - guard let destinationWorkspace = destinationManager.tabs.first(where: { $0.id == targetWorkspaceId }) else { -#if DEBUG - dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))") -#endif - return false + private func appendUITestRenderDiagnosticsIfNeeded( + _ payload: inout [String: String], + environment env: [String: String] + ) { + guard env["PROGRAMA_UI_TEST_DISPLAY_RENDER_STATS"] == "1" else { return } + + guard let renderState = currentUITestRenderDiagnostics() else { + payload["renderStatsAvailable"] = "0" + payload["renderPanelId"] = "" + payload["renderDrawCount"] = "" + payload["renderPresentCount"] = "" + payload["renderLastPresentTime"] = "" + payload["renderWindowVisible"] = "" + payload["renderAppIsActive"] = "" + payload["renderDesiredFocus"] = "" + payload["renderIsFirstResponder"] = "" + payload["renderDiagnosticsUpdatedAt"] = String(format: "%.6f", ProcessInfo.processInfo.systemUptime) + return } -#if DEBUG - 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)" - ) -#endif - let resolvedTargetPane = targetPane.flatMap { pane in - destinationWorkspace.bonsplitController.allPaneIds.first(where: { $0 == pane }) - } ?? destinationWorkspace.bonsplitController.focusedPaneId - ?? destinationWorkspace.bonsplitController.allPaneIds.first + payload["renderStatsAvailable"] = "1" + payload["renderPanelId"] = renderState.panelId.uuidString + payload["renderDrawCount"] = String(renderState.drawCount) + payload["renderPresentCount"] = String(renderState.presentCount) + payload["renderLastPresentTime"] = String(format: "%.6f", renderState.lastPresentTime) + payload["renderWindowVisible"] = renderState.windowVisible ? "1" : "0" + payload["renderAppIsActive"] = renderState.appIsActive ? "1" : "0" + payload["renderDesiredFocus"] = renderState.desiredFocus ? "1" : "0" + payload["renderIsFirstResponder"] = renderState.isFirstResponder ? "1" : "0" + payload["renderDiagnosticsUpdatedAt"] = String(format: "%.6f", ProcessInfo.processInfo.systemUptime) + } - guard let resolvedTargetPane else { -#if DEBUG - dlog( - "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=targetPaneMissing " + - "destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" - ) -#endif - return false + private func currentUITestRenderDiagnostics() -> UITestRenderDiagnosticsSnapshot? { + guard let tabManager, + let tabId = tabManager.selectedTabId, + let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else { + return nil } - 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 - dlog( - "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sameWorkspaceSplitFailed " + - "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) split=\(splitLabel) " + - "elapsedMs=\(elapsedMs(since: moveStart))" - ) -#endif - return false - } - if focus { - source.tabManager.focusTab(sourceWorkspace.id, surfaceId: panelId, suppressFlash: true) - } -#if DEBUG - dlog( - "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=sameWorkspaceSplit moved=1 " + - "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" - ) -#endif - return true + let terminalPanel: TerminalPanel? = { + if let focusedPanelId = workspace.focusedPanelId, + let terminalPanel = workspace.terminalPanel(for: focusedPanelId) { + return terminalPanel } + if let focusedTerminalPanel = workspace.focusedTerminalPanel { + return focusedTerminalPanel + } + return workspace.panels.values.compactMap { $0 as? TerminalPanel }.first + }() - let moved = sourceWorkspace.moveSurface( - panelId: panelId, - toPane: resolvedTargetPane, - atIndex: targetIndex, - focus: focus - ) -#if DEBUG - 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 - return moved - } - - let sourcePane = sourceWorkspace.paneId(forPanelId: panelId) - let sourceIndex = sourceWorkspace.indexInPane(forPanelId: panelId) -#if DEBUG - let detachStart = ProcessInfo.processInfo.systemUptime -#endif + guard let terminalPanel else { return nil } + let stats = terminalPanel.hostedView.debugRenderStats() + return UITestRenderDiagnosticsSnapshot( + panelId: terminalPanel.id, + drawCount: stats.drawCount, + presentCount: stats.presentCount, + lastPresentTime: stats.lastPresentTime, + windowVisible: stats.windowOcclusionVisible, + appIsActive: stats.appIsActive, + desiredFocus: stats.desiredFocus, + isFirstResponder: stats.isFirstResponder + ) + } - guard let detached = sourceWorkspace.detachSurface(panelId: panelId) else { -#if DEBUG - dlog( - "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=detachFailed " + - "elapsedMs=\(elapsedMs(since: moveStart))" - ) -#endif - return false + private func moveUITestWindowToTargetDisplayIfNeeded(attempt: Int = 0) { + let env = ProcessInfo.processInfo.environment + guard let rawDisplayID = env["PROGRAMA_UI_TEST_TARGET_DISPLAY_ID"], + let targetDisplayID = UInt32(rawDisplayID) else { + return } -#if DEBUG - let detachMs = elapsedMs(since: detachStart) - let attachStart = ProcessInfo.processInfo.systemUptime -#endif - guard destinationWorkspace.attachDetachedSurface( - detached, - inPane: resolvedTargetPane, - atIndex: targetIndex, - focus: focus - ) != nil else { - rollbackDetachedSurface( - detached, - to: sourceWorkspace, - sourcePane: sourcePane, - sourceIndex: sourceIndex, - focus: focus - ) -#if DEBUG - dlog( - "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=attachFailed " + - "detachMs=\(detachMs) elapsedMs=\(elapsedMs(since: moveStart))" - ) -#endif - return false + + guard let screen = NSScreen.screens.first(where: { $0.programaDisplayID == targetDisplayID }) else { + if attempt < 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1) + } + } + self.writeUITestDiagnosticsIfNeeded(stage: "targetDisplayMissing") + return } -#if DEBUG - let attachMs = elapsedMs(since: attachStart) - var splitMs = "0.00" -#endif - if let splitTarget { -#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 { - if let detachedFromDestination = destinationWorkspace.detachSurface(panelId: panelId) { - rollbackDetachedSurface( - detachedFromDestination, - to: sourceWorkspace, - sourcePane: sourcePane, - sourceIndex: sourceIndex, - focus: focus - ) + guard let window = NSApp.windows.first else { + if attempt < 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1) } -#if DEBUG - dlog( - "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=postAttachSplitFailed " + - "detachMs=\(detachMs) attachMs=\(attachMs) elapsedMs=\(elapsedMs(since: moveStart))" - ) -#endif - return false } -#if DEBUG - splitMs = elapsedMs(since: splitStart) -#endif + self.writeUITestDiagnosticsIfNeeded(stage: "targetDisplayNoWindow") + return } -#if DEBUG - let cleanupStart = ProcessInfo.processInfo.systemUptime -#endif - cleanupEmptySourceWorkspaceAfterSurfaceMove( - sourceWorkspace: sourceWorkspace, - sourceManager: source.tabManager, - sourceWindowId: source.windowId - ) -#if DEBUG - let cleanupMs = elapsedMs(since: cleanupStart) - let focusStart = ProcessInfo.processInfo.systemUptime -#endif + let visibleFrame = screen.visibleFrame + let width = min(window.frame.width, max(visibleFrame.width - 80, 480)) + let height = min(window.frame.height, max(visibleFrame.height - 80, 360)) + let frame = NSRect( + x: visibleFrame.midX - (width / 2), + y: visibleFrame.midY - (height / 2), + width: width, + height: height + ).integral - if focus { - let destinationWindowId = focusWindow ? windowId(for: destinationManager) : nil - if let destinationWindowId { - _ = focusMainWindow(windowId: destinationWindowId) - } - destinationManager.focusTab(targetWorkspaceId, surfaceId: panelId, suppressFlash: true) - if let destinationWindowId { - reassertCrossWindowSurfaceMoveFocusIfNeeded( - destinationWindowId: destinationWindowId, - sourceWindowId: source.windowId, - destinationWorkspaceId: targetWorkspaceId, - destinationPanelId: panelId, - destinationManager: destinationManager - ) + window.setFrame(frame, display: true, animate: false) + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + if window.screen?.programaDisplayID != targetDisplayID, attempt < 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1) } + return } -#if DEBUG - let focusMs = elapsedMs(since: focusStart) - 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") " + - "split=\(splitLabel) detachMs=\(detachMs) attachMs=\(attachMs) splitMs=\(splitMs) " + - "cleanupMs=\(cleanupMs) focusMs=\(focusMs) elapsedMs=\(elapsedMs(since: moveStart))" - ) + self.writeUITestDiagnosticsIfNeeded(stage: "afterMoveToTargetDisplay") + } #endif - return true + func applicationDidBecomeActive(_ notification: Notification) { + guard let notificationStore else { return } + notificationStore.handleApplicationDidBecomeActive() + guard let tabManager else { return } + guard let tabId = tabManager.selectedTabId else { return } + let surfaceId = tabManager.focusedSurfaceId(for: tabId) + guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return } + + if let surfaceId, + let tab = tabManager.tabs.first(where: { $0.id == tabId }) { + tab.triggerNotificationFocusFlash(panelId: surfaceId, requiresSplit: false, shouldFocus: false) + } + notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) } - @discardableResult - func moveBonsplitTab( - tabId: UUID, - toWorkspace targetWorkspaceId: UUID, - targetPane: PaneID? = nil, - targetIndex: Int? = nil, - splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? = nil, - focus: Bool = true, - focusWindow: Bool = true - ) -> Bool { -#if DEBUG - let moveStart = ProcessInfo.processInfo.systemUptime - func elapsedMs(since start: TimeInterval) -> String { - let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 - return String(format: "%.2f", ms) + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + isTerminatingApp = true + _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + + // Tagged DEV builds are ephemeral, skip quit confirmation entirely. + if SocketControlSettings.isTaggedDevBuild() { + return .terminateNow } - dlog( - "surface.moveBonsplit.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 { -#if DEBUG - dlog( - "surface.moveBonsplit.fail tab=\(tabId.uuidString.prefix(5)) reason=tabNotFound " + - "targetWs=\(targetWorkspaceId.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" - ) -#endif - return false + + // If the user already confirmed via the Cmd+Q shortcut warning dialog + // (handleQuitShortcutWarning), skip the check to avoid a second alert. + if isQuitWarningConfirmed { + return .terminateNow } -#if DEBUG - dlog( - "surface.moveBonsplit.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 - let moved = moveSurface( - panelId: located.panelId, - toWorkspace: targetWorkspaceId, - targetPane: targetPane, - targetIndex: targetIndex, - splitTarget: splitTarget, - focus: focus, - focusWindow: focusWindow - ) -#if DEBUG - dlog( - "surface.moveBonsplit.end tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " + - "moved=\(moved ? 1 : 0) elapsedMs=\(elapsedMs(since: moveStart))" - ) -#endif - return moved + + // Respect the "Warn Before Quit" setting even when Cmd+Q arrives via + // the Cmd+Tab app switcher, bypassing handleCustomShortcut. + guard QuitWarningSettings.isEnabled() else { + return .terminateNow + } + + // Show the same confirmation dialog used by the Cmd+Q shortcut path, + // then reply asynchronously so we can return .terminateLater now. + DispatchQueue.main.async { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(localized: "dialog.quitPrograma.title", defaultValue: "Quit Programa?") + alert.informativeText = String(localized: "dialog.quitPrograma.message", defaultValue: "This will close all windows and workspaces.") + alert.addButton(withTitle: String(localized: "dialog.quitPrograma.quit", defaultValue: "Quit")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.showsSuppressionButton = true + alert.suppressionButton?.title = String(localized: "dialog.dontWarnCmdQ", defaultValue: "Don't warn again for Cmd+Q") + + let response = alert.runModal() + if alert.suppressionButton?.state == .on { + QuitWarningSettings.setEnabled(false) + } + + let shouldQuit = response == .alertFirstButtonReturn + if shouldQuit { + self.isQuitWarningConfirmed = true + } else { + // Reset so that the next quit attempt can show the dialog again. + self.isTerminatingApp = false + } + NSApp.reply(toApplicationShouldTerminate: shouldQuit) + } + return .terminateLater } - func tabManagerFor(windowId: UUID) -> TabManager? { - mainWindowContexts.values.first(where: { $0.windowId == windowId })?.tabManager + func applicationWillTerminate(_ notification: Notification) { + isTerminatingApp = true + _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + stopSessionAutosaveTimer() + TerminalController.shared.stop() + VSCodeServeWebController.shared.stop() + BrowserProfileStore.shared.flushPendingSaves() + notificationStore?.clearAll() + enableSuddenTerminationIfNeeded() } - func windowId(for tabManager: TabManager) -> UUID? { - mainWindowContexts.values.first(where: { $0.tabManager === tabManager })?.windowId + func applicationWillResignActive(_ notification: Notification) { + guard !isTerminatingApp else { return } + clearConfiguredShortcutChordState() + _ = saveSessionSnapshot(includeScrollback: false) } - func mainWindow(for windowId: UUID) -> NSWindow? { - windowForMainWindowId(windowId) + func persistSessionForUpdateRelaunch() { + isTerminatingApp = true + _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) } - func mainWindowContainingWorkspace(_ workspaceId: UUID) -> NSWindow? { - for context in mainWindowContexts.values where context.tabManager.tabs.contains(where: { $0.id == workspaceId }) { - if let window = context.window ?? windowForMainWindowId(context.windowId) { - return window + func configure(tabManager: TabManager, notificationStore: TerminalNotificationStore, sidebarState: SidebarState) { + self.tabManager = tabManager + self.notificationStore = notificationStore + self.sidebarState = sidebarState + disableSuddenTerminationIfNeeded() + installLifecycleSnapshotObserversIfNeeded() + prepareStartupSessionSnapshotIfNeeded() + startSessionAutosaveTimerIfNeeded() +#if DEBUG + setupJumpUnreadUITestIfNeeded() + setupTerminalCmdClickUITestIfNeeded() + setupGotoSplitUITestIfNeeded() + setupBonsplitTabDragUITestIfNeeded() + 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) { + 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() } } - return nil +#endif } - func scriptableMainWindows() -> [ScriptableMainWindowState] { - var results: [ScriptableMainWindowState] = [] - var seen: Set = [] - for window in NSApp.orderedWindows { - guard let context = contextForMainTerminalWindow(window, reindex: false) else { continue } - guard seen.insert(context.windowId).inserted else { continue } - results.append( - ScriptableMainWindowState( - windowId: context.windowId, - tabManager: context.tabManager, - window: context.window ?? windowForMainWindowId(context.windowId) - ) - ) - } + private func prepareStartupSessionSnapshotIfNeeded() { + guard !didPrepareStartupSessionSnapshot else { return } + didPrepareStartupSessionSnapshot = true + guard SessionRestorePolicy.shouldAttemptRestore() else { return } + Self.removeLegacyPersistedWindowGeometry() + startupSessionSnapshot = SessionPersistenceStore.load() + } - let remaining = mainWindowContexts.values - .sorted { $0.windowId.uuidString < $1.windowId.uuidString } - .filter { seen.insert($0.windowId).inserted } + private func persistedWindowGeometry( + defaults: UserDefaults = .standard + ) -> PersistedWindowGeometry? { + Self.removeLegacyPersistedWindowGeometry(defaults: defaults) + guard let data = defaults.data(forKey: Self.persistedWindowGeometryDefaultsKey) else { + return nil + } + guard let payload = Self.decodedPersistedWindowGeometryData(data) else { + defaults.removeObject(forKey: Self.persistedWindowGeometryDefaultsKey) + return nil + } + return payload + } - for context in remaining { - results.append( - ScriptableMainWindowState( - windowId: context.windowId, - tabManager: context.tabManager, - window: context.window ?? windowForMainWindowId(context.windowId) - ) - ) + private func persistWindowGeometry( + frame: SessionRectSnapshot?, + display: SessionDisplaySnapshot?, + defaults: UserDefaults = .standard + ) { + Self.removeLegacyPersistedWindowGeometry(defaults: defaults) + guard let data = Self.encodedPersistedWindowGeometryData(frame: frame, display: display) else { + return } + defaults.set(data, forKey: Self.persistedWindowGeometryDefaultsKey) + } - return results + private nonisolated static func encodedPersistedWindowGeometryData( + frame: SessionRectSnapshot?, + display: SessionDisplaySnapshot? + ) -> Data? { + guard let frame else { return nil } + let payload = PersistedWindowGeometry( + version: persistedWindowGeometrySchemaVersion, + frame: frame, + display: display + ) + return try? JSONEncoder().encode(payload) } - func scriptableMainWindow(windowId: UUID) -> ScriptableMainWindowState? { - guard let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) else { + nonisolated static func decodedPersistedWindowGeometryData(_ data: Data) -> PersistedWindowGeometry? { + guard let payload = try? JSONDecoder().decode(PersistedWindowGeometry.self, from: data), + payload.version == persistedWindowGeometrySchemaVersion else { return nil } - return ScriptableMainWindowState( - windowId: context.windowId, - tabManager: context.tabManager, - window: context.window ?? windowForMainWindowId(context.windowId) - ) + return payload } - func scriptableMainWindowForTab(_ tabId: UUID) -> ScriptableMainWindowState? { - guard let context = contextContainingTabId(tabId) else { return nil } - return ScriptableMainWindowState( - windowId: context.windowId, - tabManager: context.tabManager, - window: context.window ?? windowForMainWindowId(context.windowId) + private nonisolated static func removeLegacyPersistedWindowGeometry( + defaults: UserDefaults = .standard + ) { + legacyPersistedWindowGeometryDefaultsKeys.forEach { defaults.removeObject(forKey: $0) } + } + + private func persistWindowGeometry(from window: NSWindow?) { + guard let window else { return } + persistWindowGeometry( + frame: SessionRectSnapshot(window.frame), + display: displaySnapshot(for: window) ) } - @discardableResult - func focusScriptableMainWindow(windowId: UUID, bringToFront shouldBringToFront: Bool) -> Bool { - guard let state = scriptableMainWindow(windowId: windowId), - let window = state.window else { - return false + private func currentDisplayGeometries() -> ( + available: [SessionDisplayGeometry], + fallback: SessionDisplayGeometry? + ) { + let available = NSScreen.screens.map { screen in + SessionDisplayGeometry( + displayID: screen.programaDisplayID, + frame: screen.frame, + visibleFrame: screen.visibleFrame + ) } - setActiveMainWindow(window) - if shouldBringToFront { - bringToFront(window) + let fallback = (NSScreen.main ?? NSScreen.screens.first).map { screen in + SessionDisplayGeometry( + displayID: screen.programaDisplayID, + frame: screen.frame, + visibleFrame: screen.visibleFrame + ) } - return true + return (available, fallback) } - @discardableResult - func addWorkspace(windowId: UUID, workingDirectory: String? = nil, bringToFront shouldBringToFront: Bool = false) -> UUID? { - guard let state = scriptableMainWindow(windowId: windowId) else { return nil } - if shouldBringToFront, let window = state.window { - setActiveMainWindow(window) - bringToFront(window) + private func attemptStartupSessionRestoreIfNeeded(primaryWindow: NSWindow) { + guard !didAttemptStartupSessionRestore else { return } + didAttemptStartupSessionRestore = true + guard !didHandleExplicitOpenIntentAtStartup else { return } + guard let primaryContext = contextForMainTerminalWindow(primaryWindow) else { return } + + let startupSnapshot = startupSessionSnapshot + let primaryWindowSnapshot = startupSnapshot?.windows.first + if let primaryWindowSnapshot { + isApplyingStartupSessionRestore = true +#if DEBUG + dlog( + "session.restore.start windows=\(startupSnapshot?.windows.count ?? 0) " + + "primaryFrame={\(debugSessionRectDescription(primaryWindowSnapshot.frame))} " + + "primaryDisplay={\(debugSessionDisplayDescription(primaryWindowSnapshot.display))}" + ) +#endif + applySessionWindowSnapshot( + primaryWindowSnapshot, + to: primaryContext, + window: primaryWindow + ) + } else { + let displays = currentDisplayGeometries() + let fallbackGeometry = persistedWindowGeometry() + if let restoredFrame = Self.resolvedStartupPrimaryWindowFrame( + primarySnapshot: nil, + fallbackFrame: fallbackGeometry?.frame, + fallbackDisplaySnapshot: fallbackGeometry?.display, + availableDisplays: displays.available, + fallbackDisplay: displays.fallback + ) { + primaryWindow.setFrame(restoredFrame, display: true) + } + } + + if let startupSnapshot { + let additionalWindows = Array(startupSnapshot + .windows + .dropFirst() + .prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1))) +#if DEBUG + for (index, windowSnapshot) in additionalWindows.enumerated() { + dlog( + "session.restore.enqueueAdditional idx=\(index + 1) " + + "frame={\(debugSessionRectDescription(windowSnapshot.frame))} " + + "display={\(debugSessionDisplayDescription(windowSnapshot.display))}" + ) + } +#endif + if !additionalWindows.isEmpty { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + for windowSnapshot in additionalWindows { + _ = self.createMainWindow(sessionWindowSnapshot: windowSnapshot) + } + self.completeStartupSessionRestore() + } + } else { + completeStartupSessionRestore() + } } - let workspace = state.tabManager.addWorkspace( - workingDirectory: workingDirectory, - select: shouldBringToFront - ) - return workspace.id } - private func markCommandPaletteOpenRequested(for window: NSWindow?) { - guard let window, - let windowId = mainWindowId(for: window) else { return } - commandPalettePendingOpenByWindowId[windowId] = true - commandPaletteRecentRequestAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime + private func completeStartupSessionRestore() { + startupSessionSnapshot = nil + isApplyingStartupSessionRestore = false + _ = saveSessionSnapshot(includeScrollback: false) } - private func postCommandPaletteRequest( - name: Notification.Name, - preferredWindow: NSWindow?, - source: String, - markPending: Bool + private func applySessionWindowSnapshot( + _ snapshot: SessionWindowSnapshot, + to context: MainWindowContext, + window: NSWindow? ) { - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow - if markPending { - markCommandPaletteOpenRequested(for: targetWindow) - } - NotificationCenter.default.post(name: name, object: targetWindow) #if DEBUG dlog( - "shortcut.palette.request source=\(source) " + - "target={\(debugWindowToken(targetWindow))} " + - "pendingMarked=\(markPending ? 1 : 0)" + "session.restore.apply window=\(context.windowId.uuidString.prefix(8)) " + + "liveWin=\(window?.windowNumber ?? -1) " + + "snapshotFrame={\(debugSessionRectDescription(snapshot.frame))} " + + "snapshotDisplay={\(debugSessionDisplayDescription(snapshot.display))}" ) #endif - } - - func requestCommandPaletteCommands(preferredWindow: NSWindow? = nil, source: String = "api.commandPalette") { - postCommandPaletteRequest( - name: .commandPaletteRequested, - preferredWindow: preferredWindow, - source: source, - markPending: true + context.tabManager.restoreSessionSnapshot(snapshot.tabManager) + context.sidebarState.isVisible = snapshot.sidebar.isVisible + context.sidebarState.persistedWidth = CGFloat( + SessionPersistencePolicy.sanitizedSidebarWidth(snapshot.sidebar.width) ) - } + context.sidebarSelectionState.selection = snapshot.sidebar.selection.sidebarSelection - func requestCommandPaletteSwitcher(preferredWindow: NSWindow? = nil, source: String = "api.commandPaletteSwitcher") { - postCommandPaletteRequest( - name: .commandPaletteSwitcherRequested, - preferredWindow: preferredWindow, - source: source, - markPending: true - ) + if let restoredFrame = resolvedWindowFrame(from: snapshot), let window { + window.setFrame(restoredFrame, display: true) +#if DEBUG + dlog( + "session.restore.frameApplied window=\(context.windowId.uuidString.prefix(8)) " + + "applied={\(debugNSRectDescription(window.frame))}" + ) +#endif + } } - func requestCommandPaletteRenameTab(preferredWindow: NSWindow? = nil, source: String = "api.commandPaletteRenameTab") { - postCommandPaletteRequest( - name: .commandPaletteRenameTabRequested, - preferredWindow: preferredWindow, - source: source, - markPending: true + private func resolvedWindowFrame(from snapshot: SessionWindowSnapshot?) -> NSRect? { + let displays = currentDisplayGeometries() + return Self.resolvedWindowFrame( + from: snapshot?.frame, + display: snapshot?.display, + availableDisplays: displays.available, + fallbackDisplay: displays.fallback ) } - func requestCommandPaletteRenameWorkspace( - preferredWindow: NSWindow? = nil, - source: String = "api.commandPaletteRenameWorkspace" - ) { - postCommandPaletteRequest( - name: .commandPaletteRenameWorkspaceRequested, - preferredWindow: preferredWindow, - source: source, - markPending: true + nonisolated static func resolvedStartupPrimaryWindowFrame( + primarySnapshot: SessionWindowSnapshot?, + fallbackFrame: SessionRectSnapshot?, + fallbackDisplaySnapshot: SessionDisplaySnapshot?, + availableDisplays: [SessionDisplayGeometry], + fallbackDisplay: SessionDisplayGeometry? + ) -> CGRect? { + if let primary = resolvedWindowFrame( + from: primarySnapshot?.frame, + display: primarySnapshot?.display, + availableDisplays: availableDisplays, + fallbackDisplay: fallbackDisplay + ) { + return primary + } + + return resolvedWindowFrame( + from: fallbackFrame, + display: fallbackDisplaySnapshot, + availableDisplays: availableDisplays, + fallbackDisplay: fallbackDisplay ) } - func requestCommandPaletteEditWorkspaceDescription( - preferredWindow: NSWindow? = nil, - source: String = "api.commandPaletteEditWorkspaceDescription" - ) { - postCommandPaletteRequest( - name: .commandPaletteEditWorkspaceDescriptionRequested, - preferredWindow: preferredWindow, - source: source, - markPending: true + nonisolated static func resolvedWindowFrame( + from frameSnapshot: SessionRectSnapshot?, + display displaySnapshot: SessionDisplaySnapshot?, + availableDisplays: [SessionDisplayGeometry], + fallbackDisplay: SessionDisplayGeometry? + ) -> CGRect? { + guard let frameSnapshot else { return nil } + let frame = frameSnapshot.cgRect + guard frame.width.isFinite, + frame.height.isFinite, + frame.origin.x.isFinite, + frame.origin.y.isFinite else { + return nil + } + + let minWidth = CGFloat(SessionPersistencePolicy.minimumWindowWidth) + let minHeight = CGFloat(SessionPersistencePolicy.minimumWindowHeight) + guard frame.width >= minWidth, + frame.height >= minHeight else { + return nil + } + + guard !availableDisplays.isEmpty else { return frame } + + if let targetDisplay = display(for: displaySnapshot, in: availableDisplays) { + if shouldPreserveExactFrame( + frame: frame, + displaySnapshot: displaySnapshot, + targetDisplay: targetDisplay + ) { + return frame + } + return resolvedWindowFrame( + frame: frame, + displaySnapshot: displaySnapshot, + targetDisplay: targetDisplay, + minWidth: minWidth, + minHeight: minHeight + ) + } + + if let intersectingDisplay = availableDisplays.first(where: { $0.visibleFrame.intersects(frame) }) { + return clampFrame( + frame, + within: intersectingDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + guard let fallbackDisplay else { return frame } + if let sourceReference = displaySnapshot?.visibleFrame?.cgRect ?? displaySnapshot?.frame?.cgRect { + return remappedFrame( + frame, + from: sourceReference, + to: fallbackDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + return centeredFrame( + frame, + in: fallbackDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight ) } - private func clearCommandPalettePendingOpen(for window: NSWindow?) { - guard let window, - let windowId = mainWindowId(for: window) else { return } - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) - } - - private func pruneExpiredCommandPalettePendingOpenStates( - now: TimeInterval = ProcessInfo.processInfo.systemUptime - ) { - for windowId in Array(commandPalettePendingOpenByWindowId.keys) { - guard commandPalettePendingOpenByWindowId[windowId] == true else { continue } - guard let requestedAt = commandPaletteRecentRequestAtByWindowId[windowId] else { - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) -#if DEBUG - dlog("shortcut.palette.pendingPrune windowId=\(windowId.uuidString.prefix(8)) reason=missingTimestamp") -#endif - continue + private nonisolated static func resolvedWindowFrame( + frame: CGRect, + displaySnapshot: SessionDisplaySnapshot?, + targetDisplay: SessionDisplayGeometry, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + if targetDisplay.visibleFrame.intersects(frame) { + // Preserve the user's exact frame when enough of the top of the window + // remains reachable on-screen; only clamp when the saved frame would + // reopen with an inaccessible titlebar/top strip. + if shouldPreserveAccessibleFrame( + frame: frame, + targetDisplay: targetDisplay + ) { + return frame } - let age = now - requestedAt - guard age > Self.commandPalettePendingOpenMaxAge else { continue } - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) -#if DEBUG - dlog( - "shortcut.palette.pendingPrune windowId=\(windowId.uuidString.prefix(8)) " + - "reason=stale ageMs=\(Int(age * 1000))" + return clampFrame( + frame, + within: targetDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight ) -#endif } - } - private func isCommandPalettePendingOpen(for window: NSWindow) -> Bool { - guard let windowId = mainWindowId(for: window) else { return false } - pruneExpiredCommandPalettePendingOpenStates() - return commandPalettePendingOpenByWindowId[windowId] == true - } - - private func beginCommandPaletteEscapeSuppression(for window: NSWindow?) { - guard let window, - let windowId = mainWindowId(for: window) else { return } - commandPaletteEscapeSuppressionByWindowId.insert(windowId) - commandPaletteEscapeSuppressionStartedAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime - } + if let sourceReference = displaySnapshot?.visibleFrame?.cgRect ?? displaySnapshot?.frame?.cgRect { + return remappedFrame( + frame, + from: sourceReference, + to: targetDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } - private func endCommandPaletteEscapeSuppression(for window: NSWindow?) { - guard let window, - let windowId = mainWindowId(for: window) else { return } - commandPaletteEscapeSuppressionByWindowId.remove(windowId) - commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: windowId) + return centeredFrame( + frame, + in: targetDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) } - private func shouldConsumeSuppressedEscape(event: NSEvent, window: NSWindow?) -> Bool { - guard let window, - let windowId = mainWindowId(for: window), - commandPaletteEscapeSuppressionByWindowId.contains(windowId) else { + private nonisolated static func shouldPreserveAccessibleFrame( + frame: CGRect, + targetDisplay: SessionDisplayGeometry, + minimumVisibleTopStripWidth: CGFloat = 120, + topStripHeight: CGFloat = 64, + minimumVisibleTopStripHeight: CGFloat = 24 + ) -> Bool { + let standardizedFrame = frame.standardized + guard standardizedFrame.width.isFinite, + standardizedFrame.height.isFinite, + standardizedFrame.width > 0, + standardizedFrame.height > 0, + standardizedFrame.intersects(targetDisplay.frame) else { return false } - let startedAt = commandPaletteEscapeSuppressionStartedAtByWindowId[windowId] ?? 0 - if ProcessInfo.processInfo.systemUptime - startedAt <= 0.35 { - return true - } - // Fallback cleanup when keyUp is lost for any reason. - endCommandPaletteEscapeSuppression(for: window) - return false + + let stripHeight = min(topStripHeight, standardizedFrame.height) + let topStrip = CGRect( + x: standardizedFrame.minX, + y: standardizedFrame.maxY - stripHeight, + width: standardizedFrame.width, + height: stripHeight + ) + let visibleTopStrip = topStrip.intersection(targetDisplay.visibleFrame) + guard !visibleTopStrip.isNull else { return false } + + let requiredWidth = min(minimumVisibleTopStripWidth, standardizedFrame.width) + let requiredHeight = min(minimumVisibleTopStripHeight, stripHeight) + return visibleTopStrip.width >= requiredWidth + && visibleTopStrip.height >= requiredHeight } - private func recentCommandPaletteRequestAge(for window: NSWindow?) -> TimeInterval? { - guard let window, - let windowId = mainWindowId(for: window) else { - return nil + private nonisolated static func display( + for snapshot: SessionDisplaySnapshot?, + in displays: [SessionDisplayGeometry] + ) -> SessionDisplayGeometry? { + guard let snapshot else { return nil } + if let displayID = snapshot.displayID, + let exact = displays.first(where: { $0.displayID == displayID }) { + return exact } - let now = ProcessInfo.processInfo.systemUptime - pruneExpiredCommandPalettePendingOpenStates(now: now) - guard commandPalettePendingOpenByWindowId[windowId] == true else { - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) + + guard let referenceRect = (snapshot.visibleFrame ?? snapshot.frame)?.cgRect else { return nil } - guard let startedAt = commandPaletteRecentRequestAtByWindowId[windowId] else { - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) - return nil + + let overlaps = displays.map { display -> (display: SessionDisplayGeometry, area: CGFloat) in + (display, intersectionArea(referenceRect, display.visibleFrame)) } - let age = now - startedAt - if age <= Self.commandPaletteRequestGraceInterval { - return age + if let bestOverlap = overlaps.max(by: { $0.area < $1.area }), bestOverlap.area > 0 { + return bestOverlap.display } - return nil - } - private func escapeSuppressionWindow(for event: NSEvent) -> NSWindow? { - commandPaletteWindowForShortcutEvent(event) ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + let referenceCenter = CGPoint(x: referenceRect.midX, y: referenceRect.midY) + return displays.min { lhs, rhs in + let lhsDistance = distanceSquared(lhs.visibleFrame, referenceCenter) + let rhsDistance = distanceSquared(rhs.visibleFrame, referenceCenter) + return lhsDistance < rhsDistance + } } - @discardableResult - private func clearEscapeSuppressionForKeyUp(event: NSEvent, consumeIfSuppressed: Bool = false) -> Bool { - guard event.type == .keyUp, event.keyCode == 53 else { return false } - let suppressionWindow = escapeSuppressionWindow(for: event) - let didConsume = consumeIfSuppressed && shouldConsumeSuppressedEscape(event: event, window: suppressionWindow) - if let window = suppressionWindow { - endCommandPaletteEscapeSuppression(for: window) -#if DEBUG - dlog( - "shortcut.escape suppressionClear target={\(debugWindowToken(window))} " + - "keyUpConsumed=\(didConsume ? 1 : 0)" - ) -#endif - return didConsume + private nonisolated static func remappedFrame( + _ frame: CGRect, + from sourceRect: CGRect, + to targetRect: CGRect, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + let source = sourceRect.standardized + let target = targetRect.standardized + guard source.width.isFinite, + source.height.isFinite, + source.width > 1, + source.height > 1, + target.width.isFinite, + target.height.isFinite, + target.width > 0, + target.height > 0 else { + return centeredFrame(frame, in: targetRect, minWidth: minWidth, minHeight: minHeight) } - commandPaletteEscapeSuppressionByWindowId.removeAll() - commandPaletteEscapeSuppressionStartedAtByWindowId.removeAll() -#if DEBUG - dlog("shortcut.escape suppressionClear target={nil} clearedAll=1 keyUpConsumed=\(didConsume ? 1 : 0)") -#endif - return didConsume + + let relativeX = (frame.minX - source.minX) / source.width + let relativeY = (frame.minY - source.minY) / source.height + let relativeWidth = frame.width / source.width + let relativeHeight = frame.height / source.height + + let remapped = CGRect( + x: target.minX + (relativeX * target.width), + y: target.minY + (relativeY * target.height), + width: target.width * relativeWidth, + height: target.height * relativeHeight + ) + return clampFrame(remapped, within: target, minWidth: minWidth, minHeight: minHeight) } - func setCommandPaletteVisible(_ visible: Bool, for window: NSWindow) { - guard let windowId = mainWindowId(for: window) else { return } - let wasVisible = commandPaletteVisibilityByWindowId[windowId] ?? false - commandPaletteVisibilityByWindowId[windowId] = visible - // Opening (false -> true) always resolves pending-open. - // Closing (true -> false) also clears stale pending state. - // Ignore repeated false updates so a stale sync cannot erase an in-flight open request. - if visible || wasVisible { - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) - } -#if DEBUG - if !visible, - !wasVisible, - commandPalettePendingOpenByWindowId[windowId] == true { - dlog( - "palette.visibility.retainPending " + - "window={\(debugWindowToken(window))} visible=0 wasVisible=0 pending=1" - ) - } -#endif + private nonisolated static func centeredFrame( + _ frame: CGRect, + in visibleFrame: CGRect, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + let centered = CGRect( + x: visibleFrame.midX - (frame.width / 2), + y: visibleFrame.midY - (frame.height / 2), + width: frame.width, + height: frame.height + ) + return clampFrame(centered, within: visibleFrame, minWidth: minWidth, minHeight: minHeight) } - func isCommandPaletteVisible(windowId: UUID) -> Bool { - commandPaletteVisibilityByWindowId[windowId] ?? false - } + private nonisolated static func clampFrame( + _ frame: CGRect, + within visibleFrame: CGRect, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + guard visibleFrame.width.isFinite, + visibleFrame.height.isFinite, + visibleFrame.width > 0, + visibleFrame.height > 0 else { + return frame + } + + let maxWidth = max(visibleFrame.width, 1) + let maxHeight = max(visibleFrame.height, 1) + let widthFloor = min(minWidth, maxWidth) + let heightFloor = min(minHeight, maxHeight) + + let width = min(max(frame.width, widthFloor), maxWidth) + let height = min(max(frame.height, heightFloor), maxHeight) + let maxX = visibleFrame.maxX - width + let maxY = visibleFrame.maxY - height + let x = min(max(frame.minX, visibleFrame.minX), maxX) + let y = min(max(frame.minY, visibleFrame.minY), maxY) - func setCommandPaletteSelectionIndex(_ index: Int, for window: NSWindow) { - guard let windowId = mainWindowId(for: window) else { return } - commandPaletteSelectionByWindowId[windowId] = max(0, index) + return CGRect(x: x, y: y, width: width, height: height) } - func commandPaletteSelectionIndex(windowId: UUID) -> Int { - commandPaletteSelectionByWindowId[windowId] ?? 0 + private nonisolated static func intersectionArea(_ lhs: CGRect, _ rhs: CGRect) -> CGFloat { + let intersection = lhs.intersection(rhs) + guard !intersection.isNull else { return 0 } + return max(0, intersection.width) * max(0, intersection.height) } - func setCommandPaletteSnapshot(_ snapshot: CommandPaletteDebugSnapshot, for window: NSWindow) { - guard let windowId = mainWindowId(for: window) else { return } - commandPaletteSnapshotByWindowId[windowId] = snapshot + private nonisolated static func distanceSquared(_ rect: CGRect, _ point: CGPoint) -> CGFloat { + let dx = rect.midX - point.x + let dy = rect.midY - point.y + return (dx * dx) + (dy * dy) } - func commandPaletteSnapshot(windowId: UUID) -> CommandPaletteDebugSnapshot { - commandPaletteSnapshotByWindowId[windowId] ?? .empty - } + private nonisolated static func shouldPreserveExactFrame( + frame: CGRect, + displaySnapshot: SessionDisplaySnapshot?, + targetDisplay: SessionDisplayGeometry + ) -> Bool { + guard let displaySnapshot else { return false } + guard let snapshotDisplayID = displaySnapshot.displayID, + let targetDisplayID = targetDisplay.displayID, + snapshotDisplayID == targetDisplayID else { + return false + } - func isCommandPaletteVisible(for window: NSWindow) -> Bool { - guard let windowId = mainWindowId(for: window) else { return false } - return commandPaletteVisibilityByWindowId[windowId] ?? false - } + let visibleMatches = displaySnapshot.visibleFrame.map { + rectApproximatelyEqual($0.cgRect, targetDisplay.visibleFrame) + } ?? false + let frameMatches = displaySnapshot.frame.map { + rectApproximatelyEqual($0.cgRect, targetDisplay.frame) + } ?? false + guard visibleMatches || frameMatches else { return false } - func isCommandPaletteEffectivelyVisible(for window: NSWindow) -> Bool { - isCommandPaletteEffectivelyVisible(in: window) + return frame.width.isFinite + && frame.height.isFinite + && frame.origin.x.isFinite + && frame.origin.y.isFinite } - func shouldBlockFirstResponderChangeWhileCommandPaletteVisible( - window: NSWindow, - responder: NSResponder? + private nonisolated static func rectApproximatelyEqual( + _ lhs: CGRect, + _ rhs: CGRect, + tolerance: CGFloat = 1 ) -> Bool { - guard isCommandPaletteVisible(for: window) else { return false } - guard let responder else { return false } - guard !isCommandPaletteResponder(responder) else { return false } - return isFocusStealingResponderWhileCommandPaletteVisible(responder) + let lhsStd = lhs.standardized + let rhsStd = rhs.standardized + return abs(lhsStd.origin.x - rhsStd.origin.x) <= tolerance + && abs(lhsStd.origin.y - rhsStd.origin.y) <= tolerance + && abs(lhsStd.size.width - rhsStd.size.width) <= tolerance + && abs(lhsStd.size.height - rhsStd.size.height) <= tolerance } - private func isCommandPaletteResponder(_ responder: NSResponder) -> Bool { - if let textView = responder as? NSTextView, textView.isFieldEditor { - if let delegateView = textView.delegate as? NSView { - return isInsideCommandPaletteOverlay(delegateView) - } - // SwiftUI can attach a non-view delegate to TextField editors. - // When command palette is visible, its search/rename editor is the - // only expected field editor inside the main window. - return true - } - if let view = responder as? NSView { - return isInsideCommandPaletteOverlay(view) - } - return false - } + private func displaySnapshot(for window: NSWindow?) -> SessionDisplaySnapshot? { + guard let window else { return nil } + let screen = window.screen + ?? NSScreen.screens.first(where: { $0.frame.intersects(window.frame) }) + guard let screen else { return nil } - private func isFocusStealingResponderWhileCommandPaletteVisible(_ responder: NSResponder) -> Bool { - isCommandPaletteFocusStealingTerminalOrBrowserResponder(responder) + return SessionDisplaySnapshot( + displayID: screen.programaDisplayID, + frame: SessionRectSnapshot(screen.frame), + visibleFrame: SessionRectSnapshot(screen.visibleFrame) + ) } - private func isInsideCommandPaletteOverlay(_ view: NSView) -> Bool { - var current: NSView? = view - while let candidate = current { - if candidate.identifier == commandPaletteOverlayContainerIdentifier { - return true + private func startSessionAutosaveTimerIfNeeded() { + guard sessionAutosaveTimer == nil else { return } + let env = ProcessInfo.processInfo.environment + guard !isRunningUnderXCTest(env) else { return } + + let timer = DispatchSource.makeTimerSource(queue: .main) + let interval = SessionPersistencePolicy.autosaveInterval + timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1)) + timer.setEventHandler { [weak self] in + guard let self, + Self.shouldRunSessionAutosaveTick(isTerminatingApp: self.isTerminatingApp) else { + return } - current = candidate.superview + self.runSessionAutosaveTick(source: "timer") } - return false + sessionAutosaveTimer = timer + timer.resume() } - private func keyRoutingOwnerView(for responder: NSResponder?) -> NSView? { - guard let responder else { return nil } - if let editor = responder as? NSTextView, - editor.isFieldEditor { - return programaFieldEditorOwnerView(editor) ?? editor - } - return responder as? NSView + private func stopSessionAutosaveTimer() { + sessionAutosaveTimer?.cancel() + sessionAutosaveTimer = nil + sessionAutosaveTickInFlight = false + sessionAutosaveDeferredRetryPending = false } - private func responderHasViableKeyRoutingOwner( - _ responder: NSResponder, - in window: NSWindow - ) -> Bool { - if let ghosttyView = cmuxOwningGhosttyView(for: responder) { - if ghosttyView.window !== window { - return false - } - if ghosttyView.isHiddenOrHasHiddenAncestor { - return false + private func installLifecycleSnapshotObserversIfNeeded() { + guard !didInstallLifecycleSnapshotObservers else { return } + didInstallLifecycleSnapshotObservers = true + + let workspaceCenter = NSWorkspace.shared.notificationCenter + let powerOffObserver = workspaceCenter.addObserver( + forName: NSWorkspace.willPowerOffNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + self.isTerminatingApp = true + _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) } - return ghosttyView === window.contentView || ghosttyView.superview != nil } + lifecycleSnapshotObservers.append(powerOffObserver) - guard let ownerView = keyRoutingOwnerView(for: responder) else { - return false + let sessionResignObserver = workspaceCenter.addObserver( + forName: NSWorkspace.sessionDidResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + if self.isTerminatingApp { + _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + } else { + _ = self.saveSessionSnapshot(includeScrollback: false) + } + } } + lifecycleSnapshotObservers.append(sessionResignObserver) - if ownerView.window !== window { - return false + let didWakeObserver = workspaceCenter.addObserver( + forName: NSWorkspace.didWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.restartSocketListenerIfEnabled(source: "workspace.didWake") + } } + lifecycleSnapshotObservers.append(didWakeObserver) + } - if ownerView.isHiddenOrHasHiddenAncestor { - return false - } + func socketListenerConfigurationIfEnabled() -> (mode: SocketControlMode, path: String)? { + let raw = UserDefaults.standard.string(forKey: SocketControlSettings.appStorageKey) + ?? SocketControlSettings.defaultMode.rawValue + let userMode = SocketControlSettings.migrateMode(raw) + let mode = SocketControlSettings.effectiveMode(userMode: userMode) + guard mode != .off else { return nil } + return (mode: mode, path: SocketControlSettings.socketPath()) + } - if ownerView !== window.contentView, ownerView.superview == nil { - return false - } + func restartSocketListenerIfEnabled(source: String) { + guard let tabManager, + let config = socketListenerConfigurationIfEnabled() else { return } + let restartPath = TerminalController.shared.activeSocketPath(preferredPath: config.path) + TerminalController.shared.stop() + TerminalController.shared.start(tabManager: tabManager, socketPath: restartPath, accessMode: config.mode) + } - return true + private func disableSuddenTerminationIfNeeded() { + guard !didDisableSuddenTermination else { return } + ProcessInfo.processInfo.disableSuddenTermination() + didDisableSuddenTermination = true } - private func responderNeedsFocusedTerminalKeyRepair( - _ responder: NSResponder?, - in window: NSWindow, - hostedView: GhosttySurfaceScrollView - ) -> Bool { - guard let responder else { return true } - if responder is NSWindow { return true } - guard responderHasViableKeyRoutingOwner(responder, in: window) else { - return true + private func enableSuddenTerminationIfNeeded() { + guard didDisableSuddenTermination else { return } + ProcessInfo.processInfo.enableSuddenTermination() + didDisableSuddenTermination = false + } + + private func sessionAutosaveFingerprint(includeScrollback: Bool) -> Int? { + guard !includeScrollback else { return nil } + + var hasher = Hasher() + let contexts = mainWindowContexts.values.sorted { lhs, rhs in + lhs.windowId.uuidString < rhs.windowId.uuidString } - if hostedView.responderMatchesPreferredKeyboardFocus(responder) { - return false + hasher.combine(contexts.count) + + for context in contexts.prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) { + hasher.combine(context.windowId) + hasher.combine(context.tabManager.sessionAutosaveFingerprint()) + hasher.combine(context.sidebarState.isVisible) + hasher.combine( + Int(SessionPersistencePolicy.sanitizedSidebarWidth(Double(context.sidebarState.persistedWidth)).rounded()) + ) + + switch context.sidebarSelectionState.selection { + case .tabs: + hasher.combine(0) + case .notifications: + hasher.combine(1) + } + + if let window = context.window ?? windowForMainWindowId(context.windowId) { + Self.hashFrame(window.frame, into: &hasher) + } else { + hasher.combine(-1) + } } - return true + + return hasher.finalize() } - func repairFocusedTerminalKeyboardRoutingIfNeeded( - window: NSWindow, - event: NSEvent - ) { - guard event.type == .keyDown else { return } - let normalizedFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - guard !normalizedFlags.contains(.command) else { return } - 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, - let terminalPanel = workspace.terminalPanel(for: panelId) else { - return + @discardableResult + private func saveSessionSnapshot(includeScrollback: Bool, removeWhenEmpty: Bool = false) -> Bool { + if Self.shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: isApplyingStartupSessionRestore, + includeScrollback: includeScrollback + ) { +#if DEBUG + dlog("session.save.skipped reason=startup_restore_in_progress includeScrollback=0") +#endif + return false } - guard responderNeedsFocusedTerminalKeyRepair( - window.firstResponder, - in: window, - hostedView: terminalPanel.hostedView - ) else { return } -#if DEBUG - let before = window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let target = terminalPanel.hostedView.preferredPanelFocusIntentForActivation() - dlog( - "focus.keyRepair attempt window=\(ObjectIdentifier(window)) " + - "workspace=\(String(workspace.id.uuidString.prefix(5))) " + - "panel=\(String(panelId.uuidString.prefix(5))) " + - "target=\(target == .findField ? "searchField" : "surface") " + - "fr=\(before) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue)" + let writeSynchronously = Self.shouldWriteSessionSnapshotSynchronously( + isTerminatingApp: isTerminatingApp, + includeScrollback: includeScrollback ) +#if DEBUG + let timingStart = ProgramaTypingTiming.start() + defer { + ProgramaTypingTiming.logDuration( + path: "session.saveSnapshot", + startedAt: timingStart, + extra: "includeScrollback=\(includeScrollback ? 1 : 0) removeWhenEmpty=\(removeWhenEmpty ? 1 : 0) sync=\(writeSynchronously ? 1 : 0)" + ) + } #endif - terminalPanel.hostedView.ensureFocus(for: workspace.id, surfaceId: panelId) + guard let snapshot = buildSessionSnapshot(includeScrollback: includeScrollback) else { + persistSessionSnapshot( + nil, + removeWhenEmpty: removeWhenEmpty, + persistedGeometryData: nil, + synchronously: writeSynchronously + ) + return false + } + + let persistedGeometryData = snapshot.windows.first.flatMap { primaryWindow in + Self.encodedPersistedWindowGeometryData( + frame: primaryWindow.frame, + display: primaryWindow.display + ) + } #if DEBUG - let after = window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - dlog( - "focus.keyRepair result window=\(ObjectIdentifier(window)) " + - "panel=\(String(panelId.uuidString.prefix(5))) " + - "isSurfaceResponder=\(terminalPanel.hostedView.isSurfaceViewFirstResponder() ? 1 : 0) " + - "fr=\(after)" - ) + debugLogSessionSaveSnapshot(snapshot, includeScrollback: includeScrollback) #endif + persistSessionSnapshot( + snapshot, + removeWhenEmpty: false, + persistedGeometryData: persistedGeometryData, + synchronously: writeSynchronously + ) + return true } - func locateSurface(surfaceId: UUID) -> (windowId: UUID, workspaceId: UUID, tabManager: TabManager)? { - for ctx in mainWindowContexts.values { - for ws in ctx.tabManager.tabs { - if ws.panels[surfaceId] != nil { - return (ctx.windowId, ws.id, ctx.tabManager) - } - } - } - return nil + nonisolated static func shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: Bool) -> Bool { + !isTerminatingApp } - /// Resolve the workspace that currently owns a panel/surface ID. - /// Prefer the provided workspace when available, then fall back to global lookup. - func workspaceContainingPanel( - panelId: UUID, - preferredWorkspaceId: UUID? = nil - ) -> (workspace: Workspace, tabManager: TabManager)? { - if let preferredWorkspaceId, - let manager = tabManagerFor(tabId: preferredWorkspaceId), - let workspace = manager.tabs.first(where: { $0.id == preferredWorkspaceId }), - workspace.panels[panelId] != nil { - return (workspace, manager) - } - - if let located = locateSurface(surfaceId: panelId), - let workspace = located.tabManager.tabs.first(where: { $0.id == located.workspaceId }), - workspace.panels[panelId] != nil { - return (workspace, located.tabManager) - } + nonisolated static func shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister( + isTerminatingApp: Bool + ) -> Bool { + !isTerminatingApp + } - if let preferredWorkspaceId, - let manager = tabManagerFor(tabId: preferredWorkspaceId) ?? tabManager, - let workspace = manager.tabs.first(where: { $0.id == preferredWorkspaceId }), - workspace.panels[panelId] != nil { - return (workspace, manager) - } + nonisolated static func shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: Bool, + includeScrollback: Bool + ) -> Bool { + isApplyingStartupSessionRestore && !includeScrollback + } - if let manager = tabManager, - let workspace = manager.tabs.first(where: { $0.panels[panelId] != nil }) { - return (workspace, manager) - } + nonisolated static func shouldRunSessionAutosaveTick(isTerminatingApp: Bool) -> Bool { + !isTerminatingApp + } - return nil + private func remainingSessionAutosaveTypingQuietPeriod( + nowUptime: TimeInterval = ProcessInfo.processInfo.systemUptime + ) -> TimeInterval? { + guard lastTypingActivityAt > 0 else { return nil } + let elapsed = nowUptime - lastTypingActivityAt + guard elapsed < Self.sessionAutosaveTypingQuietPeriod else { return nil } + return Self.sessionAutosaveTypingQuietPeriod - elapsed } - func locateGhosttySurface(_ surface: ghostty_surface_t?) -> (windowId: UUID, workspaceId: UUID, panelId: UUID, tabManager: TabManager)? { - guard let surface else { return nil } - for ctx in mainWindowContexts.values { - for ws in ctx.tabManager.tabs { - for (panelId, panel) in ws.panels { - guard let terminal = panel as? TerminalPanel else { continue } - if terminal.surface.surface == surface { - return (ctx.windowId, ws.id, panelId, ctx.tabManager) - } - } + private func scheduleDeferredSessionAutosaveRetry(after delay: TimeInterval) { + guard delay.isFinite, delay > 0 else { return } + guard !sessionAutosaveDeferredRetryPending else { return } + sessionAutosaveDeferredRetryPending = true + sessionPersistenceQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + self.sessionAutosaveDeferredRetryPending = false + self.runSessionAutosaveTick(source: "typingQuietRetry") } } - return nil } - func refreshTerminalSurfacesAfterGhosttyConfigReload(source: String) { - var refreshedCount = 0 - forEachTerminalPanel { terminalPanel in - terminalPanel.hostedView.reconcileGeometryNow() - terminalPanel.hostedView.refreshHostBackgroundAfterGhosttyConfigReload() - terminalPanel.surface.forceRefresh(reason: "appDelegate.refreshAfterGhosttyConfigReload") - refreshedCount += 1 - } + private func runSessionAutosaveTick(source: String) { + guard Self.shouldRunSessionAutosaveTick(isTerminatingApp: isTerminatingApp) else { return } + guard !sessionAutosaveTickInFlight else { return } + if let remainingQuietPeriod = remainingSessionAutosaveTypingQuietPeriod() { #if DEBUG - dlog("reload.config.surfaceRefresh source=\(source) count=\(refreshedCount)") + dlog( + "session.save.skipped reason=typing_recent includeScrollback=0 source=\(source) " + + "retryMs=\(Int((remainingQuietPeriod * 1000).rounded()))" + ) #endif - } - - private func forEachTerminalPanel(_ body: (TerminalPanel) -> Void) { - var seenManagers: Set = [] - - func visitManager(_ manager: TabManager?) { - guard let manager else { return } - let managerId = ObjectIdentifier(manager) - guard seenManagers.insert(managerId).inserted else { return } - for workspace in manager.tabs { - for panel in workspace.panels.values { - guard let terminalPanel = panel as? TerminalPanel else { continue } - body(terminalPanel) - } - } + scheduleDeferredSessionAutosaveRetry(after: remainingQuietPeriod) + return } - visitManager(tabManager) - for context in mainWindowContexts.values { - visitManager(context.tabManager) + sessionAutosaveTickInFlight = true +#if DEBUG + let timingStart = ProgramaTypingTiming.start() + let phaseStart = ProcessInfo.processInfo.systemUptime + var fingerprintMs: Double = 0 + var saveMs: Double = 0 + defer { + sessionAutosaveTickInFlight = false + let totalMs = (ProcessInfo.processInfo.systemUptime - phaseStart) * 1000.0 + ProgramaTypingTiming.logBreakdown( + path: "session.autosaveTick.phase", + totalMs: totalMs, + thresholdMs: 2.0, + parts: [ + ("fingerprintMs", fingerprintMs), + ("saveMs", saveMs), + ], + extra: "source=\(source)" + ) + ProgramaTypingTiming.logDuration( + path: "session.autosaveTick", + startedAt: timingStart, + extra: "source=\(source)" + ) } - } +#else + defer { sessionAutosaveTickInFlight = false } +#endif - func focusMainWindow(windowId: UUID) -> Bool { - guard let window = windowForMainWindowId(windowId) else { return false } - if TerminalController.shouldSuppressSocketCommandActivation(), - !TerminalController.socketCommandAllowsInAppFocusMutations() { - setActiveMainWindow(window) - return true + let now = Date() +#if DEBUG + let fingerprintStart = ProcessInfo.processInfo.systemUptime +#endif + let autosaveFingerprint = sessionAutosaveFingerprint(includeScrollback: false) +#if DEBUG + fingerprintMs = (ProcessInfo.processInfo.systemUptime - fingerprintStart) * 1000.0 +#endif + if Self.shouldSkipSessionAutosaveForUnchangedFingerprint( + isTerminatingApp: isTerminatingApp, + includeScrollback: false, + previousFingerprint: lastSessionAutosaveFingerprint, + currentFingerprint: autosaveFingerprint, + lastPersistedAt: lastSessionAutosavePersistedAt, + now: now + ) { +#if DEBUG + dlog( + "session.save.skipped reason=unchanged_autosave_fingerprint includeScrollback=0 source=\(source)" + ) +#endif + return } - bringToFront(window) - return true - } - func closeMainWindow(windowId: UUID) -> Bool { - guard let window = windowForMainWindowId(windowId) else { return false } - window.performClose(nil) - return true - } - - private func confirmCloseMainWindow(_ window: NSWindow) -> Bool { #if DEBUG - if let debugCloseMainWindowConfirmationHandler { - return debugCloseMainWindowConfirmationHandler(window) - } + let saveStart = ProcessInfo.processInfo.systemUptime #endif - - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = String(localized: "dialog.closeWindow.title", defaultValue: "Close window?") - alert.informativeText = String( - localized: "dialog.closeWindow.message", - defaultValue: "This will close the current window and all of its workspaces." + _ = saveSessionSnapshot(includeScrollback: false) +#if DEBUG + saveMs = (ProcessInfo.processInfo.systemUptime - saveStart) * 1000.0 +#endif + updateSessionAutosaveSaveState( + includeScrollback: false, + persistedAt: now, + fingerprint: autosaveFingerprint ) - alert.addButton(withTitle: String(localized: "common.close", defaultValue: "Close")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) - - let alertWindow = alert.window - if let closeButton = alert.buttons.first { - alertWindow.defaultButtonCell = closeButton.cell as? NSButtonCell - alertWindow.initialFirstResponder = closeButton - DispatchQueue.main.async { - _ = alertWindow.makeFirstResponder(closeButton) - } - } - - return alert.runModal() == .alertFirstButtonReturn } - @discardableResult - func closeWindowWithConfirmation(_ window: NSWindow) -> Bool { - guard isMainTerminalWindow(window) else { - window.performClose(nil) - return true - } - guard confirmCloseMainWindow(window) else { return true } - window.performClose(nil) - return true + // Widened from `fileprivate` to `internal`: NSWindow.programa_sendEvent(_:) (now in + // WindowSwizzles.swift) calls AppDelegate.shared?.recordTypingActivity() from a different file. Refs #95. + func recordTypingActivity() { + lastTypingActivityAt = ProcessInfo.processInfo.systemUptime } - private func orderedMainWindowSummaries(referenceWindowId: UUID?) -> [MainWindowSummary] { - let summaries = listMainWindowSummaries() - return summaries.sorted { lhs, rhs in - let lhsIsReference = lhs.windowId == referenceWindowId - let rhsIsReference = rhs.windowId == referenceWindowId - if lhsIsReference != rhsIsReference { return lhsIsReference } - if lhs.isKeyWindow != rhs.isKeyWindow { return lhs.isKeyWindow } - if lhs.isVisible != rhs.isVisible { return lhs.isVisible } - return lhs.windowId.uuidString < rhs.windowId.uuidString - } + nonisolated static func shouldWriteSessionSnapshotSynchronously( + isTerminatingApp: Bool, + includeScrollback: Bool + ) -> Bool { + isTerminatingApp && includeScrollback } - private func windowLabelsById(orderedSummaries: [MainWindowSummary], referenceWindowId: UUID?) -> [UUID: String] { - var labels: [UUID: String] = [:] - for (index, summary) in orderedSummaries.enumerated() { - if summary.windowId == referenceWindowId { - labels[summary.windowId] = String(localized: "menu.currentWindow", defaultValue: "Current Window") - } else { - let number = index + 1 - labels[summary.windowId] = String(localized: "menu.windowNumber", defaultValue: "Window \(number)") - } + nonisolated static func shouldSkipSessionAutosaveForUnchangedFingerprint( + isTerminatingApp: Bool, + includeScrollback: Bool, + previousFingerprint: Int?, + currentFingerprint: Int?, + lastPersistedAt: Date, + now: Date, + maximumAutosaveSkippableInterval: TimeInterval = 60 + ) -> Bool { + guard !isTerminatingApp, + !includeScrollback, + let previousFingerprint, + let currentFingerprint, + previousFingerprint == currentFingerprint else { + return false } - return labels - } - private func workspaceDisplayName(_ workspace: Workspace) -> String { - let trimmed = workspace.title.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? String(localized: "workspace.displayName.fallback", defaultValue: "Workspace") : trimmed + return now.timeIntervalSince(lastPersistedAt) < maximumAutosaveSkippableInterval } - private func rollbackDetachedSurface( - _ detached: Workspace.DetachedSurfaceTransfer, - to workspace: Workspace, - sourcePane: PaneID?, - sourceIndex: Int?, - focus: Bool + private func updateSessionAutosaveSaveState( + includeScrollback: Bool, + persistedAt: Date, + fingerprint: Int? ) { - let rollbackPane = sourcePane.flatMap { pane in - workspace.bonsplitController.allPaneIds.first(where: { $0 == pane }) - } ?? workspace.bonsplitController.focusedPaneId - ?? workspace.bonsplitController.allPaneIds.first - guard let rollbackPane else { return } - _ = workspace.attachDetachedSurface( - detached, - inPane: rollbackPane, - atIndex: sourceIndex, - focus: focus - ) + guard !isTerminatingApp, !includeScrollback else { return } + lastSessionAutosaveFingerprint = fingerprint + lastSessionAutosavePersistedAt = persistedAt } - private func cleanupEmptySourceWorkspaceAfterSurfaceMove( - sourceWorkspace: Workspace, - sourceManager: TabManager, - sourceWindowId: UUID + private nonisolated static func hashFrame(_ frame: NSRect, into hasher: inout Hasher) { + let standardized = frame.standardized + let quantized = [ + standardized.origin.x, + standardized.origin.y, + standardized.size.width, + standardized.size.height, + ].map { Int(($0 * 2).rounded()) } + quantized.forEach { hasher.combine($0) } + } + + private func persistSessionSnapshot( + _ snapshot: AppSessionSnapshot?, + removeWhenEmpty: Bool, + persistedGeometryData: Data?, + synchronously: Bool ) { - guard sourceWorkspace.panels.isEmpty else { return } - guard sourceManager.tabs.contains(where: { $0.id == sourceWorkspace.id }) else { return } + guard snapshot != nil || removeWhenEmpty || persistedGeometryData != nil else { return } - if sourceManager.tabs.count > 1 { - sourceManager.closeWorkspace(sourceWorkspace) + let writeBlock = { + Self.removeLegacyPersistedWindowGeometry() + if let persistedGeometryData { + UserDefaults.standard.set( + persistedGeometryData, + forKey: Self.persistedWindowGeometryDefaultsKey + ) + } + if let snapshot { + _ = SessionPersistenceStore.save(snapshot) + } else if removeWhenEmpty { + SessionPersistenceStore.removeSnapshot() + } + } + + if synchronously { + writeBlock() } else { - _ = closeMainWindow(windowId: sourceWindowId) + sessionPersistenceQueue.async(execute: writeBlock) } } - private func reassertCrossWindowSurfaceMoveFocusIfNeeded( - destinationWindowId: UUID, - sourceWindowId: UUID, - destinationWorkspaceId: UUID, - destinationPanelId: UUID, - destinationManager: TabManager - ) { - let reassert: () -> Void = { [weak self, weak destinationManager] in - guard let self, let destinationManager else { return } - guard let workspace = destinationManager.tabs.first(where: { $0.id == destinationWorkspaceId }), - workspace.panels[destinationPanelId] != nil else { - return + 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 + let rhsIsKey = rhsWindow?.isKeyWindow ?? false + if lhsIsKey != rhsIsKey { + return lhsIsKey && !rhsIsKey } - guard let destinationWindow = self.mainWindow(for: destinationWindowId) else { return } - guard let keyWindow = NSApp.keyWindow, - let keyWindowId = self.mainWindowId(for: keyWindow), - keyWindowId == sourceWindowId, - keyWindow !== destinationWindow else { - return + return lhs.windowId.uuidString < rhs.windowId.uuidString + } + + guard !contexts.isEmpty else { return nil } + + let windows: [SessionWindowSnapshot] = contexts + .prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) + .map { context in + let window = context.window ?? windowForMainWindowId(context.windowId) + return SessionWindowSnapshot( + frame: window.map { SessionRectSnapshot($0.frame) }, + display: displaySnapshot(for: window), + tabManager: context.tabManager.sessionSnapshot(includeScrollback: includeScrollback), + sidebar: SessionSidebarSnapshot( + isVisible: context.sidebarState.isVisible, + selection: SessionSidebarSelection(selection: context.sidebarSelectionState.selection), + width: SessionPersistencePolicy.sanitizedSidebarWidth(Double(context.sidebarState.persistedWidth)) + ) + ) } - self.bringToFront(destinationWindow) - destinationManager.focusTab( - destinationWorkspaceId, - surfaceId: destinationPanelId, - suppressFlash: true + guard !windows.isEmpty else { return nil } + return AppSessionSnapshot( + version: SessionSnapshotSchema.currentVersion, + createdAt: Date().timeIntervalSince1970, + windows: windows + ) + } + +#if DEBUG + private func debugLogSessionSaveSnapshot( + _ snapshot: AppSessionSnapshot, + includeScrollback: Bool + ) { + 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" + dlog( + "session.save.window idx=\(index) " + + "frame={\(debugSessionRectDescription(windowSnapshot.frame))} " + + "display={\(debugSessionDisplayDescription(windowSnapshot.display))} " + + "workspaces=\(workspaceCount) selected=\(selectedWorkspace)" ) } + } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: reassert) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.16, execute: reassert) + private func debugSessionRectDescription(_ rect: SessionRectSnapshot?) -> String { + guard let rect else { return "nil" } + return "x=\(debugSessionNumber(rect.x)) y=\(debugSessionNumber(rect.y)) " + + "w=\(debugSessionNumber(rect.width)) h=\(debugSessionNumber(rect.height))" + } + + private func debugNSRectDescription(_ rect: NSRect?) -> String { + guard let rect else { return "nil" } + return "x=\(debugSessionNumber(Double(rect.origin.x))) " + + "y=\(debugSessionNumber(Double(rect.origin.y))) " + + "w=\(debugSessionNumber(Double(rect.size.width))) " + + "h=\(debugSessionNumber(Double(rect.size.height)))" } - private func windowForMainWindowId(_ windowId: UUID) -> NSWindow? { - if let ctx = mainWindowContexts.values.first(where: { $0.windowId == windowId }), - let window = ctx.window { - return window - } - let expectedIdentifier = "cmux.main.\(windowId.uuidString)" - return NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) + private func debugSessionDisplayDescription(_ display: SessionDisplaySnapshot?) -> String { + guard let display else { return "nil" } + let displayIdText = display.displayID.map(String.init) ?? "nil" + return "id=\(displayIdText) " + + "frame={\(debugSessionRectDescription(display.frame))} " + + "visible={\(debugSessionRectDescription(display.visibleFrame))}" } - private func resolvedWindow(for context: MainWindowContext) -> NSWindow? { - if let window = context.window { - return window - } - guard let window = windowForMainWindowId(context.windowId) else { - return nil - } - reindexMainWindowContextIfNeeded(context, for: window) - return window + private func debugSessionNumber(_ value: Double) -> String { + String(format: "%.1f", value) } +#endif - private func mainWindowId(from window: NSWindow) -> UUID? { - guard let raw = window.identifier?.rawValue else { return nil } - let prefix = "cmux.main." - guard raw.hasPrefix(prefix) else { return nil } - let suffix = String(raw.dropFirst(prefix.count)) - return UUID(uuidString: suffix) + private func notifyMainWindowContextsDidChange() { + NotificationCenter.default.post(name: .mainWindowContextsDidChange, object: self) } - private func reindexMainWindowContextIfNeeded(_ context: MainWindowContext, for window: NSWindow) { - let desiredKey = ObjectIdentifier(window) - if mainWindowContexts[desiredKey] === context { - context.window = window - return - } + /// Register a terminal window with the AppDelegate so menu commands and socket control + /// can target whichever window is currently active. + func registerMainWindow( + _ window: NSWindow, + windowId: UUID, + tabManager: TabManager, + sidebarState: SidebarState, + sidebarSelectionState: SidebarSelectionState + ) { + tabManager.window = window - let contextKeys = mainWindowContexts.compactMap { key, value in - value === context ? key : nil + let key = ObjectIdentifier(window) + #if DEBUG + let priorManagerToken = debugManagerToken(self.tabManager) + #endif + if let existing = mainWindowContexts[key] { + existing.window = window + } else if let existing = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { + existing.window = window + reindexMainWindowContextIfNeeded(existing, for: window) + } else { + mainWindowContexts[key] = MainWindowContext( + windowId: windowId, + tabManager: tabManager, + sidebarState: sidebarState, + sidebarSelectionState: sidebarSelectionState, + window: window + ) + NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak self] note in + guard let self, let closing = note.object as? NSWindow else { return } + self.unregisterMainWindow(closing) + } } - for key in contextKeys { - mainWindowContexts.removeValue(forKey: key) + updateCommandPaletteState(for: windowId) { state in + state.isVisible = false + state.selectionIndex = 0 + state.snapshot = .empty } - if let conflicting = mainWindowContexts[desiredKey], conflicting !== context { - context.window = window - return +#if DEBUG + dlog( + "mainWindow.register windowId=\(String(windowId.uuidString.prefix(8))) window={\(debugWindowToken(window))} manager=\(debugManagerToken(tabManager)) priorActiveMgr=\(priorManagerToken) \(debugShortcutRouteSnapshot())" + ) +#endif + notifyMainWindowContextsDidChange() + if window.isKeyWindow { + setActiveMainWindow(window) } - mainWindowContexts[desiredKey] = context - context.window = window - notifyMainWindowContextsDidChange() + attemptStartupSessionRestoreIfNeeded(primaryWindow: window) + if !isTerminatingApp { + _ = saveSessionSnapshot(includeScrollback: false) + } } - private func contextForMainTerminalWindow(_ window: NSWindow, reindex: Bool = true) -> MainWindowContext? { - guard isMainTerminalWindow(window) else { return nil } + struct MainWindowSummary { + let windowId: UUID + let isKeyWindow: Bool + let isVisible: Bool + let workspaceCount: Int + let selectedWorkspaceId: UUID? + } - if let context = mainWindowContexts[ObjectIdentifier(window)] { - context.window = window - return context - } + struct WindowMoveTarget: Identifiable { + let windowId: UUID + let label: String + let tabManager: TabManager + let isCurrentWindow: Bool - if let windowId = mainWindowId(from: window), - let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { - if reindex { - reindexMainWindowContextIfNeeded(context, for: window) - } else { - context.window = window - } - return context - } + var id: UUID { windowId } + } - let windowNumber = window.windowNumber - if windowNumber >= 0, - let context = mainWindowContexts.values.first(where: { candidate in - let candidateWindow = candidate.window ?? windowForMainWindowId(candidate.windowId) - return candidateWindow?.windowNumber == windowNumber - }) { - if reindex { - reindexMainWindowContextIfNeeded(context, for: window) - } else { - context.window = window - } - return context - } + struct WorkspaceMoveTarget: Identifiable { + let windowId: UUID + let workspaceId: UUID + let windowLabel: String + let workspaceTitle: String + let tabManager: TabManager + let isCurrentWindow: Bool - return nil + var id: String { "\(windowId.uuidString):\(workspaceId.uuidString)" } + var label: String { + isCurrentWindow ? workspaceTitle : "\(workspaceTitle) (\(windowLabel))" + } } - private func unregisterMainWindowContext(for window: NSWindow) -> MainWindowContext? { - guard let removed = contextForMainTerminalWindow(window, reindex: false) else { return nil } - let removedKeys = mainWindowContexts.compactMap { key, value in - value === removed ? key : nil - } - for key in removedKeys { - mainWindowContexts.removeValue(forKey: key) + func listMainWindowSummaries() -> [MainWindowSummary] { + let contexts = Array(mainWindowContexts.values) + return contexts.map { ctx in + let window = ctx.window ?? windowForMainWindowId(ctx.windowId) + return MainWindowSummary( + windowId: ctx.windowId, + isKeyWindow: window?.isKeyWindow ?? false, + isVisible: window?.isVisible ?? false, + workspaceCount: ctx.tabManager.tabs.count, + selectedWorkspaceId: ctx.tabManager.selectedTabId + ) } - notifyMainWindowContextsDidChange() - return removed } - private func discardOrphanedMainWindowContext(_ context: MainWindowContext) { - let contextKeys = mainWindowContexts.compactMap { key, value in - value === context ? key : nil - } - for key in contextKeys { - mainWindowContexts.removeValue(forKey: key) + func windowMoveTargets(referenceWindowId: UUID?) -> [WindowMoveTarget] { + let orderedSummaries = orderedMainWindowSummaries(referenceWindowId: referenceWindowId) + let labels = windowLabelsById(orderedSummaries: orderedSummaries, referenceWindowId: referenceWindowId) + return orderedSummaries.compactMap { summary in + guard let manager = tabManagerFor(windowId: summary.windowId) else { return nil } + let label = labels[summary.windowId] ?? "Window" + return WindowMoveTarget( + windowId: summary.windowId, + label: label, + tabManager: manager, + isCurrentWindow: summary.windowId == referenceWindowId + ) } - notifyMainWindowContextsDidChange() + } - commandPaletteVisibilityByWindowId.removeValue(forKey: context.windowId) - commandPalettePendingOpenByWindowId.removeValue(forKey: context.windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: context.windowId) - commandPaletteEscapeSuppressionByWindowId.remove(context.windowId) - commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: context.windowId) - commandPaletteSelectionByWindowId.removeValue(forKey: context.windowId) - commandPaletteSnapshotByWindowId.removeValue(forKey: context.windowId) + func workspaceMoveTargets(excludingWorkspaceId: UUID? = nil, referenceWindowId: UUID?) -> [WorkspaceMoveTarget] { + let orderedSummaries = orderedMainWindowSummaries(referenceWindowId: referenceWindowId) + let labels = windowLabelsById(orderedSummaries: orderedSummaries, referenceWindowId: referenceWindowId) - if tabManager === context.tabManager { - if let nextContext = mainWindowContexts.values.first(where: { resolvedWindow(for: $0) != nil }) { - tabManager = nextContext.tabManager - sidebarState = nextContext.sidebarState - sidebarSelectionState = nextContext.sidebarSelectionState - TerminalController.shared.setActiveTabManager(nextContext.tabManager) - } else { - tabManager = nil - sidebarState = nil - sidebarSelectionState = nil - TerminalController.shared.setActiveTabManager(nil) - } - } + var targets: [WorkspaceMoveTarget] = [] + targets.reserveCapacity(orderedSummaries.reduce(0) { partial, summary in + partial + summary.workspaceCount + }) - if let store = notificationStore { - for tab in context.tabManager.tabs { - store.clearNotifications(forTabId: tab.id) + for summary in orderedSummaries { + guard let manager = tabManagerFor(windowId: summary.windowId) else { continue } + let windowLabel = labels[summary.windowId] ?? "Window" + let isCurrentWindow = summary.windowId == referenceWindowId + for workspace in manager.tabs { + if workspace.id == excludingWorkspaceId { + continue + } + targets.append( + WorkspaceMoveTarget( + windowId: summary.windowId, + workspaceId: workspace.id, + windowLabel: windowLabel, + workspaceTitle: workspaceDisplayName(workspace), + tabManager: manager, + isCurrentWindow: isCurrentWindow + ) + ) } } - } - private func mainWindowId(for window: NSWindow) -> UUID? { - if let context = mainWindowContexts[ObjectIdentifier(window)] { - return context.windowId - } - guard let rawIdentifier = window.identifier?.rawValue, - rawIdentifier.hasPrefix("cmux.main.") else { return nil } - let idPart = String(rawIdentifier.dropFirst("cmux.main.".count)) - return UUID(uuidString: idPart) + return targets } - private func commandPaletteOverlayContainer(in window: NSWindow) -> NSView? { - guard let searchRoot = window.contentView?.superview ?? window.contentView else { return nil } - var stack: [NSView] = [searchRoot] - while let candidate = stack.popLast() { - if candidate.identifier == commandPaletteOverlayContainerIdentifier { - return candidate + @discardableResult + func moveWorkspaceToWindow(workspaceId: UUID, windowId: UUID, focus: Bool = true) -> Bool { + guard let sourceManager = tabManagerFor(tabId: workspaceId), + let destinationManager = tabManagerFor(windowId: windowId) else { + return false + } + + if sourceManager === destinationManager { + if focus { + destinationManager.focusTab(workspaceId, suppressFlash: true) + _ = focusMainWindow(windowId: windowId) + TerminalController.shared.setActiveTabManager(destinationManager) } - stack.append(contentsOf: candidate.subviews) + return true } - return nil - } - private func isCommandPaletteOverlayPresented(in window: NSWindow) -> Bool { - guard let container = commandPaletteOverlayContainer(in: window) else { return false } - return !container.isHidden && container.alphaValue > 0.001 - } + guard let workspace = sourceManager.detachWorkspace(tabId: workspaceId) else { return false } + destinationManager.attachWorkspace(workspace, select: focus) - private func isCommandPaletteResponderActive(in window: NSWindow) -> Bool { - guard let responder = window.firstResponder else { return false } - if let textView = responder as? NSTextView, - textView.isFieldEditor, - !(textView.delegate is NSView) { - // Field-editor delegates can be non-view responders. Confirm the overlay is - // mounted and visible to avoid treating unrelated editors as palette input. - return isCommandPaletteOverlayPresented(in: window) + if focus { + _ = focusMainWindow(windowId: windowId) + TerminalController.shared.setActiveTabManager(destinationManager) } - return isCommandPaletteResponder(responder) + return true } - private func isCommandPaletteMultilineTextResponderActive(in window: NSWindow) -> Bool { - guard let textView = window.firstResponder as? NSTextView, - !textView.isFieldEditor else { - return false - } - return isCommandPaletteResponder(textView) - } + @discardableResult + func moveWorkspaceToNewWindow(workspaceId: UUID, focus: Bool = true) -> UUID? { + let windowId = createMainWindow() + guard let destinationManager = tabManagerFor(windowId: windowId) else { return nil } + let bootstrapWorkspaceId = destinationManager.tabs.first?.id - private func commandPaletteMarkedTextInput(in window: NSWindow) -> NSTextView? { - if let textView = window.firstResponder as? NSTextView, - isCommandPaletteResponder(textView), - textView.hasMarkedText() { - return textView + guard moveWorkspaceToWindow(workspaceId: workspaceId, windowId: windowId, focus: focus) else { + _ = closeMainWindow(windowId: windowId) + return nil } - if let textField = window.firstResponder as? NSTextField, - let editor = textField.currentEditor() as? NSTextView, - isCommandPaletteResponder(editor), - editor.hasMarkedText() { - return editor + // Remove the bootstrap workspace from the new window once the moved workspace arrives. + if let bootstrapWorkspaceId, + bootstrapWorkspaceId != workspaceId, + let bootstrapWorkspace = destinationManager.tabs.first(where: { $0.id == bootstrapWorkspaceId }), + destinationManager.tabs.count > 1 { + destinationManager.closeWorkspace(bootstrapWorkspace) } - - return nil + return windowId } - private func isCommandPaletteEffectivelyVisible(in window: NSWindow) -> Bool { - isCommandPaletteVisible(for: window) - || isCommandPalettePendingOpen(for: window) - || isCommandPaletteOverlayPresented(in: window) - || isCommandPaletteResponderActive(in: window) + func locateBonsplitSurface(tabId: UUID) -> (windowId: UUID, workspaceId: UUID, panelId: UUID, tabManager: TabManager)? { + let bonsplitTabId = TabID(uuid: tabId) + for context in mainWindowContexts.values { + for workspace in context.tabManager.tabs { + if let panelId = workspace.panelIdFromSurfaceId(bonsplitTabId) { + return (context.windowId, workspace.id, panelId, context.tabManager) + } + } + } + return nil } - private func activeCommandPaletteWindow() -> NSWindow? { - pruneExpiredCommandPalettePendingOpenStates() - if let keyWindow = NSApp.keyWindow, - isMainTerminalWindow(keyWindow), - isCommandPaletteEffectivelyVisible(in: keyWindow) { - return keyWindow - } - if let mainWindow = NSApp.mainWindow, - isMainTerminalWindow(mainWindow), - isCommandPaletteEffectivelyVisible(in: mainWindow) { - return mainWindow + @discardableResult + func moveSurface( + panelId: UUID, + toWorkspace targetWorkspaceId: UUID, + targetPane: PaneID? = nil, + targetIndex: Int? = nil, + splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? = nil, + focus: Bool = true, + focusWindow: Bool = true + ) -> Bool { +#if DEBUG + let moveStart = ProcessInfo.processInfo.systemUptime + let splitLabel = splitTarget.map { split in + "\(split.orientation.rawValue):\(split.insertFirst ? 1 : 0)" + } ?? "none" + func elapsedMs(since start: TimeInterval) -> String { + let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 + return String(format: "%.2f", ms) } - if let orderedWindow = NSApp.orderedWindows.first(where: { window in - isMainTerminalWindow(window) && isCommandPaletteEffectivelyVisible(in: window) - }) { - return orderedWindow + 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 + guard let source = locateSurface(surfaceId: panelId) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourcePanelNotFound elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false } - if let visibleWindowId = commandPaletteVisibilityByWindowId.first(where: { $0.value })?.key { - return windowForMainWindowId(visibleWindowId) + guard let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourceWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false } - if let pendingWindowId = commandPalettePendingOpenByWindowId.first(where: { $0.value })?.key { - return windowForMainWindowId(pendingWindowId) + guard let destinationManager = tabManagerFor(tabId: targetWorkspaceId) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationManagerMissing elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false } - return nil - } - - private func commandPaletteWindowForShortcutEvent(_ event: NSEvent) -> NSWindow? { - if let scopedWindow = mainWindowForShortcutEvent(event) { - return scopedWindow + guard let destinationWorkspace = destinationManager.tabs.first(where: { $0.id == targetWorkspaceId }) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false } - return activeCommandPaletteWindow() - } +#if DEBUG + 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)" + ) +#endif - private func contextForMainWindow(_ window: NSWindow?) -> MainWindowContext? { - guard let window, isMainTerminalWindow(window) else { return nil } - return mainWindowContexts[ObjectIdentifier(window)] - } + let resolvedTargetPane = targetPane.flatMap { pane in + destinationWorkspace.bonsplitController.allPaneIds.first(where: { $0 == pane }) + } ?? destinationWorkspace.bonsplitController.focusedPaneId + ?? destinationWorkspace.bonsplitController.allPaneIds.first + guard let resolvedTargetPane else { #if DEBUG - func debugManagerToken(_ manager: TabManager?) -> String { - guard let manager else { return "nil" } - return String(describing: Unmanaged.passUnretained(manager).toOpaque()) - } - - private func debugWindowToken(_ window: NSWindow?) -> String { - guard let window else { return "nil" } - let id = mainWindowId(for: window).map { String($0.uuidString.prefix(8)) } ?? "none" - let ident = window.identifier?.rawValue ?? "nil" - let shortIdent: String - if ident.count > 120 { - shortIdent = String(ident.prefix(120)) + "..." - } else { - shortIdent = ident + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=targetPaneMissing " + + "destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false } - return "num=\(window.windowNumber) id=\(id) ident=\(shortIdent) key=\(window.isKeyWindow ? 1 : 0) main=\(window.isMainWindow ? 1 : 0)" - } - - private func debugContextToken(_ context: MainWindowContext?) -> String { - guard let context else { return "nil" } - let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" - let hasWindow = (context.window != nil || windowForMainWindowId(context.windowId) != nil) ? 1 : 0 - return "id=\(String(context.windowId.uuidString.prefix(8))) mgr=\(debugManagerToken(context.tabManager)) tabs=\(context.tabManager.tabs.count) selected=\(selected) hasWindow=\(hasWindow)" - } - - private func debugShortcutRouteSnapshot(event: NSEvent? = nil) -> String { - let activeManager = tabManager - let activeWindowId = activeManager.flatMap { windowId(for: $0) }.map { String($0.uuidString.prefix(8)) } ?? "nil" - let selectedWorkspace = activeManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" - let contexts = mainWindowContexts.values - .map { context in - let marker = (activeManager != nil && context.tabManager === activeManager) ? "*" : "-" - let window = context.window ?? windowForMainWindowId(context.windowId) - let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" - return "\(marker)\(String(context.windowId.uuidString.prefix(8))){mgr=\(debugManagerToken(context.tabManager)),win=\(window?.windowNumber ?? -1),key=\((window?.isKeyWindow ?? false) ? 1 : 0),main=\((window?.isMainWindow ?? false) ? 1 : 0),tabs=\(context.tabManager.tabs.count),selected=\(selected)}" + 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 + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sameWorkspaceSplitFailed " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) split=\(splitLabel) " + + "elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false + } + if focus { + source.tabManager.focusTab(sourceWorkspace.id, surfaceId: panelId, suppressFlash: true) + } +#if DEBUG + dlog( + "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=sameWorkspaceSplit moved=1 " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return true } - .sorted() - .joined(separator: ",") - let eventWindowNumber = event.map { String($0.windowNumber) } ?? "nil" - let eventWindow = event?.window - return "eventWinNum=\(eventWindowNumber) eventWin={\(debugWindowToken(eventWindow))} keyWin={\(debugWindowToken(NSApp.keyWindow))} mainWin={\(debugWindowToken(NSApp.mainWindow))} activeMgr=\(debugManagerToken(activeManager)) activeWinId=\(activeWindowId) activeSelected=\(selectedWorkspace) contexts=[\(contexts)]" - } + let moved = sourceWorkspace.moveSurface( + panelId: panelId, + toPane: resolvedTargetPane, + atIndex: targetIndex, + focus: focus + ) +#if DEBUG + 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 + return moved + } + + let sourcePane = sourceWorkspace.paneId(forPanelId: panelId) + let sourceIndex = sourceWorkspace.indexInPane(forPanelId: panelId) +#if DEBUG + let detachStart = ProcessInfo.processInfo.systemUptime #endif - private func mainWindowForShortcutEvent(_ event: NSEvent) -> NSWindow? { - if let window = resolvedShortcutEventWindow(event), - isMainTerminalWindow(window) { - return window - } - if let keyWindow = NSApp.keyWindow, isMainTerminalWindow(keyWindow) { - return keyWindow - } - if let mainWindow = NSApp.mainWindow, isMainTerminalWindow(mainWindow) { - return mainWindow + guard let detached = sourceWorkspace.detachSurface(panelId: panelId) else { +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=detachFailed " + + "elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false } - return nil - } - - private func resolvedShortcutEventWindow(_ event: NSEvent) -> NSWindow? { - if let window = event.window { - return window +#if DEBUG + let detachMs = elapsedMs(since: detachStart) + let attachStart = ProcessInfo.processInfo.systemUptime +#endif + guard destinationWorkspace.attachDetachedSurface( + detached, + inPane: resolvedTargetPane, + atIndex: targetIndex, + focus: focus + ) != nil else { + rollbackDetachedSurface( + detached, + to: sourceWorkspace, + sourcePane: sourcePane, + sourceIndex: sourceIndex, + focus: focus + ) +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=attachFailed " + + "detachMs=\(detachMs) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false } - let eventWindowNumber = event.windowNumber - guard eventWindowNumber > 0 else { return nil } - return NSApp.window(withWindowNumber: eventWindowNumber) - } +#if DEBUG + let attachMs = elapsedMs(since: attachStart) + var splitMs = "0.00" +#endif - /// Re-sync app-level active window pointers from the currently focused main terminal window. - /// This keeps menu/shortcut actions window-scoped even if the cached `tabManager` drifts. - @discardableResult - func synchronizeActiveMainWindowContext(preferredWindow: NSWindow? = nil) -> TabManager? { - let (context, source): (MainWindowContext?, String) = { - if let preferredWindow, - let context = contextForMainWindow(preferredWindow) { - return (context, "preferredWindow") - } - if let context = contextForMainWindow(NSApp.keyWindow) { - return (context, "keyWindow") - } - if let context = contextForMainWindow(NSApp.mainWindow) { - return (context, "mainWindow") - } - if let activeManager = tabManager, - let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { - return (activeContext, "activeManager") + if let splitTarget { +#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 { + if let detachedFromDestination = destinationWorkspace.detachSurface(panelId: panelId) { + rollbackDetachedSurface( + detachedFromDestination, + to: sourceWorkspace, + sourcePane: sourcePane, + sourceIndex: sourceIndex, + focus: focus + ) + } +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=postAttachSplitFailed " + + "detachMs=\(detachMs) attachMs=\(attachMs) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false } - return (mainWindowContexts.values.first, "firstContextFallback") - }() +#if DEBUG + splitMs = elapsedMs(since: splitStart) +#endif + } #if DEBUG - let beforeManagerToken = debugManagerToken(tabManager) - dlog( - "shortcut.sync.pre source=\(source) preferred={\(debugWindowToken(preferredWindow))} chosen={\(debugContextToken(context))} \(debugShortcutRouteSnapshot())" - ) + let cleanupStart = ProcessInfo.processInfo.systemUptime #endif - guard let context else { return tabManager } - let alreadyActive = - tabManager === context.tabManager - && sidebarState === context.sidebarState - && sidebarSelectionState === context.sidebarSelectionState - if alreadyActive { + cleanupEmptySourceWorkspaceAfterSurfaceMove( + sourceWorkspace: sourceWorkspace, + sourceManager: source.tabManager, + sourceWindowId: source.windowId + ) #if DEBUG - dlog( - "shortcut.sync.post source=\(source) beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) chosen={\(debugContextToken(context))} nochange=1 \(debugShortcutRouteSnapshot())" - ) + let cleanupMs = elapsedMs(since: cleanupStart) + let focusStart = ProcessInfo.processInfo.systemUptime #endif - return context.tabManager - } - if let window = context.window ?? windowForMainWindowId(context.windowId) { - setActiveMainWindow(window) - } else { - tabManager = context.tabManager - sidebarState = context.sidebarState - sidebarSelectionState = context.sidebarSelectionState - TerminalController.shared.setActiveTabManager(context.tabManager) + + if focus { + let destinationWindowId = focusWindow ? windowId(for: destinationManager) : nil + if let destinationWindowId { + _ = focusMainWindow(windowId: destinationWindowId) + } + destinationManager.focusTab(targetWorkspaceId, surfaceId: panelId, suppressFlash: true) + if let destinationWindowId { + reassertCrossWindowSurfaceMoveFocusIfNeeded( + destinationWindowId: destinationWindowId, + sourceWindowId: source.windowId, + destinationWorkspaceId: targetWorkspaceId, + destinationPanelId: panelId, + destinationManager: destinationManager + ) + } } #if DEBUG + let focusMs = elapsedMs(since: focusStart) dlog( - "shortcut.sync.post source=\(source) beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) chosen={\(debugContextToken(context))} \(debugShortcutRouteSnapshot())" + "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") " + + "split=\(splitLabel) detachMs=\(detachMs) attachMs=\(attachMs) splitMs=\(splitMs) " + + "cleanupMs=\(cleanupMs) focusMs=\(focusMs) elapsedMs=\(elapsedMs(since: moveStart))" ) #endif - return context.tabManager - } - - private struct FocusedTerminalShortcutContext { - let tabManager: TabManager - let workspaceId: UUID - let panelId: UUID - } - private func resolveShortcutTabManager(for tabId: UUID, preferredWindow: NSWindow? = nil) -> TabManager? { - if let manager = tabManagerFor(tabId: tabId) { - return manager - } - if let preferredWindow, - let context = contextForMainWindow(preferredWindow), - context.tabManager.tabs.contains(where: { $0.id == tabId }) { - return context.tabManager - } - if let activeManager = tabManager, - activeManager.tabs.contains(where: { $0.id == tabId }) { - return activeManager - } - return nil + return true } - private func focusedTerminalShortcutContext(preferredWindow: NSWindow? = nil) -> FocusedTerminalShortcutContext? { - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow - let responder = targetWindow?.firstResponder - ?? NSApp.keyWindow?.firstResponder - ?? NSApp.mainWindow?.firstResponder - guard let ghosttyView = cmuxOwningGhosttyView(for: responder), - let workspaceId = ghosttyView.tabId, - let panelId = ghosttyView.terminalSurface?.id, - let manager = resolveShortcutTabManager(for: workspaceId, preferredWindow: targetWindow) else { - return nil + @discardableResult + func moveBonsplitTab( + tabId: UUID, + toWorkspace targetWorkspaceId: UUID, + targetPane: PaneID? = nil, + targetIndex: Int? = nil, + splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? = nil, + focus: Bool = true, + focusWindow: Bool = true + ) -> Bool { +#if DEBUG + let moveStart = ProcessInfo.processInfo.systemUptime + func elapsedMs(since start: TimeInterval) -> String { + let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 + return String(format: "%.2f", ms) } - return FocusedTerminalShortcutContext( - tabManager: manager, - workspaceId: workspaceId, - panelId: panelId + dlog( + "surface.moveBonsplit.begin tab=\(tabId.uuidString.prefix(5)) targetWs=\(targetWorkspaceId.uuidString.prefix(5)) " + + "targetPane=\(targetPane?.id.uuidString.prefix(5) ?? "auto") targetIndex=\(targetIndex.map(String.init) ?? "nil")" ) - } - - private func preferredMainWindowContextForShortcuts(event: NSEvent) -> MainWindowContext? { - if let context = contextForMainWindow(event.window) { - return context - } - if let context = contextForMainWindow(NSApp.keyWindow) { - return context - } - if let context = contextForMainWindow(NSApp.mainWindow) { - return context - } - if let activeManager = tabManager, - let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { - return activeContext +#endif + guard let located = locateBonsplitSurface(tabId: tabId) else { +#if DEBUG + dlog( + "surface.moveBonsplit.fail tab=\(tabId.uuidString.prefix(5)) reason=tabNotFound " + + "targetWs=\(targetWorkspaceId.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false } - return mainWindowContexts.values.first - } - - private func activateMainWindowContextForShortcutEvent(_ event: NSEvent) { - let preferredWindow = mainWindowForShortcutEvent(event) #if DEBUG dlog( - "shortcut.activate.pre event=\(NSWindow.keyDescription(event)) preferred={\(debugWindowToken(preferredWindow))} \(debugShortcutRouteSnapshot(event: event))" + "surface.moveBonsplit.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 + let moved = moveSurface( + panelId: located.panelId, + toWorkspace: targetWorkspaceId, + targetPane: targetPane, + targetIndex: targetIndex, + splitTarget: splitTarget, + focus: focus, + focusWindow: focusWindow ) -#endif - _ = synchronizeActiveMainWindowContext(preferredWindow: preferredWindow) #if DEBUG dlog( - "shortcut.activate.post event=\(NSWindow.keyDescription(event)) preferred={\(debugWindowToken(preferredWindow))} \(debugShortcutRouteSnapshot(event: event))" + "surface.moveBonsplit.end tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " + + "moved=\(moved ? 1 : 0) elapsedMs=\(elapsedMs(since: moveStart))" ) #endif + return moved } - @discardableResult - func toggleSidebarInActiveMainWindow() -> Bool { - if let activeManager = tabManager, - let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { - if let window = activeContext.window ?? windowForMainWindowId(activeContext.windowId) { - setActiveMainWindow(window) - } - activeContext.sidebarState.toggle() - return true - } - if let keyContext = contextForMainWindow(NSApp.keyWindow) { - if let window = keyContext.window ?? windowForMainWindowId(keyContext.windowId) { - setActiveMainWindow(window) - } - keyContext.sidebarState.toggle() - return true - } - if let mainContext = contextForMainWindow(NSApp.mainWindow) { - if let window = mainContext.window ?? windowForMainWindowId(mainContext.windowId) { - setActiveMainWindow(window) - } - mainContext.sidebarState.toggle() - return true - } - if let fallbackContext = mainWindowContexts.values.first { - if let window = fallbackContext.window ?? windowForMainWindowId(fallbackContext.windowId) { - setActiveMainWindow(window) - } - fallbackContext.sidebarState.toggle() - return true - } - if let sidebarState { - sidebarState.toggle() - return true - } - return false + func tabManagerFor(windowId: UUID) -> TabManager? { + mainWindowContexts.values.first(where: { $0.windowId == windowId })?.tabManager } - func sidebarVisibility(windowId: UUID) -> Bool? { - mainWindowContexts.values.first(where: { $0.windowId == windowId })?.sidebarState.isVisible + func windowId(for tabManager: TabManager) -> UUID? { + mainWindowContexts.values.first(where: { $0.tabManager === tabManager })?.windowId } - /// Toggle the sidebar for the window identified by the given NSWindow. - /// Used by titlebar accessory buttons so they affect their own window - /// rather than the currently focused/key window (#1779). - func toggleSidebarForWindow(_ window: NSWindow?) -> Bool { - if let window { - guard let context = contextForMainTerminalWindow(window) else { - return false - } - context.sidebarState.toggle() - return true - } - return toggleSidebarInActiveMainWindow() + func mainWindow(for windowId: UUID) -> NSWindow? { + windowForMainWindowId(windowId) } - func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { - let menu = NSMenu(title: "") - let newWindowItem = NSMenuItem( - title: String(localized: "menu.file.newWindow", defaultValue: "New Window"), - action: #selector(openNewMainWindow(_:)), - keyEquivalent: "" - ) - newWindowItem.target = self - menu.addItem(newWindowItem) - return menu + func mainWindowContainingWorkspace(_ workspaceId: UUID) -> NSWindow? { + for context in mainWindowContexts.values where context.tabManager.tabs.contains(where: { $0.id == workspaceId }) { + if let window = context.window ?? windowForMainWindowId(context.windowId) { + return window + } + } + return nil } - @objc func openNewMainWindow(_ sender: Any?) { - _ = createMainWindow() - } + func scriptableMainWindows() -> [ScriptableMainWindowState] { + var results: [ScriptableMainWindowState] = [] + var seen: Set = [] - /// Shows the "Open Folder" panel and creates a workspace for the selected directory. - /// Called from both the SwiftUI menu and `handleCustomShortcut`. - func showOpenFolderPanel() { - let panel = NSOpenPanel() - panel.canChooseFiles = false - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - panel.title = String(localized: "menu.file.openFolder.panelTitle", defaultValue: "Open Folder") - panel.prompt = String(localized: "menu.file.openFolder.panelPrompt", defaultValue: "Open") - // Seed the panel with the active workspace's directory. Use the shared - // main-window resolver so this works even when an auxiliary window is key. - if let context = preferredMainWindowContextForWorkspaceCreation(debugSource: "openFolderPanel.seed"), - let cwd = context.tabManager.selectedWorkspace?.currentDirectory, - !cwd.isEmpty { - panel.directoryURL = URL(fileURLWithPath: cwd) + for window in NSApp.orderedWindows { + guard let context = contextForMainTerminalWindow(window, reindex: false) else { continue } + guard seen.insert(context.windowId).inserted else { continue } + results.append( + ScriptableMainWindowState( + windowId: context.windowId, + tabManager: context.tabManager, + window: context.window ?? windowForMainWindowId(context.windowId) + ) + ) } - if panel.runModal() == .OK, let url = panel.url { - openWorkspaceForExternalDirectory( - workingDirectory: url.path, - debugSource: "shortcut.openFolder" + + let remaining = mainWindowContexts.values + .sorted { $0.windowId.uuidString < $1.windowId.uuidString } + .filter { seen.insert($0.windowId).inserted } + + for context in remaining { + results.append( + ScriptableMainWindowState( + windowId: context.windowId, + tabManager: context.tabManager, + window: context.window ?? windowForMainWindowId(context.windowId) + ) ) } + + return results } - @discardableResult - func openDirectoryInInlineVSCode( - _ directoryURL: URL, - tabManager preferredTabManager: TabManager? = nil - ) -> Bool { - guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscodeInline.applicationURL() else { - return false + func scriptableMainWindow(windowId: UUID) -> ScriptableMainWindowState? { + guard let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) else { + return nil } + return ScriptableMainWindowState( + windowId: context.windowId, + tabManager: context.tabManager, + window: context.window ?? windowForMainWindowId(context.windowId) + ) + } - let targetTabManager = preferredTabManager - ?? preferredMainWindowContextForWorkspaceCreation(debugSource: "inlineVSCode.open.target")?.tabManager - guard let targetTabManager else { + func scriptableMainWindowForTab(_ tabId: UUID) -> ScriptableMainWindowState? { + guard let context = contextContainingTabId(tabId) else { return nil } + return ScriptableMainWindowState( + windowId: context.windowId, + tabManager: context.tabManager, + window: context.window ?? windowForMainWindowId(context.windowId) + ) + } + + @discardableResult + func focusScriptableMainWindow(windowId: UUID, bringToFront shouldBringToFront: Bool) -> Bool { + guard let state = scriptableMainWindow(windowId: windowId), + let window = state.window else { return false } - - let targetWorkspaceId = targetTabManager.selectedWorkspace?.id - ?? targetTabManager.tabs.first?.id - ?? targetTabManager.addWorkspace(select: true).id - let normalizedDirectoryURL = directoryURL.standardizedFileURL - - VSCodeServeWebController.shared.ensureServeWebURL(vscodeApplicationURL: vscodeApplicationURL) { serveWebURL in - guard let serveWebURL, - let openFolderURL = VSCodeServeWebURLBuilder.openFolderURL( - baseWebUIURL: serveWebURL, - directoryPath: normalizedDirectoryURL.path - ) else { - NSSound.beep() - return - } - - guard targetTabManager.openBrowser( - inWorkspace: targetWorkspaceId, - url: openFolderURL, - preferSplitRight: true - ) != nil else { - NSSound.beep() - return - } + setActiveMainWindow(window) + if shouldBringToFront { + bringToFront(window) } - return true } - func showOpenFolderInInlineVSCodePanel(tabManager preferredTabManager: TabManager? = nil) { - guard TerminalDirectoryOpenTarget.vscodeInline.isAvailable() else { - NSSound.beep() - return + @discardableResult + func addWorkspace(windowId: UUID, workingDirectory: String? = nil, bringToFront shouldBringToFront: Bool = false) -> UUID? { + guard let state = scriptableMainWindow(windowId: windowId) else { return nil } + if shouldBringToFront, let window = state.window { + setActiveMainWindow(window) + bringToFront(window) } + let workspace = state.tabManager.addWorkspace( + workingDirectory: workingDirectory, + select: shouldBringToFront + ) + return workspace.id + } - let targetTabManager = preferredTabManager - ?? preferredMainWindowContextForWorkspaceCreation(debugSource: "inlineVSCode.panel.target")?.tabManager - guard let targetTabManager else { - NSSound.beep() - return - } + private func commandPaletteState(for windowId: UUID) -> CommandPaletteWindowState { + commandPaletteStateByWindowId[windowId] ?? CommandPaletteWindowState() + } - let panel = NSOpenPanel() - panel.canChooseFiles = false - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - panel.title = String( - localized: "menu.file.openFolderInVSCodeInline.panelTitle", - defaultValue: "Open Folder in VS Code (Inline)" - ) - panel.prompt = String( - localized: "menu.file.openFolderInVSCodeInline.panelPrompt", - defaultValue: "Open in VS Code" - ) - if let cwd = targetTabManager.selectedWorkspace?.currentDirectory, - !cwd.isEmpty { - panel.directoryURL = URL(fileURLWithPath: cwd) + private func updateCommandPaletteState(for windowId: UUID, _ mutate: (inout CommandPaletteWindowState) -> Void) { + var state = commandPaletteState(for: windowId) + mutate(&state) + commandPaletteStateByWindowId[windowId] = state + } + + private func teardownCommandPaletteState(for windowId: UUID) { + commandPaletteStateByWindowId.removeValue(forKey: windowId) + } + + private func markCommandPaletteOpenRequested(for window: NSWindow?) { + guard let window, + let windowId = mainWindowId(for: window) else { return } + updateCommandPaletteState(for: windowId) { state in + state.pendingOpen = true + state.recentRequestAt = ProcessInfo.processInfo.systemUptime } + } - if panel.runModal() == .OK, - let url = panel.url, - !openDirectoryInInlineVSCode(url, tabManager: targetTabManager) { - NSSound.beep() + private func postCommandPaletteRequest( + name: Notification.Name, + preferredWindow: NSWindow?, + source: String, + markPending: Bool + ) { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + if markPending { + markCommandPaletteOpenRequested(for: targetWindow) } + NotificationCenter.default.post(name: name, object: targetWindow) +#if DEBUG + dlog( + "shortcut.palette.request source=\(source) " + + "target={\(debugWindowToken(targetWindow))} " + + "pendingMarked=\(markPending ? 1 : 0)" + ) +#endif } - @objc func openWindow( - _ pasteboard: NSPasteboard, - userData: String?, - error: AutoreleasingUnsafeMutablePointer - ) { - openFromServicePasteboard(pasteboard, target: .window, error: error) + func requestCommandPaletteCommands(preferredWindow: NSWindow? = nil, source: String = "api.commandPalette") { + postCommandPaletteRequest( + name: .commandPaletteRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) } - @objc func openTab( - _ pasteboard: NSPasteboard, - userData: String?, - error: AutoreleasingUnsafeMutablePointer - ) { - openFromServicePasteboard(pasteboard, target: .workspace, error: error) + func requestCommandPaletteSwitcher(preferredWindow: NSWindow? = nil, source: String = "api.commandPaletteSwitcher") { + postCommandPaletteRequest( + name: .commandPaletteSwitcherRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) } - private enum ServiceOpenTarget { - case window - case workspace + func requestCommandPaletteRenameTab(preferredWindow: NSWindow? = nil, source: String = "api.commandPaletteRenameTab") { + postCommandPaletteRequest( + name: .commandPaletteRenameTabRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) } - private func openFromServicePasteboard( - _ pasteboard: NSPasteboard, - target: ServiceOpenTarget, - error: AutoreleasingUnsafeMutablePointer + func requestCommandPaletteRenameWorkspace( + preferredWindow: NSWindow? = nil, + source: String = "api.commandPaletteRenameWorkspace" ) { - let pathURLs = servicePathURLs(from: pasteboard) - guard !pathURLs.isEmpty else { - error.pointee = Self.serviceErrorNoPath - return - } - - let directories = externalOpenDirectories(from: pathURLs) - guard !directories.isEmpty else { - error.pointee = Self.serviceErrorNoPath - return - } + postCommandPaletteRequest( + name: .commandPaletteRenameWorkspaceRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) + } - prepareForExplicitOpenIntentAtStartup() - for directory in directories { - switch target { - case .window: - _ = createMainWindow(initialWorkingDirectory: directory) - case .workspace: - openWorkspaceFromService(workingDirectory: directory) - } - } + func requestCommandPaletteEditWorkspaceDescription( + preferredWindow: NSWindow? = nil, + source: String = "api.commandPaletteEditWorkspaceDescription" + ) { + postCommandPaletteRequest( + name: .commandPaletteEditWorkspaceDescriptionRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) } - private func servicePathURLs(from pasteboard: NSPasteboard) -> [URL] { - if let pathURLs = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], !pathURLs.isEmpty { - return pathURLs + private func clearCommandPalettePendingOpen(for window: NSWindow?) { + guard let window, + let windowId = mainWindowId(for: window) else { return } + updateCommandPaletteState(for: windowId) { state in + state.pendingOpen = false + state.recentRequestAt = nil } + } - let filenamesType = NSPasteboard.PasteboardType(rawValue: "NSFilenamesPboardType") - if let paths = pasteboard.propertyList(forType: filenamesType) as? [String] { - let urls = paths.map { URL(fileURLWithPath: $0) } - if !urls.isEmpty { - return urls + private func pruneExpiredCommandPalettePendingOpenStates( + now: TimeInterval = ProcessInfo.processInfo.systemUptime + ) { + for windowId in Array(commandPaletteStateByWindowId.keys) { + guard commandPaletteStateByWindowId[windowId]?.pendingOpen == true else { continue } + guard let requestedAt = commandPaletteStateByWindowId[windowId]?.recentRequestAt else { + commandPaletteStateByWindowId[windowId]?.pendingOpen = false +#if DEBUG + dlog("shortcut.palette.pendingPrune windowId=\(windowId.uuidString.prefix(8)) reason=missingTimestamp") +#endif + continue } + let age = now - requestedAt + guard age > Self.commandPalettePendingOpenMaxAge else { continue } + commandPaletteStateByWindowId[windowId]?.pendingOpen = false + commandPaletteStateByWindowId[windowId]?.recentRequestAt = nil +#if DEBUG + dlog( + "shortcut.palette.pendingPrune windowId=\(windowId.uuidString.prefix(8)) " + + "reason=stale ageMs=\(Int(age * 1000))" + ) +#endif } - - if let raw = pasteboard.string(forType: .string), !raw.isEmpty { - return raw - .split(whereSeparator: \.isNewline) - .map { line in - let text = String(line).trimmingCharacters(in: .whitespacesAndNewlines) - if let fileURL = URL(string: text), fileURL.isFileURL { - return fileURL - } - return URL(fileURLWithPath: text) - } - } - - return [] } - private func openWorkspaceFromService(workingDirectory: String) { - openWorkspaceForExternalDirectory( - workingDirectory: workingDirectory, - debugSource: "service.openTab" - ) + private func isCommandPalettePendingOpen(for window: NSWindow) -> Bool { + guard let windowId = mainWindowId(for: window) else { return false } + pruneExpiredCommandPalettePendingOpenStates() + return commandPaletteStateByWindowId[windowId]?.pendingOpen == true } - private func prepareForExplicitOpenIntentAtStartup() { - didHandleExplicitOpenIntentAtStartup = true - if !didAttemptStartupSessionRestore { - startupSessionSnapshot = nil - didAttemptStartupSessionRestore = true + private func beginCommandPaletteEscapeSuppression(for window: NSWindow?) { + guard let window, + let windowId = mainWindowId(for: window) else { return } + updateCommandPaletteState(for: windowId) { state in + state.escapeSuppressed = true + state.escapeSuppressionStartedAt = ProcessInfo.processInfo.systemUptime } } - 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. - FinderServicePathResolver.orderedUniqueDirectories( - from: urls.filter { $0.isFileURL }, - excludingDescendantsOf: [Bundle.main.bundleURL] - ) + private func endCommandPaletteEscapeSuppression(for window: NSWindow?) { + guard let window, + let windowId = mainWindowId(for: window) else { return } + updateCommandPaletteState(for: windowId) { state in + state.escapeSuppressed = false + state.escapeSuppressionStartedAt = nil + } } - private func openWorkspaceForExternalDirectory( - workingDirectory: String, - debugSource: String - ) { - if addWorkspaceInPreferredMainWindow( - workingDirectory: workingDirectory, - shouldBringToFront: true, - debugSource: debugSource - ) != nil { - return + private func shouldConsumeSuppressedEscape(event: NSEvent, window: NSWindow?) -> Bool { + guard let window, + let windowId = mainWindowId(for: window), + commandPaletteStateByWindowId[windowId]?.escapeSuppressed == true else { + return false } - _ = createMainWindow(initialWorkingDirectory: workingDirectory) + let startedAt = commandPaletteStateByWindowId[windowId]?.escapeSuppressionStartedAt ?? 0 + if ProcessInfo.processInfo.systemUptime - startedAt <= 0.35 { + return true + } + // Fallback cleanup when keyUp is lost for any reason. + endCommandPaletteEscapeSuppression(for: window) + return false } - @discardableResult - func addWorkspaceInPreferredMainWindow( - workingDirectory: String? = nil, - shouldBringToFront: Bool = false, - event: NSEvent? = nil, - debugSource: String = "unspecified" - ) -> UUID? { - #if DEBUG - logWorkspaceCreationRouting( - phase: "request", - source: debugSource, - reason: "add_workspace", - event: event, - chosenContext: nil, - workingDirectory: workingDirectory - ) - #endif - guard let context = preferredMainWindowContextForWorkspaceCreation(event: event, debugSource: debugSource) else { - #if DEBUG - logWorkspaceCreationRouting( - phase: "no_context", - source: debugSource, - reason: "context_selection_failed", - event: event, - chosenContext: nil, - workingDirectory: workingDirectory - ) - #endif + private func recentCommandPaletteRequestAge(for window: NSWindow?) -> TimeInterval? { + guard let window, + let windowId = mainWindowId(for: window) else { return nil } - guard let window = resolvedWindow(for: context) else { - #if DEBUG - logWorkspaceCreationRouting( - phase: "no_context", - source: debugSource, - reason: "context_window_missing", - event: event, - chosenContext: context, - workingDirectory: workingDirectory - ) - #endif - discardOrphanedMainWindowContext(context) + let now = ProcessInfo.processInfo.systemUptime + pruneExpiredCommandPalettePendingOpenStates(now: now) + guard commandPaletteStateByWindowId[windowId]?.pendingOpen == true else { + commandPaletteStateByWindowId[windowId]?.recentRequestAt = nil return nil } - setActiveMainWindow(window) - if shouldBringToFront { - bringToFront(window) + guard let startedAt = commandPaletteStateByWindowId[windowId]?.recentRequestAt else { + commandPaletteStateByWindowId[windowId]?.pendingOpen = false + return nil } - - let workspace: Workspace - if let workingDirectory { - workspace = context.tabManager.addWorkspace(workingDirectory: workingDirectory, select: true) - } else { - workspace = context.tabManager.addTab(select: true) + let age = now - startedAt + if age <= Self.commandPaletteRequestGraceInterval { + return age } - #if DEBUG - logWorkspaceCreationRouting( - phase: "created", - source: debugSource, - reason: "workspace_created", - event: event, - chosenContext: context, - workspaceId: workspace.id, - workingDirectory: workingDirectory - ) - #endif - return workspace.id + return nil } - private func preferredMainWindowContextForWorkspaceCreation( - event: NSEvent? = nil, - debugSource: String = "unspecified" - ) -> MainWindowContext? { - if let context = mainWindowContext(forShortcutEvent: event, debugSource: debugSource) { - return context - } + private func escapeSuppressionWindow(for event: NSEvent) -> NSWindow? { + commandPaletteWindowForShortcutEvent(event) ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + } - // If a keyboard event identifies a specific window but that context - // can't be resolved, do not fall back to another window. - if shortcutEventHasAddressableWindow(event) { + @discardableResult + private func clearEscapeSuppressionForKeyUp(event: NSEvent, consumeIfSuppressed: Bool = false) -> Bool { + guard event.type == .keyUp, event.keyCode == 53 else { return false } + let suppressionWindow = escapeSuppressionWindow(for: event) + let didConsume = consumeIfSuppressed && shouldConsumeSuppressedEscape(event: event, window: suppressionWindow) + if let window = suppressionWindow { + endCommandPaletteEscapeSuppression(for: window) #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "event_context_required_no_fallback", - event: event, - chosenContext: nil + dlog( + "shortcut.escape suppressionClear target={\(debugWindowToken(window))} " + + "keyUpConsumed=\(didConsume ? 1 : 0)" ) #endif - return nil + return didConsume + } + for windowId in commandPaletteStateByWindowId.keys { + commandPaletteStateByWindowId[windowId]?.escapeSuppressed = false + commandPaletteStateByWindowId[windowId]?.escapeSuppressionStartedAt = nil } +#if DEBUG + dlog("shortcut.escape suppressionClear target={nil} clearedAll=1 keyUpConsumed=\(didConsume ? 1 : 0)") +#endif + return didConsume + } - if let keyWindow = NSApp.keyWindow, - let context = contextForMainTerminalWindow(keyWindow) { + func setCommandPaletteVisible(_ visible: Bool, for window: NSWindow) { + guard let windowId = mainWindowId(for: window) else { return } + let wasVisible = commandPaletteState(for: windowId).isVisible + updateCommandPaletteState(for: windowId) { $0.isVisible = visible } + // Opening (false -> true) always resolves pending-open. + // Closing (true -> false) also clears stale pending state. + // Ignore repeated false updates so a stale sync cannot erase an in-flight open request. + if visible || wasVisible { + commandPaletteStateByWindowId[windowId]?.pendingOpen = false + commandPaletteStateByWindowId[windowId]?.recentRequestAt = nil + } #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "key_window", - event: event, - chosenContext: context + if !visible, + !wasVisible, + commandPaletteStateByWindowId[windowId]?.pendingOpen == true { + dlog( + "palette.visibility.retainPending " + + "window={\(debugWindowToken(window))} visible=0 wasVisible=0 pending=1" ) - #endif - return context } +#endif + } - if let mainWindow = NSApp.mainWindow, - let context = contextForMainTerminalWindow(mainWindow) { - #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "main_window", - event: event, - chosenContext: context - ) - #endif - return context - } + func isCommandPaletteVisible(windowId: UUID) -> Bool { + commandPaletteStateByWindowId[windowId]?.isVisible ?? false + } - for window in NSApp.orderedWindows where isMainTerminalWindow(window) { - if let context = contextForMainTerminalWindow(window) { - #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "ordered_windows", - event: event, - chosenContext: context - ) - #endif - return context - } - } + func setCommandPaletteSelectionIndex(_ index: Int, for window: NSWindow) { + guard let windowId = mainWindowId(for: window) else { return } + updateCommandPaletteState(for: windowId) { $0.selectionIndex = max(0, index) } + } - let fallback = mainWindowContexts.values.first(where: { resolvedWindow(for: $0) != nil }) - #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "fallback_first_context", - event: event, - chosenContext: fallback - ) -#endif - return fallback + func commandPaletteSelectionIndex(windowId: UUID) -> Int { + commandPaletteStateByWindowId[windowId]?.selectionIndex ?? 0 } - private func shortcutEventHasAddressableWindow(_ event: NSEvent?) -> Bool { - guard let event else { return false } - // NSEvent.windowNumber can be 0 for responder-chain events that are not - // actually bound to an NSWindow (notably some WebKit key paths). - return event.window != nil || event.windowNumber > 0 + func setCommandPaletteSnapshot(_ snapshot: CommandPaletteDebugSnapshot, for window: NSWindow) { + guard let windowId = mainWindowId(for: window) else { return } + updateCommandPaletteState(for: windowId) { $0.snapshot = snapshot } } - private func mainWindowContext( - forShortcutEvent event: NSEvent?, - debugSource: String = "unspecified" - ) -> MainWindowContext? { - guard let event else { return nil } + func commandPaletteSnapshot(windowId: UUID) -> CommandPaletteDebugSnapshot { + commandPaletteStateByWindowId[windowId]?.snapshot ?? .empty + } - if let eventWindow = event.window, - let context = contextForMainTerminalWindow(eventWindow) { - #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "event_window", - event: event, - chosenContext: context - ) - #endif - return context - } + func isCommandPaletteVisible(for window: NSWindow) -> Bool { + guard let windowId = mainWindowId(for: window) else { return false } + return commandPaletteStateByWindowId[windowId]?.isVisible ?? false + } - if event.windowNumber > 0, - let numberedWindow = NSApp.window(withWindowNumber: event.windowNumber), - let context = contextForMainTerminalWindow(numberedWindow) { - #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "event_window_number", - event: event, - chosenContext: context - ) - #endif - return context - } + func isCommandPaletteEffectivelyVisible(for window: NSWindow) -> Bool { + isCommandPaletteEffectivelyVisible(in: window) + } - if event.windowNumber > 0, - let context = mainWindowContexts.values.first(where: { candidate in - let window = candidate.window ?? windowForMainWindowId(candidate.windowId) - return window?.windowNumber == event.windowNumber - }) { - #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "event_window_number_scan", - event: event, - chosenContext: context - ) - #endif - return context + func shouldBlockFirstResponderChangeWhileCommandPaletteVisible( + window: NSWindow, + responder: NSResponder? + ) -> Bool { + guard isCommandPaletteVisible(for: window) else { return false } + guard let responder else { return false } + guard !isCommandPaletteResponder(responder) else { return false } + return isFocusStealingResponderWhileCommandPaletteVisible(responder) + } + + private func isCommandPaletteResponder(_ responder: NSResponder) -> Bool { + if let textView = responder as? NSTextView, textView.isFieldEditor { + if let delegateView = textView.delegate as? NSView { + return isInsideCommandPaletteOverlay(delegateView) + } + // SwiftUI can attach a non-view delegate to TextField editors. + // When command palette is visible, its search/rename editor is the + // only expected field editor inside the main window. + return true } + if let view = responder as? NSView { + return isInsideCommandPaletteOverlay(view) + } + return false + } - #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "event_context_not_found", - event: event, - chosenContext: nil - ) - #endif - return nil + private func isFocusStealingResponderWhileCommandPaletteVisible(_ responder: NSResponder) -> Bool { + isCommandPaletteFocusStealingTerminalOrBrowserResponder(responder) } - private func preferredMainWindowContextForShortcutRouting(event: NSEvent) -> MainWindowContext? { - if let context = mainWindowContext(forShortcutEvent: event, debugSource: "shortcut.routing") { - return context + private func isInsideCommandPaletteOverlay(_ view: NSView) -> Bool { + var current: NSView? = view + while let candidate = current { + if candidate.identifier == commandPaletteOverlayContainerIdentifier { + return true + } + current = candidate.superview } + return false + } - if shortcutEventHasAddressableWindow(event) { - if let eventWindow = resolvedShortcutEventWindow(event), - programaWindowShouldOwnCloseShortcut(eventWindow) { - // Auxiliary cmux windows do not own a terminal tab manager. Let them fall back - // to the active main terminal window so app shortcuts like Cmd+W still route. - } else { -#if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: "shortcut.routing", - reason: "event_context_required_no_fallback", - event: event, - chosenContext: nil - ) -#endif - return nil + private func keyRoutingOwnerView(for responder: NSResponder?) -> NSView? { + guard let responder else { return nil } + if let editor = responder as? NSTextView, + editor.isFieldEditor { + return programaFieldEditorOwnerView(editor) ?? editor + } + return responder as? NSView + } + + private func responderHasViableKeyRoutingOwner( + _ responder: NSResponder, + in window: NSWindow + ) -> Bool { + if let ghosttyView = cmuxOwningGhosttyView(for: responder) { + if ghosttyView.window !== window { + return false + } + if ghosttyView.isHiddenOrHasHiddenAncestor { + return false } + return ghosttyView === window.contentView || ghosttyView.superview != nil } - if let keyWindow = NSApp.keyWindow, - let context = contextForMainTerminalWindow(keyWindow) { - return context + guard let ownerView = keyRoutingOwnerView(for: responder) else { + return false } - if let mainWindow = NSApp.mainWindow, - let context = contextForMainTerminalWindow(mainWindow) { - return context + if ownerView.window !== window { + return false } - if let activeManager = tabManager, - let context = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { - return context + if ownerView.isHiddenOrHasHiddenAncestor { + return false + } + + if ownerView !== window.contentView, ownerView.superview == nil { + return false } - return mainWindowContexts.values.first + return true } - @discardableResult - private func synchronizeShortcutRoutingContext(event: NSEvent) -> Bool { - guard let context = preferredMainWindowContextForShortcutRouting(event: event) else { -#if DEBUG - FocusLogStore.shared.append( - "shortcut.route reason=no_context_no_fallback eventWin=\(event.windowNumber) keyCode=\(event.keyCode)" - ) -#endif + private func responderNeedsFocusedTerminalKeyRepair( + _ responder: NSResponder?, + in window: NSWindow, + hostedView: GhosttySurfaceScrollView + ) -> Bool { + guard let responder else { return true } + if responder is NSWindow { return true } + guard responderHasViableKeyRoutingOwner(responder, in: window) else { + return true + } + if hostedView.responderMatchesPreferredKeyboardFocus(responder) { return false } + return true + } - let alreadyActive = - tabManager === context.tabManager - && sidebarState === context.sidebarState - && sidebarSelectionState === context.sidebarSelectionState - if alreadyActive { return true } - - if let window = context.window ?? windowForMainWindowId(context.windowId) { - setActiveMainWindow(window) - } else { - tabManager = context.tabManager - sidebarState = context.sidebarState - sidebarSelectionState = context.sidebarSelectionState - TerminalController.shared.setActiveTabManager(context.tabManager) + func repairFocusedTerminalKeyboardRoutingIfNeeded( + window: NSWindow, + event: NSEvent + ) { + guard event.type == .keyDown else { return } + let normalizedFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + guard !normalizedFlags.contains(.command) else { return } + 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, + let terminalPanel = workspace.terminalPanel(for: panelId) else { + return } + guard responderNeedsFocusedTerminalKeyRepair( + window.firstResponder, + in: window, + hostedView: terminalPanel.hostedView + ) else { return } #if DEBUG - FocusLogStore.shared.append( - "shortcut.route reason=sync activeTM=\(pointerString(tabManager)) chosen={\(summarizeContextForWorkspaceRouting(context))}" + let before = window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let target = terminalPanel.hostedView.preferredPanelFocusIntentForActivation() + dlog( + "focus.keyRepair attempt window=\(ObjectIdentifier(window)) " + + "workspace=\(String(workspace.id.uuidString.prefix(5))) " + + "panel=\(String(panelId.uuidString.prefix(5))) " + + "target=\(target == .findField ? "searchField" : "surface") " + + "fr=\(before) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue)" ) #endif - return true - } - @discardableResult - func createMainWindow( - initialWorkingDirectory: String? = nil, - sessionWindowSnapshot: SessionWindowSnapshot? = nil - ) -> UUID { - let windowId = UUID() - let tabManager = TabManager(initialWorkingDirectory: initialWorkingDirectory) - if let tabManagerSnapshot = sessionWindowSnapshot?.tabManager { - tabManager.restoreSessionSnapshot(tabManagerSnapshot) - } + terminalPanel.hostedView.ensureFocus(for: workspace.id, surfaceId: panelId) - let sidebarWidth = sessionWindowSnapshot?.sidebar.width - .map(SessionPersistencePolicy.sanitizedSidebarWidth) - ?? SessionPersistencePolicy.defaultSidebarWidth - let sidebarState = SidebarState( - isVisible: sessionWindowSnapshot?.sidebar.isVisible ?? true, - persistedWidth: CGFloat(sidebarWidth) - ) - let sidebarSelectionState = SidebarSelectionState( - selection: sessionWindowSnapshot?.sidebar.selection.sidebarSelection ?? .tabs +#if DEBUG + let after = window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "focus.keyRepair result window=\(ObjectIdentifier(window)) " + + "panel=\(String(panelId.uuidString.prefix(5))) " + + "isSurfaceResponder=\(terminalPanel.hostedView.isSurfaceViewFirstResponder() ? 1 : 0) " + + "fr=\(after)" ) - let notificationStore = TerminalNotificationStore.shared +#endif + } - let programaConfigStore = ProgramaConfigStore() - programaConfigStore.wireDirectoryTracking(tabManager: tabManager) - programaConfigStore.loadAll() + func locateSurface(surfaceId: UUID) -> (windowId: UUID, workspaceId: UUID, tabManager: TabManager)? { + for ctx in mainWindowContexts.values { + for ws in ctx.tabManager.tabs { + if ws.panels[surfaceId] != nil { + return (ctx.windowId, ws.id, ctx.tabManager) + } + } + } + return nil + } - let root = ContentView(updateViewModel: updateViewModel, windowId: windowId) - .environmentObject(tabManager) - .environmentObject(notificationStore) - .environmentObject(sidebarState) - .environmentObject(sidebarSelectionState) - .environmentObject(programaConfigStore) + /// Resolve the workspace that currently owns a panel/surface ID. + /// Prefer the provided workspace when available, then fall back to global lookup. + func workspaceContainingPanel( + panelId: UUID, + preferredWorkspaceId: UUID? = nil + ) -> (workspace: Workspace, tabManager: TabManager)? { + if let preferredWorkspaceId, + let manager = tabManagerFor(tabId: preferredWorkspaceId), + let workspace = manager.tabs.first(where: { $0.id == preferredWorkspaceId }), + workspace.panels[panelId] != nil { + return (workspace, manager) + } - // Use the current key window's size for new windows so Cmd+Shift+N - // creates a window matching the previous one's dimensions. - let styleMask: NSWindow.StyleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView] - let sourceContext = preferredMainWindowContextForWorkspaceCreation( - debugSource: "createMainWindow.initialGeometry" - ) - let sourceWindow = sourceContext.flatMap { resolvedWindow(for: $0) } - let existingFrame = sourceWindow?.frame - let sourceWindowIsNativeFullScreen: Bool = { -#if DEBUG - if let debugCreateMainWindowSourceIsNativeFullScreenOverride { - return debugCreateMainWindowSourceIsNativeFullScreenOverride - } -#endif - return sourceWindow?.styleMask.contains(.fullScreen) == true - }() - let shouldTemporarilyDisallowFullScreenTiling = - sessionWindowSnapshot == nil && sourceWindowIsNativeFullScreen - let initialRect: NSRect - if sessionWindowSnapshot == nil, let existingFrame { - // Convert frame rect to content rect so the new window matches the - // source window's actual size (frame includes titlebar insets). - initialRect = NSWindow.contentRect(forFrameRect: existingFrame, styleMask: styleMask) - } else { - initialRect = NSRect(x: 0, y: 0, width: 460, height: 360) + if let located = locateSurface(surfaceId: panelId), + let workspace = located.tabManager.tabs.first(where: { $0.id == located.workspaceId }), + workspace.panels[panelId] != nil { + return (workspace, located.tabManager) } - let window = NSWindow( - contentRect: initialRect, - styleMask: styleMask, - backing: .buffered, - defer: false - ) - // When creating a new window from an existing native fullscreen window, - // temporarily opt out of fullscreen tiling so AppKit doesn't place the - // new window into the active fullscreen Space. - if shouldTemporarilyDisallowFullScreenTiling { - window.collectionBehavior.insert(.fullScreenDisallowsTiling) + if let preferredWorkspaceId, + let manager = tabManagerFor(tabId: preferredWorkspaceId) ?? tabManager, + let workspace = manager.tabs.first(where: { $0.id == preferredWorkspaceId }), + workspace.panels[panelId] != nil { + return (workspace, manager) } - window.title = "" - window.titleVisibility = .hidden - window.titlebarAppearsTransparent = true - window.isMovableByWindowBackground = false - window.isMovable = false - let restoredFrame = resolvedWindowFrame(from: sessionWindowSnapshot) - if let restoredFrame { - window.setFrame(restoredFrame, display: false) - } else { - window.center() - // Cascade using the same algorithm as upstream Ghostty: seed from - // the window's own top-left on the first call, then advance the - // cascade point for each subsequent window. - if mainWindowContexts.count >= 1 { - lastCascadePoint = window.cascadeTopLeft(from: lastCascadePoint) - } else { - lastCascadePoint = window.cascadeTopLeft(from: NSPoint(x: window.frame.minX, y: window.frame.maxY)) - } + + if let manager = tabManager, + let workspace = manager.tabs.first(where: { $0.panels[panelId] != nil }) { + return (workspace, manager) } - window.contentView = MainWindowHostingView(rootView: root) - // Apply shared window styling. - attachUpdateAccessory(to: window) - applyWindowDecorations(to: window) + return nil + } - // Keep a strong reference so the window isn't deallocated. - let controller = MainWindowController(window: window) - controller.onClose = { [weak self, weak controller] in - guard let self, let controller else { return } - self.mainWindowControllers.removeAll(where: { $0 === controller }) + func locateGhosttySurface(_ surface: ghostty_surface_t?) -> (windowId: UUID, workspaceId: UUID, panelId: UUID, tabManager: TabManager)? { + guard let surface else { return nil } + for ctx in mainWindowContexts.values { + for ws in ctx.tabManager.tabs { + for (panelId, panel) in ws.panels { + guard let terminal = panel as? TerminalPanel else { continue } + if terminal.surface.surface == surface { + return (ctx.windowId, ws.id, panelId, ctx.tabManager) + } + } + } } - window.delegate = controller - mainWindowControllers.append(controller) + return nil + } - registerMainWindow( - window, - windowId: windowId, - tabManager: tabManager, - sidebarState: sidebarState, - sidebarSelectionState: sidebarSelectionState - ) - installFileDropOverlay(on: window, tabManager: tabManager) - if TerminalController.shouldSuppressSocketCommandActivation() { - window.orderFront(nil) - if TerminalController.socketCommandAllowsInAppFocusMutations() { - setActiveMainWindow(window) + func refreshTerminalSurfacesAfterGhosttyConfigReload(source: String) { + var refreshedCount = 0 + forEachTerminalPanel { terminalPanel in + terminalPanel.hostedView.reconcileGeometryNow() + terminalPanel.hostedView.refreshHostBackgroundAfterGhosttyConfigReload() + terminalPanel.surface.forceRefresh(reason: "appDelegate.refreshAfterGhosttyConfigReload") + refreshedCount += 1 + } +#if DEBUG + dlog("reload.config.surfaceRefresh source=\(source) count=\(refreshedCount)") +#endif + } + + private func forEachTerminalPanel(_ body: (TerminalPanel) -> Void) { + var seenManagers: Set = [] + + func visitManager(_ manager: TabManager?) { + guard let manager else { return } + let managerId = ObjectIdentifier(manager) + guard seenManagers.insert(managerId).inserted else { return } + for workspace in manager.tabs { + for panel in workspace.panels.values { + guard let terminalPanel = panel as? TerminalPanel else { continue } + body(terminalPanel) + } } - } else { - window.makeKeyAndOrderFront(nil) - setActiveMainWindow(window) - NSApp.activate(ignoringOtherApps: true) } - if shouldTemporarilyDisallowFullScreenTiling { - DispatchQueue.main.async { [weak window] in - window?.collectionBehavior.remove(.fullScreenDisallowsTiling) - } + + visitManager(tabManager) + for context in mainWindowContexts.values { + visitManager(context.tabManager) } - if let restoredFrame { - window.setFrame(restoredFrame, display: true) -#if DEBUG - dlog( - "session.restore.frameApplied window=\(windowId.uuidString.prefix(8)) " + - "applied={\(debugNSRectDescription(window.frame))}" - ) -#endif + } + + func focusMainWindow(windowId: UUID) -> Bool { + guard let window = windowForMainWindowId(windowId) else { return false } + if TerminalController.shouldSuppressSocketCommandActivation(), + !TerminalController.socketCommandAllowsInAppFocusMutations() { + setActiveMainWindow(window) + return true } - return windowId + bringToFront(window) + return true } - @objc func checkForUpdates(_ sender: Any?) { - updateViewModel.overrideState = nil - updateController.checkForUpdates() + func closeMainWindow(windowId: UUID) -> Bool { + guard let window = windowForMainWindowId(windowId) else { return false } + window.performClose(nil) + return true } - func checkForUpdatesInCustomUI() { - updateViewModel.overrideState = nil - updateController.checkForUpdatesInCustomUI() + private func confirmCloseMainWindow(_ window: NSWindow) -> Bool { +#if DEBUG + if let debugCloseMainWindowConfirmationHandler { + return debugCloseMainWindowConfirmationHandler(window) + } +#endif + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(localized: "dialog.closeWindow.title", defaultValue: "Close window?") + alert.informativeText = String( + localized: "dialog.closeWindow.message", + defaultValue: "This will close the current window and all of its workspaces." + ) + alert.addButton(withTitle: String(localized: "common.close", defaultValue: "Close")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + + let alertWindow = alert.window + if let closeButton = alert.buttons.first { + alertWindow.defaultButtonCell = closeButton.cell as? NSButtonCell + alertWindow.initialFirstResponder = closeButton + DispatchQueue.main.async { + _ = alertWindow.makeFirstResponder(closeButton) + } + } + + return alert.runModal() == .alertFirstButtonReturn } - func openWelcomeWorkspace() { - guard let context = preferredMainWindowContextForWorkspaceCreation(event: nil, debugSource: "welcome") else { - return + @discardableResult + func closeWindowWithConfirmation(_ window: NSWindow) -> Bool { + guard isMainTerminalWindow(window) else { + window.performClose(nil) + return true } - if let window = context.window ?? windowForMainWindowId(context.windowId) { - setActiveMainWindow(window) - bringToFront(window) + guard confirmCloseMainWindow(window) else { return true } + window.performClose(nil) + return true + } + + private func orderedMainWindowSummaries(referenceWindowId: UUID?) -> [MainWindowSummary] { + let summaries = listMainWindowSummaries() + return summaries.sorted { lhs, rhs in + let lhsIsReference = lhs.windowId == referenceWindowId + let rhsIsReference = rhs.windowId == referenceWindowId + if lhsIsReference != rhsIsReference { return lhsIsReference } + if lhs.isKeyWindow != rhs.isKeyWindow { return lhs.isKeyWindow } + if lhs.isVisible != rhs.isVisible { return lhs.isVisible } + return lhs.windowId.uuidString < rhs.windowId.uuidString } - let workspace = context.tabManager.addWorkspace(select: true, autoWelcomeIfNeeded: false) - sendWelcomeCommandWhenReady(to: workspace) } - func sendWelcomeCommandWhenReady(to workspace: Workspace, markShownOnSend: Bool = false) { - sendTextWhenReady("programa welcome\n", to: workspace) { - if markShownOnSend { - UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey) + private func windowLabelsById(orderedSummaries: [MainWindowSummary], referenceWindowId: UUID?) -> [UUID: String] { + var labels: [UUID: String] = [:] + for (index, summary) in orderedSummaries.enumerated() { + if summary.windowId == referenceWindowId { + labels[summary.windowId] = String(localized: "menu.currentWindow", defaultValue: "Current Window") + } else { + let number = index + 1 + labels[summary.windowId] = String(localized: "menu.windowNumber", defaultValue: "Window \(number)") } } + return labels } - @objc func applyUpdateIfAvailable(_ sender: Any?) { - updateViewModel.overrideState = nil - updateController.installUpdate() + private func workspaceDisplayName(_ workspace: Workspace) -> String { + let trimmed = workspace.title.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? String(localized: "workspace.displayName.fallback", defaultValue: "Workspace") : trimmed } - @objc func attemptUpdate(_ sender: Any?) { - updateViewModel.overrideState = nil - updateController.attemptUpdate() + private func rollbackDetachedSurface( + _ detached: Workspace.DetachedSurfaceTransfer, + to workspace: Workspace, + sourcePane: PaneID?, + sourceIndex: Int?, + focus: Bool + ) { + let rollbackPane = sourcePane.flatMap { pane in + workspace.bonsplitController.allPaneIds.first(where: { $0 == pane }) + } ?? workspace.bonsplitController.focusedPaneId + ?? workspace.bonsplitController.allPaneIds.first + guard let rollbackPane else { return } + _ = workspace.attachDetachedSurface( + detached, + inPane: rollbackPane, + atIndex: sourceIndex, + focus: focus + ) } - func isProgramaCLIInstalledInPATH() -> Bool { - ProgramaCLIPathInstaller().isInstalled() - } + private func cleanupEmptySourceWorkspaceAfterSurfaceMove( + sourceWorkspace: Workspace, + sourceManager: TabManager, + sourceWindowId: UUID + ) { + guard sourceWorkspace.panels.isEmpty else { return } + guard sourceManager.tabs.contains(where: { $0.id == sourceWorkspace.id }) else { return } - @objc func installProgramaCLIInPath(_ sender: Any?) { - let installer = ProgramaCLIPathInstaller() - do { - let outcome = try installer.install() - var informativeText = String(localized: "cli.install.symlinkCreated", defaultValue: "Created symlink:\n\n\(outcome.destinationURL.path) -> \(outcome.sourceURL.path)") - if outcome.usedAdministratorPrivileges { - informativeText += "\n\n" + String(localized: "cli.install.adminRequired", defaultValue: "Administrator privileges were required to write to /usr/local/bin.") - } - presentCLIPathAlert( - title: String(localized: "cli.installed", defaultValue: "Programa CLI Installed"), - informativeText: informativeText, - style: .informational - ) - } catch { - presentCLIPathAlert( - title: String(localized: "cli.installFailed", defaultValue: "Couldn't Install Programa CLI"), - informativeText: error.localizedDescription, - style: .warning - ) + if sourceManager.tabs.count > 1 { + sourceManager.closeWorkspace(sourceWorkspace) + } else { + _ = closeMainWindow(windowId: sourceWindowId) } } - @objc func uninstallProgramaCLIInPath(_ sender: Any?) { - let installer = ProgramaCLIPathInstaller() - do { - let outcome = try installer.uninstall() - let prefix = outcome.removedExistingEntry - ? String(localized: "cli.uninstall.removed", defaultValue: "Removed \(outcome.destinationURL.path).") - : String(localized: "cli.uninstall.notFound", defaultValue: "No Programa CLI symlink was found at \(outcome.destinationURL.path).") - var informativeText = prefix - if outcome.usedAdministratorPrivileges { - informativeText += "\n\n" + String(localized: "cli.uninstall.adminRequired", defaultValue: "Administrator privileges were required to modify /usr/local/bin.") + private func reassertCrossWindowSurfaceMoveFocusIfNeeded( + destinationWindowId: UUID, + sourceWindowId: UUID, + destinationWorkspaceId: UUID, + destinationPanelId: UUID, + destinationManager: TabManager + ) { + let reassert: () -> Void = { [weak self, weak destinationManager] in + guard let self, let destinationManager else { return } + guard let workspace = destinationManager.tabs.first(where: { $0.id == destinationWorkspaceId }), + workspace.panels[destinationPanelId] != nil else { + return } - presentCLIPathAlert( - title: String(localized: "cli.uninstalled", defaultValue: "Programa CLI Uninstalled"), - informativeText: informativeText, - style: .informational - ) - } catch { - presentCLIPathAlert( - title: String(localized: "cli.uninstallFailed", defaultValue: "Couldn't Uninstall Programa CLI"), - informativeText: error.localizedDescription, - style: .warning + guard let destinationWindow = self.mainWindow(for: destinationWindowId) else { return } + guard let keyWindow = NSApp.keyWindow, + let keyWindowId = self.mainWindowId(for: keyWindow), + keyWindowId == sourceWindowId, + keyWindow !== destinationWindow else { + return + } + + self.bringToFront(destinationWindow) + destinationManager.focusTab( + destinationWorkspaceId, + surfaceId: destinationPanelId, + suppressFlash: true ) } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: reassert) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.16, execute: reassert) } - private func presentCLIPathAlert( - title: String, - informativeText: String, - style: NSAlert.Style - ) { - let alert = NSAlert() - alert.alertStyle = style - alert.messageText = title - alert.informativeText = informativeText - alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + private func windowForMainWindowId(_ windowId: UUID) -> NSWindow? { + if let ctx = mainWindowContexts.values.first(where: { $0.windowId == windowId }), + let window = ctx.window { + return window + } + let expectedIdentifier = "cmux.main.\(windowId.uuidString)" + return NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) + } - if let window = NSApp.keyWindow ?? NSApp.mainWindow { - alert.beginSheetModal(for: window, completionHandler: nil) - } else { - _ = alert.runModal() + private func resolvedWindow(for context: MainWindowContext) -> NSWindow? { + if let window = context.window { + return window } + guard let window = windowForMainWindowId(context.windowId) else { + return nil + } + reindexMainWindowContextIfNeeded(context, for: window) + return window + } + + private func mainWindowId(from window: NSWindow) -> UUID? { + guard let raw = window.identifier?.rawValue else { return nil } + let prefix = "cmux.main." + guard raw.hasPrefix(prefix) else { return nil } + let suffix = String(raw.dropFirst(prefix.count)) + return UUID(uuidString: suffix) } - @objc func restartSocketListener(_ sender: Any?) { - guard tabManager != nil else { - NSSound.beep() + private func reindexMainWindowContextIfNeeded(_ context: MainWindowContext, for window: NSWindow) { + let desiredKey = ObjectIdentifier(window) + if mainWindowContexts[desiredKey] === context { + context.window = window return } - guard socketListenerConfigurationIfEnabled() != nil else { - TerminalController.shared.stop() - NSSound.beep() + let contextKeys = mainWindowContexts.compactMap { key, value in + value === context ? key : nil + } + for key in contextKeys { + mainWindowContexts.removeValue(forKey: key) + } + + if let conflicting = mainWindowContexts[desiredKey], conflicting !== context { + context.window = window return } - restartSocketListenerIfEnabled(source: "menu.command") - } - private func setupMenuBarExtra() { - guard menuBarExtraController == nil else { return } - let store = TerminalNotificationStore.shared - menuBarExtraController = MenuBarExtraController( - notificationStore: store, - onShowNotifications: { [weak self] in - self?.showNotificationsPopoverFromMenuBar() - }, - onOpenNotification: { [weak self] notification in - _ = self?.openNotification( - tabId: notification.tabId, - surfaceId: notification.surfaceId, - notificationId: notification.id - ) - }, - onJumpToLatestUnread: { [weak self] in - self?.jumpToLatestUnread() - }, - onCheckForUpdates: { [weak self] in - self?.checkForUpdates(nil) - }, - onOpenPreferences: { [weak self] in - self?.openPreferencesWindow(debugSource: "menuBarExtra") - }, - onQuitApp: { - NSApp.terminate(nil) - } - ) + mainWindowContexts[desiredKey] = context + context.window = window + notifyMainWindowContextsDidChange() } - private func installMenuBarVisibilityObserver() { - guard menuBarVisibilityObserver == nil else { return } - menuBarVisibilityObserver = NotificationCenter.default.addObserver( - forName: UserDefaults.didChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.syncMenuBarExtraVisibility() + private func contextForMainTerminalWindow(_ window: NSWindow, reindex: Bool = true) -> MainWindowContext? { + guard isMainTerminalWindow(window) else { return nil } + + if let context = mainWindowContexts[ObjectIdentifier(window)] { + context.window = window + return context + } + + if let windowId = mainWindowId(from: window), + let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { + if reindex { + reindexMainWindowContextIfNeeded(context, for: window) + } else { + context.window = window } + return context } - } - private func syncMenuBarExtraVisibility(defaults: UserDefaults = .standard) { - if MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults) { - setupMenuBarExtra() - return + let windowNumber = window.windowNumber + if windowNumber >= 0, + let context = mainWindowContexts.values.first(where: { candidate in + let candidateWindow = candidate.window ?? windowForMainWindowId(candidate.windowId) + return candidateWindow?.windowNumber == windowNumber + }) { + if reindex { + reindexMainWindowContextIfNeeded(context, for: window) + } else { + context.window = window + } + return context } - menuBarExtraController?.removeFromMenuBar() - menuBarExtraController = nil + return nil } - @MainActor - static func presentPreferencesWindow( - navigationTarget: SettingsNavigationTarget? = nil, - showFallbackSettingsWindow: @MainActor (SettingsNavigationTarget?) -> Void = { target in - SettingsWindowController.shared.show(navigationTarget: target) - }, - activateApplication: @MainActor () -> Void = { - NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + private func unregisterMainWindowContext(for window: NSWindow) -> MainWindowContext? { + guard let removed = contextForMainTerminalWindow(window, reindex: false) else { return nil } + let removedKeys = mainWindowContexts.compactMap { key, value in + value === removed ? key : nil } - ) { -#if DEBUG - dlog("settings.open.present path=customWindowDirect") -#endif - showFallbackSettingsWindow(navigationTarget) - activateApplication() - if let window = SettingsWindowController.shared.window { - window.orderFrontRegardless() - window.makeKeyAndOrderFront(nil) - DispatchQueue.main.async { - window.orderFrontRegardless() - window.makeKeyAndOrderFront(nil) - } + for key in removedKeys { + mainWindowContexts.removeValue(forKey: key) } -#if DEBUG - dlog("settings.open.present activate=1") -#endif - } - - @MainActor - func openPreferencesWindow(debugSource: String, navigationTarget: SettingsNavigationTarget? = nil) { -#if DEBUG - dlog("settings.open.request source=\(debugSource)") -#endif - Self.presentPreferencesWindow(navigationTarget: navigationTarget) + notifyMainWindowContextsDidChange() + return removed } - @objc func openPreferencesWindow() { - openPreferencesWindow(debugSource: "appDelegate") - } + private func discardOrphanedMainWindowContext(_ context: MainWindowContext) { + let contextKeys = mainWindowContexts.compactMap { key, value in + value === context ? key : nil + } + for key in contextKeys { + mainWindowContexts.removeValue(forKey: key) + } + notifyMainWindowContextsDidChange() - func refreshMenuBarExtraForDebug() { - menuBarExtraController?.refreshForDebugControls() - } + teardownCommandPaletteState(for: context.windowId) - func showNotificationsPopoverFromMenuBar() { - let context: MainWindowContext? = { - if let keyWindow = NSApp.keyWindow, - let keyContext = contextForMainTerminalWindow(keyWindow) { - return keyContext - } - if let first = mainWindowContexts.values.first { - return first + if tabManager === context.tabManager { + if let nextContext = mainWindowContexts.values.first(where: { resolvedWindow(for: $0) != nil }) { + tabManager = nextContext.tabManager + sidebarState = nextContext.sidebarState + sidebarSelectionState = nextContext.sidebarSelectionState + TerminalController.shared.setActiveTabManager(nextContext.tabManager) + } else { + tabManager = nil + sidebarState = nil + sidebarSelectionState = nil + TerminalController.shared.setActiveTabManager(nil) } - let windowId = createMainWindow() - return mainWindowContexts.values.first(where: { $0.windowId == windowId }) - }() - - if let context, - let window = context.window ?? windowForMainWindowId(context.windowId) { - setActiveMainWindow(window) - bringToFront(window) } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.titlebarAccessoryController.showNotificationsPopover(animated: false) + if let store = notificationStore { + for tab in context.tabManager.tabs { + store.clearNotifications(forTabId: tab.id) + } } } - #if DEBUG - @objc func showUpdatePill(_ sender: Any?) { - updateViewModel.debugOverrideText = nil - updateViewModel.overrideState = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {}, dismiss: {})) - } - - @objc func showUpdatePillLongNightly(_ sender: Any?) { - updateViewModel.debugOverrideText = "Update Available: 0.32.0-nightly+20260216.abc1234" - updateViewModel.overrideState = .notFound(.init(acknowledgement: {})) - } - - @objc func showUpdatePillLoading(_ sender: Any?) { - updateViewModel.debugOverrideText = nil - updateViewModel.overrideState = .checking(.init(cancel: {})) + private func mainWindowId(for window: NSWindow) -> UUID? { + if let context = mainWindowContexts[ObjectIdentifier(window)] { + return context.windowId + } + guard let rawIdentifier = window.identifier?.rawValue, + rawIdentifier.hasPrefix("cmux.main.") else { return nil } + let idPart = String(rawIdentifier.dropFirst("cmux.main.".count)) + return UUID(uuidString: idPart) } - @objc func hideUpdatePill(_ sender: Any?) { - updateViewModel.debugOverrideText = nil - updateViewModel.overrideState = .idle + private func commandPaletteOverlayContainer(in window: NSWindow) -> NSView? { + guard let searchRoot = window.contentView?.superview ?? window.contentView else { return nil } + var stack: [NSView] = [searchRoot] + while let candidate = stack.popLast() { + if candidate.identifier == commandPaletteOverlayContainerIdentifier { + return candidate + } + stack.append(contentsOf: candidate.subviews) + } + return nil } - @objc func clearUpdatePillOverride(_ sender: Any?) { - updateViewModel.debugOverrideText = nil - updateViewModel.overrideState = nil + private func isCommandPaletteOverlayPresented(in window: NSWindow) -> Bool { + guard let container = commandPaletteOverlayContainer(in: window) else { return false } + return !container.isHidden && container.alphaValue > 0.001 } -#endif - @objc func copyUpdateLogs(_ sender: Any?) { - let logText = UpdateLogStore.shared.snapshot() - let payload: String - if logText.isEmpty { - payload = "No update logs captured.\nLog file: \(UpdateLogStore.shared.logPath())" - } else { - payload = logText + "\nLog file: \(UpdateLogStore.shared.logPath())" - } - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(payload, forType: .string) - } - @objc func copyFocusLogs(_ sender: Any?) { - let logText = FocusLogStore.shared.snapshot() - let payload: String - if logText.isEmpty { - payload = "No focus logs captured.\nLog file: \(FocusLogStore.shared.logPath())" - } else { - payload = logText + "\nLog file: \(FocusLogStore.shared.logPath())" + private func isCommandPaletteResponderActive(in window: NSWindow) -> Bool { + guard let responder = window.firstResponder else { return false } + if let textView = responder as? NSTextView, + textView.isFieldEditor, + !(textView.delegate is NSView) { + // Field-editor delegates can be non-view responders. Confirm the overlay is + // mounted and visible to avoid treating unrelated editors as palette input. + return isCommandPaletteOverlayPresented(in: window) } - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(payload, forType: .string) + return isCommandPaletteResponder(responder) } - @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 - dlog( - "reactGrab.pasteback h3.didCopy.drop " + - "reason=missingNotificationFields " + - "workspace=\(Self.debugShortId(notification.userInfo?[ReactGrabPastebackNotificationKey.workspaceId] as? UUID)) " + - "browser=\(Self.debugShortId(browserPanelId)) " + - "return=\(Self.debugShortId(notification.userInfo?[ReactGrabPastebackNotificationKey.returnPanelId] as? UUID)) " + - "hasContent=\((notification.userInfo?[ReactGrabPastebackNotificationKey.content] as? String) != nil ? 1 : 0)" - ) -#endif - return + private func isCommandPaletteMultilineTextResponderActive(in window: NSWindow) -> Bool { + guard let textView = window.firstResponder as? NSTextView, + !textView.isFieldEditor else { + return false } + return isCommandPaletteResponder(textView) + } - guard let manager = tabManagerFor(tabId: workspaceId), - let workspace = manager.tabs.first(where: { $0.id == workspaceId }) else { -#if DEBUG - dlog( - "reactGrab.pasteback h3.didCopy.drop " + - "reason=missingWorkspace workspace=\(Self.debugShortId(workspaceId)) " + - "browser=\(Self.debugShortId(browserPanelId)) return=\(Self.debugShortId(returnPanelId))" - ) -#endif - return + private func commandPaletteMarkedTextInput(in window: NSWindow) -> NSTextView? { + if let textView = window.firstResponder as? NSTextView, + isCommandPaletteResponder(textView), + textView.hasMarkedText() { + return textView } - guard workspace.terminalPanel(for: returnPanelId) != nil else { -#if DEBUG - dlog( - "reactGrab.pasteback h3.didCopy.drop " + - "reason=missingReturnTerminal workspace=\(Self.debugShortId(workspaceId)) " + - "browser=\(Self.debugShortId(browserPanelId)) return=\(Self.debugShortId(returnPanelId)) " + - "focused=\(Self.debugShortId(workspace.focusedPanelId))" - ) -#endif - return + if let textField = window.firstResponder as? NSTextField, + let editor = textField.currentEditor() as? NSTextView, + isCommandPaletteResponder(editor), + editor.hasMarkedText() { + return editor } -#if DEBUG - dlog( - "reactGrab.pasteback h3.didCopy " + - "workspace=\(Self.debugShortId(workspaceId)) " + - "browser=\(Self.debugShortId(browserPanelId)) " + - "return=\(Self.debugShortId(returnPanelId)) " + - "focusedBefore=\(Self.debugShortId(workspace.focusedPanelId)) len=\(content.count)" - ) -#endif - manager.focusTab(workspaceId, surfaceId: returnPanelId, suppressFlash: true) -#if DEBUG - dlog( - "reactGrab.pasteback h1.focusRequested " + - "workspace=\(Self.debugShortId(workspaceId)) " + - "return=\(Self.debugShortId(returnPanelId)) " + - "focusedAfterRequest=\(Self.debugShortId(workspace.focusedPanelId))" - ) -#endif - sendTextWhenReady(content, to: workspace, preferredPanelId: returnPanelId) + return nil } - nonisolated private static func debugShortId(_ id: UUID?) -> String { - id.map { String($0.uuidString.prefix(5)) } ?? "nil" + private func isCommandPaletteEffectivelyVisible(in window: NSWindow) -> Bool { + isCommandPaletteVisible(for: window) + || isCommandPalettePendingOpen(for: window) + || isCommandPaletteOverlayPresented(in: window) + || isCommandPaletteResponderActive(in: window) } - static func resolveTerminalPanelForTextSend(in tab: Tab, preferredPanelId: UUID? = nil) -> TerminalPanel? { - if let preferredPanelId { - return tab.terminalPanel(for: preferredPanelId) + private func activeCommandPaletteWindow() -> NSWindow? { + pruneExpiredCommandPalettePendingOpenStates() + if let keyWindow = NSApp.keyWindow, + isMainTerminalWindow(keyWindow), + isCommandPaletteEffectivelyVisible(in: keyWindow) { + return keyWindow } - return tab.focusedTerminalPanel + if let mainWindow = NSApp.mainWindow, + isMainTerminalWindow(mainWindow), + isCommandPaletteEffectivelyVisible(in: mainWindow) { + return mainWindow + } + if let orderedWindow = NSApp.orderedWindows.first(where: { window in + isMainTerminalWindow(window) && isCommandPaletteEffectivelyVisible(in: window) + }) { + return orderedWindow + } + if let visibleWindowId = commandPaletteStateByWindowId.first(where: { $0.value.isVisible })?.key { + return windowForMainWindowId(visibleWindowId) + } + if let pendingWindowId = commandPaletteStateByWindowId.first(where: { $0.value.pendingOpen })?.key { + return windowForMainWindowId(pendingWindowId) + } + return nil } - func sendTextWhenReady( - _ text: String, - to tab: Tab, - preferredPanelId: UUID? = nil, - beforeSend: (() -> Void)? = nil - ) { - let isReactGrabPasteback = preferredPanelId != nil -#if DEBUG - let initialTargetPanel = Self.resolveTerminalPanelForTextSend( - in: tab, - preferredPanelId: preferredPanelId - ) - if isReactGrabPasteback { - dlog( - "reactGrab.pasteback h2.send.start " + - "workspace=\(Self.debugShortId(tab.id)) " + - "preferred=\(Self.debugShortId(preferredPanelId)) " + - "focused=\(Self.debugShortId(tab.focusedPanelId)) " + - "focusedTerminal=\(Self.debugShortId(tab.focusedTerminalPanel?.id)) " + - "resolved=\(Self.debugShortId(initialTargetPanel?.id)) " + - "surfaceReady=\(initialTargetPanel?.surface.surface != nil ? 1 : 0) len=\(text.count)" - ) + private func commandPaletteWindowForShortcutEvent(_ event: NSEvent) -> NSWindow? { + if let scopedWindow = mainWindowForShortcutEvent(event) { + return scopedWindow } -#endif - if let terminalPanel = Self.resolveTerminalPanelForTextSend( - in: tab, - preferredPanelId: preferredPanelId - ), - terminalPanel.surface.surface != nil { -#if DEBUG - if isReactGrabPasteback { - dlog( - "reactGrab.pasteback h2.send.immediate " + - "workspace=\(Self.debugShortId(tab.id)) " + - "target=\(Self.debugShortId(terminalPanel.id)) len=\(text.count)" - ) - } -#endif - beforeSend?() - terminalPanel.sendText(text) + return activeCommandPaletteWindow() + } + + private func contextForMainWindow(_ window: NSWindow?) -> MainWindowContext? { + guard let window, isMainTerminalWindow(window) else { return nil } + return mainWindowContexts[ObjectIdentifier(window)] + } + #if DEBUG - if isReactGrabPasteback { - dlog( - "reactGrab.pasteback h2.send.sent " + - "workspace=\(Self.debugShortId(tab.id)) " + - "target=\(Self.debugShortId(terminalPanel.id)) mode=immediate len=\(text.count)" - ) - } -#endif - return + func debugManagerToken(_ manager: TabManager?) -> String { + guard let manager else { return "nil" } + return String(describing: Unmanaged.passUnretained(manager).toOpaque()) + } + + private func debugWindowToken(_ window: NSWindow?) -> String { + guard let window else { return "nil" } + let id = mainWindowId(for: window).map { String($0.uuidString.prefix(8)) } ?? "none" + let ident = window.identifier?.rawValue ?? "nil" + let shortIdent: String + if ident.count > 120 { + shortIdent = String(ident.prefix(120)) + "..." + } else { + shortIdent = ident } + return "num=\(window.windowNumber) id=\(id) ident=\(shortIdent) key=\(window.isKeyWindow ? 1 : 0) main=\(window.isMainWindow ? 1 : 0)" + } - var resolved = false - var readyObserver: NSObjectProtocol? - var focusObserver: NSObjectProtocol? - var firstResponderObserver: NSObjectProtocol? - var panelsCancellable: AnyCancellable? + private func debugContextToken(_ context: MainWindowContext?) -> String { + guard let context else { return "nil" } + let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let hasWindow = (context.window != nil || windowForMainWindowId(context.windowId) != nil) ? 1 : 0 + return "id=\(String(context.windowId.uuidString.prefix(8))) mgr=\(debugManagerToken(context.tabManager)) tabs=\(context.tabManager.tabs.count) selected=\(selected) hasWindow=\(hasWindow)" + } - func cleanupObservers() { - if let readyObserver { - NotificationCenter.default.removeObserver(readyObserver) - } - if let focusObserver { - NotificationCenter.default.removeObserver(focusObserver) - } - if let firstResponderObserver { - NotificationCenter.default.removeObserver(firstResponderObserver) - } - panelsCancellable?.cancel() - } + private func debugShortcutRouteSnapshot(event: NSEvent? = nil) -> String { + let activeManager = tabManager + let activeWindowId = activeManager.flatMap { windowId(for: $0) }.map { String($0.uuidString.prefix(8)) } ?? "nil" + let selectedWorkspace = activeManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" - func finishIfReady() { - let terminalPanel = Self.resolveTerminalPanelForTextSend( - in: tab, - preferredPanelId: preferredPanelId - ) -#if DEBUG - if isReactGrabPasteback { - dlog( - "reactGrab.pasteback h2.finishIfReady " + - "workspace=\(Self.debugShortId(tab.id)) " + - "preferred=\(Self.debugShortId(preferredPanelId)) " + - "focused=\(Self.debugShortId(tab.focusedPanelId)) " + - "resolved=\(Self.debugShortId(terminalPanel?.id)) " + - "surfaceReady=\(terminalPanel?.surface.surface != nil ? 1 : 0) alreadyResolved=\(resolved ? 1 : 0)" - ) - } -#endif - guard !resolved, - let terminalPanel, - terminalPanel.surface.surface != nil else { return } - resolved = true - cleanupObservers() - beforeSend?() - terminalPanel.sendText(text) -#if DEBUG - if isReactGrabPasteback { - dlog( - "reactGrab.pasteback h2.send.sent " + - "workspace=\(Self.debugShortId(tab.id)) " + - "target=\(Self.debugShortId(terminalPanel.id)) mode=delayed len=\(text.count)" - ) + let contexts = mainWindowContexts.values + .map { context in + let marker = (activeManager != nil && context.tabManager === activeManager) ? "*" : "-" + let window = context.window ?? windowForMainWindowId(context.windowId) + let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + return "\(marker)\(String(context.windowId.uuidString.prefix(8))){mgr=\(debugManagerToken(context.tabManager)),win=\(window?.windowNumber ?? -1),key=\((window?.isKeyWindow ?? false) ? 1 : 0),main=\((window?.isMainWindow ?? false) ? 1 : 0),tabs=\(context.tabManager.tabs.count),selected=\(selected)}" } + .sorted() + .joined(separator: ",") + + let eventWindowNumber = event.map { String($0.windowNumber) } ?? "nil" + let eventWindow = event?.window + return "eventWinNum=\(eventWindowNumber) eventWin={\(debugWindowToken(eventWindow))} keyWin={\(debugWindowToken(NSApp.keyWindow))} mainWin={\(debugWindowToken(NSApp.mainWindow))} activeMgr=\(debugManagerToken(activeManager)) activeWinId=\(activeWindowId) activeSelected=\(selectedWorkspace) contexts=[\(contexts)]" + } #endif + + private func mainWindowForShortcutEvent(_ event: NSEvent) -> NSWindow? { + if let window = resolvedShortcutEventWindow(event), + isMainTerminalWindow(window) { + return window } + if let keyWindow = NSApp.keyWindow, isMainTerminalWindow(keyWindow) { + return keyWindow + } + if let mainWindow = NSApp.mainWindow, isMainTerminalWindow(mainWindow) { + return mainWindow + } + return nil + } - panelsCancellable = tab.$panels - .map { _ in () } - .sink { _ in -#if DEBUG - if isReactGrabPasteback { - dlog( - "reactGrab.pasteback h2.panelsChanged " + - "workspace=\(Self.debugShortId(tab.id)) " + - "focused=\(Self.debugShortId(tab.focusedPanelId))" - ) - } -#endif - finishIfReady() + private func resolvedShortcutEventWindow(_ event: NSEvent) -> NSWindow? { + if let window = event.window { + return window + } + let eventWindowNumber = event.windowNumber + guard eventWindowNumber > 0 else { return nil } + return NSApp.window(withWindowNumber: eventWindowNumber) + } + + /// Re-sync app-level active window pointers from the currently focused main terminal window. + /// This keeps menu/shortcut actions window-scoped even if the cached `tabManager` drifts. + @discardableResult + func synchronizeActiveMainWindowContext(preferredWindow: NSWindow? = nil) -> TabManager? { + let (context, source): (MainWindowContext?, String) = { + if let preferredWindow, + let context = contextForMainWindow(preferredWindow) { + return (context, "preferredWindow") } - if isReactGrabPasteback { - focusObserver = NotificationCenter.default.addObserver( - forName: .ghosttyDidFocusSurface, - object: nil, - queue: .main - ) { note in - guard let candidateTabId = note.userInfo?[GhosttyNotificationKey.tabId] as? UUID, - candidateTabId == tab.id, - let candidateSurfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { - return - } -#if DEBUG - dlog( - "reactGrab.pasteback h1.focusEvent " + - "workspace=\(Self.debugShortId(candidateTabId)) " + - "surface=\(Self.debugShortId(candidateSurfaceId)) " + - "target=\(Self.debugShortId(preferredPanelId)) " + - "match=\(candidateSurfaceId == preferredPanelId ? 1 : 0)" - ) -#endif + if let context = contextForMainWindow(NSApp.keyWindow) { + return (context, "keyWindow") } - firstResponderObserver = NotificationCenter.default.addObserver( - forName: .ghosttyDidBecomeFirstResponderSurface, - object: nil, - queue: .main - ) { note in - guard let candidateTabId = note.userInfo?[GhosttyNotificationKey.tabId] as? UUID, - candidateTabId == tab.id, - let candidateSurfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { - return - } + if let context = contextForMainWindow(NSApp.mainWindow) { + return (context, "mainWindow") + } + if let activeManager = tabManager, + let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { + return (activeContext, "activeManager") + } + return (mainWindowContexts.values.first, "firstContextFallback") + }() + #if DEBUG - dlog( - "reactGrab.pasteback h1.firstResponderEvent " + - "workspace=\(Self.debugShortId(candidateTabId)) " + - "surface=\(Self.debugShortId(candidateSurfaceId)) " + - "target=\(Self.debugShortId(preferredPanelId)) " + - "match=\(candidateSurfaceId == preferredPanelId ? 1 : 0)" - ) + let beforeManagerToken = debugManagerToken(tabManager) + dlog( + "shortcut.sync.pre source=\(source) preferred={\(debugWindowToken(preferredWindow))} chosen={\(debugContextToken(context))} \(debugShortcutRouteSnapshot())" + ) #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 + guard let context else { return tabManager } + let alreadyActive = + tabManager === context.tabManager + && sidebarState === context.sidebarState + && sidebarSelectionState === context.sidebarSelectionState + if alreadyActive { #if DEBUG - if isReactGrabPasteback { - dlog( - "reactGrab.pasteback h2.surfaceReadyEvent " + - "workspace=\(Self.debugShortId(workspaceId)) " + - "surface=\(Self.debugShortId(surfaceId)) " + - "target=\(Self.debugShortId(preferredPanelId)) " + - "match=\(surfaceId == preferredPanelId ? 1 : 0)" - ) - } + dlog( + "shortcut.sync.post source=\(source) beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) chosen={\(debugContextToken(context))} nochange=1 \(debugShortcutRouteSnapshot())" + ) #endif - if let preferredPanelId, - let surfaceId, - surfaceId != preferredPanelId { - return - } - finishIfReady() + return context.tabManager + } + if let window = context.window ?? windowForMainWindowId(context.windowId) { + setActiveMainWindow(window) + } else { + tabManager = context.tabManager + sidebarState = context.sidebarState + sidebarSelectionState = context.sidebarSelectionState + TerminalController.shared.setActiveTabManager(context.tabManager) } - DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { - if !resolved { #if DEBUG - if isReactGrabPasteback { - dlog( - "reactGrab.pasteback h2.send.timeout " + - "workspace=\(Self.debugShortId(tab.id)) " + - "preferred=\(Self.debugShortId(preferredPanelId)) " + - "focused=\(Self.debugShortId(tab.focusedPanelId)) " + - "focusedTerminal=\(Self.debugShortId(tab.focusedTerminalPanel?.id))" - ) - } + dlog( + "shortcut.sync.post source=\(source) beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) chosen={\(debugContextToken(context))} \(debugShortcutRouteSnapshot())" + ) #endif - cleanupObservers() - NSLog("Command send: surface not ready after 3.0s") - } + return context.tabManager + } + + private struct FocusedTerminalShortcutContext { + let tabManager: TabManager + let workspaceId: UUID + let panelId: UUID + } + + private func resolveShortcutTabManager(for tabId: UUID, preferredWindow: NSWindow? = nil) -> TabManager? { + if let manager = tabManagerFor(tabId: tabId) { + return manager + } + if let preferredWindow, + let context = contextForMainWindow(preferredWindow), + context.tabManager.tabs.contains(where: { $0.id == tabId }) { + return context.tabManager } + if let activeManager = tabManager, + activeManager.tabs.contains(where: { $0.id == tabId }) { + return activeManager + } + return nil } -#if DEBUG - private let debugColorWorkspaceTitlePrefix = "Debug Color - " - let debugPerfWorkspaceTitlePrefix = "Debug Perf - " - var debugStressWorkspaceCreationInProgress = false - var debugStressLagProbeEnabled = false - let debugStressWorkspaceCount = 20 - let debugStressPaneCount = 4 - let debugStressTabsPerPane = 4 - let debugStressYieldInterval = 4 - let debugStressSurfaceLoadTimeoutSeconds: TimeInterval = 10.0 + private func focusedTerminalShortcutContext(preferredWindow: NSWindow? = nil) -> FocusedTerminalShortcutContext? { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + let responder = targetWindow?.firstResponder + ?? NSApp.keyWindow?.firstResponder + ?? NSApp.mainWindow?.firstResponder + guard let ghosttyView = cmuxOwningGhosttyView(for: responder), + let workspaceId = ghosttyView.tabId, + let panelId = ghosttyView.terminalSurface?.id, + let manager = resolveShortcutTabManager(for: workspaceId, preferredWindow: targetWindow) else { + return nil + } + return FocusedTerminalShortcutContext( + tabManager: manager, + workspaceId: workspaceId, + panelId: panelId + ) + } - @objc func openDebugScrollbackTab(_ sender: Any?) { - guard let tabManager else { return } - let tab = tabManager.addTab() - let config = GhosttyConfig.load() - 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) + private func preferredMainWindowContextForShortcuts(event: NSEvent) -> MainWindowContext? { + if let context = contextForMainWindow(event.window) { + return context + } + if let context = contextForMainWindow(NSApp.keyWindow) { + return context + } + if let context = contextForMainWindow(NSApp.mainWindow) { + return context + } + if let activeManager = tabManager, + let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { + return activeContext + } + return mainWindowContexts.values.first } - @objc func openDebugLoremTab(_ sender: Any?) { - guard let tabManager else { return } - let tab = tabManager.addTab() - let lineCount = 2000 - let base = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore." - var lines: [String] = [] - lines.reserveCapacity(lineCount) - for index in 1...lineCount { - lines.append(String(format: "%04d %@", index, base)) + private func activateMainWindowContextForShortcutEvent(_ event: NSEvent) { + let preferredWindow = mainWindowForShortcutEvent(event) +#if DEBUG + dlog( + "shortcut.activate.pre event=\(NSWindow.keyDescription(event)) preferred={\(debugWindowToken(preferredWindow))} \(debugShortcutRouteSnapshot(event: event))" + ) +#endif + _ = synchronizeActiveMainWindowContext(preferredWindow: preferredWindow) +#if DEBUG + dlog( + "shortcut.activate.post event=\(NSWindow.keyDescription(event)) preferred={\(debugWindowToken(preferredWindow))} \(debugShortcutRouteSnapshot(event: event))" + ) +#endif + } + + @discardableResult + func toggleSidebarInActiveMainWindow() -> Bool { + if let activeManager = tabManager, + let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { + if let window = activeContext.window ?? windowForMainWindowId(activeContext.windowId) { + setActiveMainWindow(window) + } + activeContext.sidebarState.toggle() + return true + } + if let keyContext = contextForMainWindow(NSApp.keyWindow) { + if let window = keyContext.window ?? windowForMainWindowId(keyContext.windowId) { + setActiveMainWindow(window) + } + keyContext.sidebarState.toggle() + return true } - let payload = lines.joined(separator: "\n") + "\n" - sendTextWhenReady(payload, to: tab) - } - - @objc func openDebugColorComparisonWorkspaces(_ sender: Any?) { - guard let tabManager else { return } - - let palette = WorkspaceTabColorSettings.palette() - guard !palette.isEmpty else { return } - - var existingByTitle: [String: Workspace] = [:] - for tab in tabManager.tabs { - guard let title = tab.customTitle, - title.hasPrefix(debugColorWorkspaceTitlePrefix) else { continue } - existingByTitle[title] = tab + if let mainContext = contextForMainWindow(NSApp.mainWindow) { + if let window = mainContext.window ?? windowForMainWindowId(mainContext.windowId) { + setActiveMainWindow(window) + } + mainContext.sidebarState.toggle() + return true } - - for entry in palette { - let title = "\(debugColorWorkspaceTitlePrefix)\(entry.name)" - let targetTab: Workspace - if let existing = existingByTitle[title] { - targetTab = existing - } else { - targetTab = tabManager.addTab() + if let fallbackContext = mainWindowContexts.values.first { + if let window = fallbackContext.window ?? windowForMainWindowId(fallbackContext.windowId) { + setActiveMainWindow(window) } - tabManager.setCustomTitle(tabId: targetTab.id, title: title) - tabManager.setTabColor(tabId: targetTab.id, color: entry.hex) + fallbackContext.sidebarState.toggle() + return true + } + if let sidebarState { + sidebarState.toggle() + return true } + return false } - -#endif - - - func attachUpdateAccessory(to window: NSWindow) { - titlebarAccessoryController.start() - titlebarAccessoryController.attach(to: window) + func sidebarVisibility(windowId: UUID) -> Bool? { + mainWindowContexts.values.first(where: { $0.windowId == windowId })?.sidebarState.isVisible } - func applyWindowDecorations(to window: NSWindow) { - windowDecorationsController.apply(to: window) + /// Toggle the sidebar for the window identified by the given NSWindow. + /// Used by titlebar accessory buttons so they affect their own window + /// rather than the currently focused/key window (#1779). + func toggleSidebarForWindow(_ window: NSWindow?) -> Bool { + if let window { + guard let context = contextForMainTerminalWindow(window) else { + return false + } + context.sidebarState.toggle() + return true + } + return toggleSidebarInActiveMainWindow() } - func toggleNotificationsPopover(animated: Bool = true, anchorView: NSView? = nil) { - titlebarAccessoryController.toggleNotificationsPopover(animated: animated, anchorView: anchorView) + func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { + let menu = NSMenu(title: "") + let newWindowItem = NSMenuItem( + title: String(localized: "menu.file.newWindow", defaultValue: "New Window"), + action: #selector(openNewMainWindow(_:)), + keyEquivalent: "" + ) + newWindowItem.target = self + menu.addItem(newWindowItem) + return menu } - @discardableResult - func dismissNotificationsPopoverIfShown() -> Bool { - titlebarAccessoryController.dismissNotificationsPopoverIfShown() + @objc func openNewMainWindow(_ sender: Any?) { + _ = createMainWindow() } - func isNotificationsPopoverShown() -> Bool { - titlebarAccessoryController.isNotificationsPopoverShown() + /// Shows the "Open Folder" panel and creates a workspace for the selected directory. + /// Called from both the SwiftUI menu and `handleCustomShortcut`. + func showOpenFolderPanel() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.title = String(localized: "menu.file.openFolder.panelTitle", defaultValue: "Open Folder") + panel.prompt = String(localized: "menu.file.openFolder.panelPrompt", defaultValue: "Open") + // Seed the panel with the active workspace's directory. Use the shared + // main-window resolver so this works even when an auxiliary window is key. + if let context = preferredMainWindowContextForWorkspaceCreation(debugSource: "openFolderPanel.seed"), + let cwd = context.tabManager.selectedWorkspace?.currentDirectory, + !cwd.isEmpty { + panel.directoryURL = URL(fileURLWithPath: cwd) + } + if panel.runModal() == .OK, let url = panel.url { + openWorkspaceForExternalDirectory( + workingDirectory: url.path, + debugSource: "shortcut.openFolder" + ) + } } - func jumpToLatestUnread() { - guard let notificationStore else { return } -#if DEBUG - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData([ - "jumpUnreadInvoked": "1", - "jumpUnreadNotificationCount": String(notificationStore.notifications.count), - ]) + @discardableResult + func openDirectoryInInlineVSCode( + _ directoryURL: URL, + tabManager preferredTabManager: TabManager? = nil + ) -> Bool { + guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscodeInline.applicationURL() else { + return false } -#endif - // Prefer the latest unread that we can actually open. In early startup (especially on the VM), - // the window-context registry can lag behind model initialization, so fall back to whatever - // tab manager currently owns the tab. - for notification in notificationStore.notifications where !notification.isRead { - if openNotification(tabId: notification.tabId, surfaceId: notification.surfaceId, notificationId: notification.id) { - return - } + + let targetTabManager = preferredTabManager + ?? preferredMainWindowContextForWorkspaceCreation(debugSource: "inlineVSCode.open.target")?.tabManager + guard let targetTabManager else { + return false } - } - static func installWindowResponderSwizzlesForTesting() { - _ = didInstallWindowKeyEquivalentSwizzle - _ = didInstallWindowFirstResponderSwizzle - _ = didInstallWindowSendEventSwizzle - } + let targetWorkspaceId = targetTabManager.selectedWorkspace?.id + ?? targetTabManager.tabs.first?.id + ?? targetTabManager.addWorkspace(select: true).id + let normalizedDirectoryURL = directoryURL.standardizedFileURL -#if DEBUG - static func setWindowFirstResponderGuardTesting(currentEvent: NSEvent?, hitView: NSView?) { - programaFirstResponderGuardCurrentEventOverride = currentEvent - programaFirstResponderGuardHitViewOverride = hitView - } + VSCodeServeWebController.shared.ensureServeWebURL(vscodeApplicationURL: vscodeApplicationURL) { serveWebURL in + guard let serveWebURL, + let openFolderURL = VSCodeServeWebURLBuilder.openFolderURL( + baseWebUIURL: serveWebURL, + directoryPath: normalizedDirectoryURL.path + ) else { + NSSound.beep() + return + } - static func clearWindowFirstResponderGuardTesting() { - programaFirstResponderGuardCurrentEventOverride = nil - programaFirstResponderGuardHitViewOverride = nil - } -#endif + guard targetTabManager.openBrowser( + inWorkspace: targetWorkspaceId, + url: openFolderURL, + preferSplitRight: true + ) != nil else { + NSSound.beep() + return + } + } - private func installWindowResponderSwizzles() { - _ = Self.didInstallApplicationSendEventSwizzle - _ = Self.didInstallWindowKeyEquivalentSwizzle - _ = Self.didInstallWindowFirstResponderSwizzle - _ = Self.didInstallWindowSendEventSwizzle + return true } - private func installShortcutMonitor() { - // Local monitor only receives events when app is active (not global) - shortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp, .flagsChanged]) { [weak self] event in - guard let self else { return event } - if event.type == .keyDown { -#if DEBUG - let phaseTotalStart = ProcessInfo.processInfo.systemUptime - let preludeStart = ProcessInfo.processInfo.systemUptime - var preludeMs: Double = 0 - var shortcutMs: Double = 0 - ProgramaTypingTiming.logEventDelay(path: "appMonitor", event: event) - let shortcutMonitorTraceEnabled = - ProcessInfo.processInfo.environment["PROGRAMA_SHORTCUT_MONITOR_TRACE"] == "1" - || UserDefaults.standard.bool(forKey: "programaShortcutMonitorTrace") - if shortcutMonitorTraceEnabled { - let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - dlog( - "monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil") \(self.debugShortcutRouteSnapshot(event: event))" - ) - } - if let probeKind = self.developerToolsShortcutProbeKind(event: event) { - self.logDeveloperToolsShortcutSnapshot(phase: "monitor.pre.\(probeKind)", event: event) - } - preludeMs = (ProcessInfo.processInfo.systemUptime - preludeStart) * 1000.0 - let shortcutTimingStart = ProgramaTypingTiming.start() -#endif - let shortcutStart = ProcessInfo.processInfo.systemUptime - let handledByShortcut = self.handleCustomShortcut(event: event) -#if DEBUG - shortcutMs = (ProcessInfo.processInfo.systemUptime - shortcutStart) * 1000.0 - ProgramaTypingTiming.logDuration( - path: "appMonitor.handleCustomShortcut", - startedAt: shortcutTimingStart, - event: event, - extra: "handled=\(handledByShortcut ? 1 : 0)" - ) - let shortcutElapsedMs = (ProcessInfo.processInfo.systemUptime - shortcutStart) * 1000.0 - self.logSlowShortcutMonitorLatencyIfNeeded( - event: event, - handledByShortcut: handledByShortcut, - elapsedMs: shortcutElapsedMs - ) - let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0 - ProgramaTypingTiming.logBreakdown( - path: "appMonitor.phase", - totalMs: totalMs, - event: event, - thresholdMs: 0.75, - parts: [ - ("preludeMs", preludeMs), - ("shortcutMs", shortcutMs), - ], - extra: "handled=\(handledByShortcut ? 1 : 0)" - ) -#endif - if handledByShortcut { -#if DEBUG - dlog(" → consumed by handleCustomShortcut") -#endif - return nil // Consume the event - } - return event // Pass through - } - self.handleBrowserOmnibarSelectionRepeatLifecycleEvent(event) - if self.clearEscapeSuppressionForKeyUp(event: event, consumeIfSuppressed: true) { - return nil - } - return event + func showOpenFolderInInlineVSCodePanel(tabManager preferredTabManager: TabManager? = nil) { + guard TerminalDirectoryOpenTarget.vscodeInline.isAvailable() else { + NSSound.beep() + return + } + + let targetTabManager = preferredTabManager + ?? preferredMainWindowContextForWorkspaceCreation(debugSource: "inlineVSCode.panel.target")?.tabManager + guard let targetTabManager else { + NSSound.beep() + return } - } - private func installShortcutDefaultsObserver() { - guard shortcutDefaultsObserver == nil else { return } - refreshConfiguredShortcutChordActions() - shortcutDefaultsObserver = NotificationCenter.default.addObserver( - forName: KeyboardShortcutSettings.didChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.refreshConfiguredShortcutChordActions() - self?.clearConfiguredShortcutChordState() - self?.scheduleSplitButtonTooltipRefreshAcrossWorkspaces() + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.title = String( + localized: "menu.file.openFolderInVSCodeInline.panelTitle", + defaultValue: "Open Folder in VS Code (Inline)" + ) + panel.prompt = String( + localized: "menu.file.openFolderInVSCodeInline.panelPrompt", + defaultValue: "Open in VS Code" + ) + if let cwd = targetTabManager.selectedWorkspace?.currentDirectory, + !cwd.isEmpty { + panel.directoryURL = URL(fileURLWithPath: cwd) } - } - private func refreshConfiguredShortcutChordActions() { - configuredShortcutChordActions = KeyboardShortcutSettings.Action.allCases.filter { - KeyboardShortcutSettings.shortcut(for: $0).hasChord + if panel.runModal() == .OK, + let url = panel.url, + !openDirectoryInInlineVSCode(url, tabManager: targetTabManager) { + NSSound.beep() } } - private func clearConfiguredShortcutChordState() { - pendingConfiguredShortcutChord = nil - activeConfiguredShortcutChordPrefixForCurrentEvent = nil + @objc func openWindow( + _ pasteboard: NSPasteboard, + userData: String?, + error: AutoreleasingUnsafeMutablePointer + ) { + openFromServicePasteboard(pasteboard, target: .window, error: error) } - /// Coalesce shortcut-default changes and refresh on the next runloop turn to - /// avoid mutating Bonsplit/SwiftUI-observed state during an active update pass. - private func scheduleSplitButtonTooltipRefreshAcrossWorkspaces() { - guard !splitButtonTooltipRefreshScheduled else { return } - splitButtonTooltipRefreshScheduled = true - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.splitButtonTooltipRefreshScheduled = false - self.refreshSplitButtonTooltipsAcrossWorkspaces() - } + @objc func openTab( + _ pasteboard: NSPasteboard, + userData: String?, + error: AutoreleasingUnsafeMutablePointer + ) { + openFromServicePasteboard(pasteboard, target: .workspace, error: error) } - private func refreshSplitButtonTooltipsAcrossWorkspaces() { - var refreshedManagers: Set = [] - if let manager = tabManager { - manager.refreshSplitButtonTooltips() - refreshedManagers.insert(ObjectIdentifier(manager)) + private enum ServiceOpenTarget { + case window + case workspace + } + + private func openFromServicePasteboard( + _ pasteboard: NSPasteboard, + target: ServiceOpenTarget, + error: AutoreleasingUnsafeMutablePointer + ) { + let pathURLs = servicePathURLs(from: pasteboard) + guard !pathURLs.isEmpty else { + error.pointee = Self.serviceErrorNoPath + return } - for context in mainWindowContexts.values { - let manager = context.tabManager - let identifier = ObjectIdentifier(manager) - guard refreshedManagers.insert(identifier).inserted else { continue } - manager.refreshSplitButtonTooltips() + + let directories = externalOpenDirectories(from: pathURLs) + guard !directories.isEmpty else { + error.pointee = Self.serviceErrorNoPath + return } - } - private func installGhosttyConfigObserver() { - guard ghosttyConfigObserver == nil else { return } - ghosttyConfigObserver = NotificationCenter.default.addObserver( - forName: .ghosttyConfigDidReload, - object: nil, - queue: .main - ) { [weak self] _ in - self?.refreshGhosttyGotoSplitShortcuts() + prepareForExplicitOpenIntentAtStartup() + for directory in directories { + switch target { + case .window: + _ = createMainWindow(initialWorkingDirectory: directory) + case .workspace: + openWorkspaceFromService(workingDirectory: directory) + } } } - private func refreshGhosttyGotoSplitShortcuts() { - guard let config = GhosttyApp.shared.config else { - ghosttyGotoSplitLeftShortcut = nil - ghosttyGotoSplitRightShortcut = nil - ghosttyGotoSplitUpShortcut = nil - ghosttyGotoSplitDownShortcut = nil - return + private func servicePathURLs(from pasteboard: NSPasteboard) -> [URL] { + if let pathURLs = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], !pathURLs.isEmpty { + return pathURLs } - ghosttyGotoSplitLeftShortcut = storedShortcutFromGhosttyTrigger( - ghostty_config_trigger(config, "goto_split:left", UInt("goto_split:left".utf8.count)) - ) - ghosttyGotoSplitRightShortcut = storedShortcutFromGhosttyTrigger( - ghostty_config_trigger(config, "goto_split:right", UInt("goto_split:right".utf8.count)) - ) - ghosttyGotoSplitUpShortcut = storedShortcutFromGhosttyTrigger( - ghostty_config_trigger(config, "goto_split:up", UInt("goto_split:up".utf8.count)) + let filenamesType = NSPasteboard.PasteboardType(rawValue: "NSFilenamesPboardType") + if let paths = pasteboard.propertyList(forType: filenamesType) as? [String] { + let urls = paths.map { URL(fileURLWithPath: $0) } + if !urls.isEmpty { + return urls + } + } + + if let raw = pasteboard.string(forType: .string), !raw.isEmpty { + return raw + .split(whereSeparator: \.isNewline) + .map { line in + let text = String(line).trimmingCharacters(in: .whitespacesAndNewlines) + if let fileURL = URL(string: text), fileURL.isFileURL { + return fileURL + } + return URL(fileURLWithPath: text) + } + } + + return [] + } + + private func openWorkspaceFromService(workingDirectory: String) { + openWorkspaceForExternalDirectory( + workingDirectory: workingDirectory, + debugSource: "service.openTab" ) - ghosttyGotoSplitDownShortcut = storedShortcutFromGhosttyTrigger( - ghostty_config_trigger(config, "goto_split:down", UInt("goto_split:down".utf8.count)) + } + + private func prepareForExplicitOpenIntentAtStartup() { + didHandleExplicitOpenIntentAtStartup = true + if !didAttemptStartupSessionRestore { + startupSessionSnapshot = nil + didAttemptStartupSessionRestore = true + } + } + + 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. + FinderServicePathResolver.orderedUniqueDirectories( + from: urls.filter { $0.isFileURL }, + excludingDescendantsOf: [Bundle.main.bundleURL] ) } - private func storedShortcutFromGhosttyTrigger(_ trigger: ghostty_input_trigger_s) -> StoredShortcut? { - let key: String - switch trigger.tag { - case GHOSTTY_TRIGGER_PHYSICAL: - switch trigger.key.physical { - case GHOSTTY_KEY_ARROW_LEFT: - key = "←" - case GHOSTTY_KEY_ARROW_RIGHT: - key = "→" - case GHOSTTY_KEY_ARROW_UP: - key = "↑" - case GHOSTTY_KEY_ARROW_DOWN: - key = "↓" - case GHOSTTY_KEY_A: key = "a" - case GHOSTTY_KEY_B: key = "b" - case GHOSTTY_KEY_C: key = "c" - case GHOSTTY_KEY_D: key = "d" - case GHOSTTY_KEY_E: key = "e" - case GHOSTTY_KEY_F: key = "f" - case GHOSTTY_KEY_G: key = "g" - case GHOSTTY_KEY_H: key = "h" - case GHOSTTY_KEY_I: key = "i" - case GHOSTTY_KEY_J: key = "j" - case GHOSTTY_KEY_K: key = "k" - case GHOSTTY_KEY_L: key = "l" - case GHOSTTY_KEY_M: key = "m" - case GHOSTTY_KEY_N: key = "n" - case GHOSTTY_KEY_O: key = "o" - case GHOSTTY_KEY_P: key = "p" - case GHOSTTY_KEY_Q: key = "q" - case GHOSTTY_KEY_R: key = "r" - case GHOSTTY_KEY_S: key = "s" - case GHOSTTY_KEY_T: key = "t" - case GHOSTTY_KEY_U: key = "u" - case GHOSTTY_KEY_V: key = "v" - case GHOSTTY_KEY_W: key = "w" - case GHOSTTY_KEY_X: key = "x" - case GHOSTTY_KEY_Y: key = "y" - case GHOSTTY_KEY_Z: key = "z" - case GHOSTTY_KEY_DIGIT_0: key = "0" - case GHOSTTY_KEY_DIGIT_1: key = "1" - case GHOSTTY_KEY_DIGIT_2: key = "2" - case GHOSTTY_KEY_DIGIT_3: key = "3" - case GHOSTTY_KEY_DIGIT_4: key = "4" - case GHOSTTY_KEY_DIGIT_5: key = "5" - case GHOSTTY_KEY_DIGIT_6: key = "6" - case GHOSTTY_KEY_DIGIT_7: key = "7" - case GHOSTTY_KEY_DIGIT_8: key = "8" - case GHOSTTY_KEY_DIGIT_9: key = "9" - case GHOSTTY_KEY_BRACKET_LEFT: key = "[" - case GHOSTTY_KEY_BRACKET_RIGHT: key = "]" - case GHOSTTY_KEY_MINUS: key = "-" - case GHOSTTY_KEY_EQUAL: key = "=" - case GHOSTTY_KEY_COMMA: key = "," - case GHOSTTY_KEY_PERIOD: key = "." - case GHOSTTY_KEY_SLASH: key = "/" - case GHOSTTY_KEY_SEMICOLON: key = ";" - case GHOSTTY_KEY_QUOTE: key = "'" - case GHOSTTY_KEY_BACKQUOTE: key = "`" - case GHOSTTY_KEY_BACKSLASH: key = "\\" - default: - return nil - } - case GHOSTTY_TRIGGER_UNICODE: - guard let scalar = UnicodeScalar(trigger.key.unicode) else { return nil } - key = String(Character(scalar)).lowercased() - case GHOSTTY_TRIGGER_CATCH_ALL: - return nil - default: + private func openWorkspaceForExternalDirectory( + workingDirectory: String, + debugSource: String + ) { + if addWorkspaceInPreferredMainWindow( + workingDirectory: workingDirectory, + shouldBringToFront: true, + debugSource: debugSource + ) != nil { + return + } + _ = createMainWindow(initialWorkingDirectory: workingDirectory) + } + + @discardableResult + func addWorkspaceInPreferredMainWindow( + workingDirectory: String? = nil, + shouldBringToFront: Bool = false, + event: NSEvent? = nil, + debugSource: String = "unspecified" + ) -> UUID? { + #if DEBUG + logWorkspaceCreationRouting( + phase: "request", + source: debugSource, + reason: "add_workspace", + event: event, + chosenContext: nil, + workingDirectory: workingDirectory + ) + #endif + guard let context = preferredMainWindowContextForWorkspaceCreation(event: event, debugSource: debugSource) else { + #if DEBUG + logWorkspaceCreationRouting( + phase: "no_context", + source: debugSource, + reason: "context_selection_failed", + event: event, + chosenContext: nil, + workingDirectory: workingDirectory + ) + #endif return nil } - - let mods = trigger.mods.rawValue - let command = (mods & GHOSTTY_MODS_SUPER.rawValue) != 0 - let shift = (mods & GHOSTTY_MODS_SHIFT.rawValue) != 0 - let option = (mods & GHOSTTY_MODS_ALT.rawValue) != 0 - let control = (mods & GHOSTTY_MODS_CTRL.rawValue) != 0 - - // Ignore bogus empty triggers. - if key.isEmpty || (!command && !shift && !option && !control) { + guard let window = resolvedWindow(for: context) else { + #if DEBUG + logWorkspaceCreationRouting( + phase: "no_context", + source: debugSource, + reason: "context_window_missing", + event: event, + chosenContext: context, + workingDirectory: workingDirectory + ) + #endif + discardOrphanedMainWindowContext(context) return nil } + setActiveMainWindow(window) + if shouldBringToFront { + bringToFront(window) + } - return StoredShortcut(key: key, command: command, shift: shift, option: option, control: control) + let workspace: Workspace + if let workingDirectory { + workspace = context.tabManager.addWorkspace(workingDirectory: workingDirectory, select: true) + } else { + workspace = context.tabManager.addTab(select: true) + } + #if DEBUG + logWorkspaceCreationRouting( + phase: "created", + source: debugSource, + reason: "workspace_created", + event: event, + chosenContext: context, + workspaceId: workspace.id, + workingDirectory: workingDirectory + ) + #endif + return workspace.id } - private func handleQuitShortcutWarning() -> Bool { - // Tagged DEV builds are ephemeral, skip quit confirmation entirely. - if SocketControlSettings.isTaggedDevBuild() { - NSApp.terminate(nil) - return true + private func preferredMainWindowContextForWorkspaceCreation( + event: NSEvent? = nil, + debugSource: String = "unspecified" + ) -> MainWindowContext? { + if let context = mainWindowContext(forShortcutEvent: event, debugSource: debugSource) { + return context } - if !QuitWarningSettings.isEnabled() { - NSApp.terminate(nil) - return true + // If a keyboard event identifies a specific window but that context + // can't be resolved, do not fall back to another window. + if shortcutEventHasAddressableWindow(event) { +#if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_context_required_no_fallback", + event: event, + chosenContext: nil + ) +#endif + return nil } - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = String(localized: "dialog.quitPrograma.title", defaultValue: "Quit Programa?") - alert.informativeText = String(localized: "dialog.quitPrograma.message", defaultValue: "This will close all windows and workspaces.") - alert.addButton(withTitle: String(localized: "dialog.quitPrograma.quit", defaultValue: "Quit")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) - alert.showsSuppressionButton = true - alert.suppressionButton?.title = String(localized: "dialog.dontWarnCmdQ", defaultValue: "Don't warn again for Cmd+Q") - - let response = alert.runModal() - if alert.suppressionButton?.state == .on { - QuitWarningSettings.setEnabled(false) + if let keyWindow = NSApp.keyWindow, + let context = contextForMainTerminalWindow(keyWindow) { +#if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "key_window", + event: event, + chosenContext: context + ) + #endif + return context } - if response == .alertFirstButtonReturn { - // Mark as confirmed so applicationShouldTerminate does not show a - // second alert when NSApp.terminate re-enters the delegate callback. - isQuitWarningConfirmed = true - NSApp.terminate(nil) + if let mainWindow = NSApp.mainWindow, + let context = contextForMainTerminalWindow(mainWindow) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "main_window", + event: event, + chosenContext: context + ) + #endif + return context } - return true - } - func promptRenameSelectedWorkspace() -> Bool { - guard let tabManager, - let tabId = tabManager.selectedTabId, - let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { - NSSound.beep() - return false + for window in NSApp.orderedWindows where isMainTerminalWindow(window) { + if let context = contextForMainTerminalWindow(window) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "ordered_windows", + event: event, + chosenContext: context + ) + #endif + return context + } } - let alert = NSAlert() - alert.messageText = String(localized: "dialog.renameWorkspace.title", defaultValue: "Rename Workspace") - alert.informativeText = String(localized: "dialog.renameWorkspace.message", defaultValue: "Enter a custom name for this workspace.") - let input = NSTextField(string: tab.customTitle ?? tab.title) - input.placeholderString = String(localized: "dialog.renameWorkspace.placeholder", defaultValue: "Workspace name") - input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) - alert.accessoryView = input - alert.addButton(withTitle: String(localized: "common.rename", defaultValue: "Rename")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) - let alertWindow = alert.window - alertWindow.initialFirstResponder = input - DispatchQueue.main.async { - alertWindow.makeFirstResponder(input) - input.selectText(nil) - } + let fallback = mainWindowContexts.values.first(where: { resolvedWindow(for: $0) != nil }) + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "fallback_first_context", + event: event, + chosenContext: fallback + ) +#endif + return fallback + } - let response = alert.runModal() - guard response == .alertFirstButtonReturn else { return true } - tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue) - return true + private func shortcutEventHasAddressableWindow(_ event: NSEvent?) -> Bool { + guard let event else { return false } + // NSEvent.windowNumber can be 0 for responder-chain events that are not + // actually bound to an NSWindow (notably some WebKit key paths). + return event.window != nil || event.windowNumber > 0 } - private func handleCustomShortcut(event: NSEvent) -> Bool { - // `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.), - // charactersIgnoringModifiers returns non-ASCII characters that never match - // Latin shortcut keys. Normalize via KeyboardLayout so downstream comparisons - // (Cmd+1-9, Ctrl+1-9, omnibar N/P, command palette, etc.) work correctly. - let chars = KeyboardLayout.normalizedCharacters(for: event) - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - let hasControl = flags.contains(.control) - let hasCommand = flags.contains(.command) - let hasOption = flags.contains(.option) - let isControlOnly = hasControl && !hasCommand && !hasOption - let controlDChar = chars == "d" || event.characters == "\u{04}" - let isControlD = isControlOnly && (controlDChar || event.keyCode == 2) - let configuredShortcutEventWindowNumber = configuredShortcutChordWindowNumber(for: event) - if let pendingConfiguredShortcutChord, - pendingConfiguredShortcutChord.windowNumber == configuredShortcutEventWindowNumber { - activeConfiguredShortcutChordPrefixForCurrentEvent = pendingConfiguredShortcutChord.firstStroke - } else { - activeConfiguredShortcutChordPrefixForCurrentEvent = nil + private func mainWindowContext( + forShortcutEvent event: NSEvent?, + debugSource: String = "unspecified" + ) -> MainWindowContext? { + guard let event else { return nil } + + if let eventWindow = event.window, + let context = contextForMainTerminalWindow(eventWindow) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_window", + event: event, + chosenContext: context + ) + #endif + return context } - pendingConfiguredShortcutChord = nil - defer { activeConfiguredShortcutChordPrefixForCurrentEvent = nil } -#if DEBUG - if isControlD { - writeChildExitKeyboardProbe( - [ - "probeAppShortcutCharsHex": childExitKeyboardProbeHex(event.characters), - "probeAppShortcutCharsIgnoringHex": childExitKeyboardProbeHex(event.charactersIgnoringModifiers), - "probeAppShortcutKeyCode": String(event.keyCode), - "probeAppShortcutModsRaw": String(event.modifierFlags.rawValue), - ], - increments: ["probeAppShortcutCtrlDSeenCount": 1] + + if event.windowNumber > 0, + let numberedWindow = NSApp.window(withWindowNumber: event.windowNumber), + let context = contextForMainTerminalWindow(numberedWindow) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_window_number", + event: event, + chosenContext: context ) + #endif + return context } -#endif - // Don't steal shortcuts from close-confirmation alerts. Keep standard alert key - // equivalents working and avoid surprising actions while the confirmation is up. - let closeConfirmationTitles = [ - String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"), - String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?"), - String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), - String(localized: "dialog.closeOtherTabs.title", defaultValue: "Close other tabs?"), - String(localized: "dialog.closeWindow.title", defaultValue: "Close window?"), - ] - let closeConfirmationPanel = NSApp.windows - .compactMap { $0 as? NSPanel } - .first { panel in - guard panel.isVisible, let root = panel.contentView else { return false } - return closeConfirmationTitles.contains { title in - findStaticText(in: root, equals: title) - } - } - if let closeConfirmationPanel { - // Special-case: Cmd+D should confirm destructive close on alerts. - // XCUITest key events often hit the app-level local monitor first, so forward the key - // equivalent to the alert panel explicitly. - if matchShortcut( + if event.windowNumber > 0, + let context = mainWindowContexts.values.first(where: { candidate in + let window = candidate.window ?? windowForMainWindowId(candidate.windowId) + return window?.windowNumber == event.windowNumber + }) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_window_number_scan", event: event, - shortcut: StoredShortcut(key: "d", command: true, shift: false, option: false, control: false) - ), - let root = closeConfirmationPanel.contentView, - let closeButton = findButton( - in: root, - titled: String(localized: "common.close", defaultValue: "Close") - ) { - closeButton.performClick(nil) - return true - } - return false - } - - if NSApp.modalWindow != nil || NSApp.keyWindow?.attachedSheet != nil { - return false + chosenContext: context + ) + #endif + return context } - let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) - let commandPaletteTargetWindow = commandPaletteWindowForShortcutEvent(event) - let commandPaletteShortcutWindow = shouldHandleCommandPaletteShortcutEvent( - event, - paletteWindow: commandPaletteTargetWindow - ) ? commandPaletteTargetWindow : nil - let commandPaletteVisibleInTargetWindow = commandPaletteShortcutWindow.map { - isCommandPaletteVisible(for: $0) - } ?? false - let commandPalettePendingOpenInTargetWindow = commandPaletteTargetWindow.map { - isCommandPalettePendingOpen(for: $0) - } ?? false - let commandPaletteOverlayVisibleInTargetWindow = commandPaletteTargetWindow.map { - isCommandPaletteOverlayPresented(in: $0) - } ?? false - let commandPaletteResponderActiveInTargetWindow = commandPaletteTargetWindow.map { - isCommandPaletteResponderActive(in: $0) - } ?? false - let commandPaletteInteractiveInTargetWindow = - commandPaletteVisibleInTargetWindow - || commandPaletteOverlayVisibleInTargetWindow - || commandPaletteResponderActiveInTargetWindow - let commandPaletteEffectiveInTargetWindow = - commandPaletteInteractiveInTargetWindow - || commandPalettePendingOpenInTargetWindow + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_context_not_found", + event: event, + chosenContext: nil + ) + #endif + return nil + } -#if DEBUG - if event.keyCode == 36 || event.keyCode == 76 { - dlog( - "shortcut.return.raw " + - "interactive=\(commandPaletteInteractiveInTargetWindow ? 1 : 0) " + - "effective=\(commandPaletteEffectiveInTargetWindow ? 1 : 0) " + - "target={\(debugWindowToken(commandPaletteTargetWindow))} " + - "shortcutWindow={\(debugWindowToken(commandPaletteShortcutWindow))} " + - "responderTarget=\(commandPaletteResponderActiveInTargetWindow ? 1 : 0) " + - "overlayTarget=\(commandPaletteOverlayVisibleInTargetWindow ? 1 : 0) " + - "pendingTarget=\(commandPalettePendingOpenInTargetWindow ? 1 : 0) " + - "\(debugShortcutRouteSnapshot(event: event))" - ) + private func preferredMainWindowContextForShortcutRouting(event: NSEvent) -> MainWindowContext? { + if let context = mainWindowContext(forShortcutEvent: event, debugSource: "shortcut.routing") { + return context } -#endif - if normalizedFlags.isEmpty, event.keyCode == 53 { - let activePaletteWindow = activeCommandPaletteWindow() - let escapePaletteWindow: NSWindow? = { - if let targetWindow = commandPaletteTargetWindow { - guard commandPaletteEffectiveInTargetWindow else { - return nil - } - return targetWindow - } - return activePaletteWindow - }() -#if DEBUG - dlog( - "shortcut.escape route target={\(debugWindowToken(commandPaletteTargetWindow))} " + - "active={\(debugWindowToken(activePaletteWindow))} " + - "visibleTarget=\(commandPaletteVisibleInTargetWindow ? 1 : 0) " + - "pendingTarget=\(commandPalettePendingOpenInTargetWindow ? 1 : 0) " + - "overlayTarget=\(commandPaletteOverlayVisibleInTargetWindow ? 1 : 0) " + - "responderTarget=\(commandPaletteResponderActiveInTargetWindow ? 1 : 0) " + - "effectiveTarget=\(commandPaletteEffectiveInTargetWindow ? 1 : 0) " + - "\(debugShortcutRouteSnapshot(event: event))" - ) - if commandPaletteTargetWindow != nil, - !commandPaletteVisibleInTargetWindow, - !commandPalettePendingOpenInTargetWindow, - (commandPaletteOverlayVisibleInTargetWindow || commandPaletteResponderActiveInTargetWindow) { - dlog( - "shortcut.escape stateMismatch target={\(debugWindowToken(commandPaletteTargetWindow))} " + - "overlayTarget=\(commandPaletteOverlayVisibleInTargetWindow ? 1 : 0) " + - "responderTarget=\(commandPaletteResponderActiveInTargetWindow ? 1 : 0)" - ) - } -#endif - if let paletteWindow = escapePaletteWindow, - isCommandPaletteEffectivelyVisible(in: paletteWindow) { - if commandPaletteMarkedTextInput(in: paletteWindow) != nil { -#if DEBUG - dlog( - "shortcut.escape imeMarkedTextBypass consumed=0 target={\(debugWindowToken(paletteWindow))}" - ) -#endif - return false - } - clearCommandPalettePendingOpen(for: paletteWindow) - beginCommandPaletteEscapeSuppression(for: paletteWindow) - NotificationCenter.default.post(name: .commandPaletteToggleRequested, object: paletteWindow) -#if DEBUG - dlog("shortcut.escape paletteDismiss consumed=1 target={\(debugWindowToken(paletteWindow))}") -#endif - return true - } - let suppressionWindow = commandPaletteTargetWindow - ?? event.window - ?? NSApp.keyWindow - ?? NSApp.mainWindow - if shouldConsumeSuppressedEscape(event: event, window: suppressionWindow) { -#if DEBUG - dlog( - "shortcut.escape suppressionConsume consumed=1 target={\(debugWindowToken(suppressionWindow))} " + - "repeat=\(event.isARepeat ? 1 : 0)" - ) -#endif - return true - } - if let requestAge = recentCommandPaletteRequestAge(for: suppressionWindow) { - beginCommandPaletteEscapeSuppression(for: suppressionWindow) + if shortcutEventHasAddressableWindow(event) { + if let eventWindow = resolvedShortcutEventWindow(event), + programaWindowShouldOwnCloseShortcut(eventWindow) { + // Auxiliary cmux windows do not own a terminal tab manager. Let them fall back + // to the active main terminal window so app shortcuts like Cmd+W still route. + } else { #if DEBUG - dlog( - "shortcut.escape requestGraceConsume consumed=1 target={\(debugWindowToken(suppressionWindow))} " + - "ageMs=\(Int(requestAge * 1000)) repeat=\(event.isARepeat ? 1 : 0)" + logWorkspaceCreationRouting( + phase: "choose", + source: "shortcut.routing", + reason: "event_context_required_no_fallback", + event: event, + chosenContext: nil ) #endif - return true + return nil } -#if DEBUG - dlog( - "shortcut.escape paletteDismiss consumed=0 target={\(debugWindowToken(commandPaletteTargetWindow))} " + - "active={\(debugWindowToken(activePaletteWindow))}" - ) -#endif } - let paletteUsesInlineTextHandling = commandPaletteShortcutWindow.map { - isCommandPaletteMultilineTextResponderActive(in: $0) - } ?? false + if let keyWindow = NSApp.keyWindow, + let context = contextForMainTerminalWindow(keyWindow) { + return context + } - let paletteSelectionDelta = commandPaletteSelectionDeltaForKeyboardNavigation( - flags: event.modifierFlags, - chars: chars, - keyCode: event.keyCode - ) + if let mainWindow = NSApp.mainWindow, + let context = contextForMainTerminalWindow(mainWindow) { + return context + } - if shouldRouteCommandPaletteSelectionNavigation( - delta: paletteSelectionDelta, - isInteractive: commandPaletteInteractiveInTargetWindow, - usesInlineTextHandling: paletteUsesInlineTextHandling - ), - let delta = paletteSelectionDelta, - let paletteWindow = commandPaletteShortcutWindow { - NotificationCenter.default.post( - name: .commandPaletteMoveSelection, - object: paletteWindow, - userInfo: ["delta": delta] - ) - return true + if let activeManager = tabManager, + let context = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { + return context } - if commandPaletteInteractiveInTargetWindow, - let paletteWindow = commandPaletteShortcutWindow { - let paletteFieldEditorHasMarkedText = commandPaletteFieldEditorHasMarkedText(in: paletteWindow) - let paletteSnapshot = mainWindowId(for: paletteWindow).map(commandPaletteSnapshot(windowId:)) ?? .empty - let paletteUsesInlineReturnHandling = paletteUsesInlineTextHandling - if normalizedFlags.isEmpty, event.keyCode == 53 { - if paletteFieldEditorHasMarkedText { - return false - } - NotificationCenter.default.post(name: .commandPaletteDismissRequested, object: paletteWindow) - return true - } + return mainWindowContexts.values.first + } - let shouldSubmitPalette = shouldSubmitCommandPaletteWithReturn( - keyCode: event.keyCode, - flags: event.modifierFlags, - mode: paletteSnapshot.mode + @discardableResult + private func synchronizeShortcutRoutingContext(event: NSEvent) -> Bool { + guard let context = preferredMainWindowContextForShortcutRouting(event: event) else { +#if DEBUG + FocusLogStore.shared.append( + "shortcut.route reason=no_context_no_fallback eventWin=\(event.windowNumber) keyCode=\(event.keyCode)" ) -#if DEBUG - if event.keyCode == 36 || event.keyCode == 76 { - dlog( - "shortcut.palette.return target={\(debugWindowToken(paletteWindow))} " + - "mode=\(paletteSnapshot.mode) " + - "inline=\(paletteUsesInlineReturnHandling ? 1 : 0) " + - "submit=\(shouldSubmitPalette ? 1 : 0) " + - "marked=\(paletteFieldEditorHasMarkedText ? 1 : 0) " + - "\(debugShortcutRouteSnapshot(event: event))" - ) - } #endif - if paletteUsesInlineReturnHandling, - event.keyCode == 36 || event.keyCode == 76 { - return false - } - if shouldSubmitPalette { - if paletteFieldEditorHasMarkedText { - return false - } - NotificationCenter.default.post(name: .commandPaletteSubmitRequested, object: paletteWindow) - return true - } + return false + } + + let alreadyActive = + tabManager === context.tabManager + && sidebarState === context.sidebarState + && sidebarSelectionState === context.sidebarSelectionState + if alreadyActive { return true } + + if let window = context.window ?? windowForMainWindowId(context.windowId) { + setActiveMainWindow(window) + } else { + tabManager = context.tabManager + sidebarState = context.sidebarState + sidebarSelectionState = context.sidebarSelectionState + TerminalController.shared.setActiveTabManager(context.tabManager) } - // Guard against stale browserAddressBarFocusedPanelId after focus transitions - // (e.g., split that doesn't properly blur the address bar). If the first responder - // is a terminal surface, the address bar can't be focused. - if browserAddressBarFocusedPanelId != nil, - cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder) != nil { #if DEBUG - let stalePanelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - dlog( - "browser.focus.addressBar.staleClear panel=\(stalePanelToken) " + - "reason=terminal_first_responder fr=\(firstResponderType)" - ) + FocusLogStore.shared.append( + "shortcut.route reason=sync activeTM=\(pointerString(tabManager)) chosen={\(summarizeContextForWorkspaceRouting(context))}" + ) #endif - browserAddressBarFocusedPanelId = nil - stopBrowserOmnibarSelectionRepeat() + return true + } + + @discardableResult + func createMainWindow( + initialWorkingDirectory: String? = nil, + sessionWindowSnapshot: SessionWindowSnapshot? = nil + ) -> UUID { + let windowId = UUID() + let tabManager = TabManager(initialWorkingDirectory: initialWorkingDirectory) + if let tabManagerSnapshot = sessionWindowSnapshot?.tabManager { + tabManager.restoreSessionSnapshot(tabManagerSnapshot) } - // Keep Cmd+P/Cmd+N inside the focused browser omnibar for Chrome-like - // suggestion navigation, and avoid opening command palette switcher. - // Scope the omnibar check to the shortcut's routed window context so a - // focused omnibar in another window does not suppress Cmd+P here. - let hasFocusedAddressBarInShortcutContext = focusedBrowserAddressBarPanelIdForShortcutEvent(event) != nil + let sidebarWidth = sessionWindowSnapshot?.sidebar.width + .map(SessionPersistencePolicy.sanitizedSidebarWidth) + ?? SessionPersistencePolicy.defaultSidebarWidth + let sidebarState = SidebarState( + isVisible: sessionWindowSnapshot?.sidebar.isVisible ?? true, + persistedWidth: CGFloat(sidebarWidth) + ) + let sidebarSelectionState = SidebarSelectionState( + selection: sessionWindowSnapshot?.sidebar.selection.sidebarSelection ?? .tabs + ) + let notificationStore = TerminalNotificationStore.shared - if commandPaletteEffectiveInTargetWindow { - if matchConfiguredShortcut(event: event, action: .commandPalette) { - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.commandPalette") - return true - } + let programaConfigStore = ProgramaConfigStore() + programaConfigStore.wireDirectoryTracking(tabManager: tabManager) + programaConfigStore.loadAll() - if !hasFocusedAddressBarInShortcutContext, - matchConfiguredShortcut(event: event, action: .goToWorkspace) { - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.goToWorkspace") - return true - } + let root = ContentView(updateViewModel: updateViewModel, windowId: windowId) + .environmentObject(tabManager) + .environmentObject(notificationStore) + .environmentObject(sidebarState) + .environmentObject(sidebarSelectionState) + .environmentObject(programaConfigStore) - if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, - armConfiguredShortcutChordIfNeeded(event: event, actions: [.commandPalette]) { - return true + // Use the current key window's size for new windows so Cmd+Shift+N + // creates a window matching the previous one's dimensions. + let styleMask: NSWindow.StyleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView] + let sourceContext = preferredMainWindowContextForWorkspaceCreation( + debugSource: "createMainWindow.initialGeometry" + ) + let sourceWindow = sourceContext.flatMap { resolvedWindow(for: $0) } + let existingFrame = sourceWindow?.frame + let sourceWindowIsNativeFullScreen: Bool = { +#if DEBUG + if let debugCreateMainWindowSourceIsNativeFullScreenOverride { + return debugCreateMainWindowSourceIsNativeFullScreenOverride } +#endif + return sourceWindow?.styleMask.contains(.fullScreen) == true + }() + let shouldTemporarilyDisallowFullScreenTiling = + sessionWindowSnapshot == nil && sourceWindowIsNativeFullScreen + let initialRect: NSRect + if sessionWindowSnapshot == nil, let existingFrame { + // Convert frame rect to content rect so the new window matches the + // source window's actual size (frame includes titlebar insets). + initialRect = NSWindow.contentRect(forFrameRect: existingFrame, styleMask: styleMask) + } else { + initialRect = NSRect(x: 0, y: 0, width: 460, height: 360) + } - if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, - !hasFocusedAddressBarInShortcutContext, - armConfiguredShortcutChordIfNeeded(event: event, actions: [.goToWorkspace]) { - return true + let window = NSWindow( + contentRect: initialRect, + styleMask: styleMask, + backing: .buffered, + defer: false + ) + // When creating a new window from an existing native fullscreen window, + // temporarily opt out of fullscreen tiling so AppKit doesn't place the + // new window into the active fullscreen Space. + if shouldTemporarilyDisallowFullScreenTiling { + window.collectionBehavior.insert(.fullScreenDisallowsTiling) + } + window.title = "" + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.isMovableByWindowBackground = false + window.isMovable = false + let restoredFrame = resolvedWindowFrame(from: sessionWindowSnapshot) + if let restoredFrame { + window.setFrame(restoredFrame, display: false) + } else { + window.center() + // Cascade using the same algorithm as upstream Ghostty: seed from + // the window's own top-left on the first call, then advance the + // cascade point for each subsequent window. + if mainWindowContexts.count >= 1 { + lastCascadePoint = window.cascadeTopLeft(from: lastCascadePoint) + } else { + lastCascadePoint = window.cascadeTopLeft(from: NSPoint(x: window.frame.minX, y: window.frame.maxY)) } } + window.contentView = MainWindowHostingView(rootView: root) - if shouldConsumeShortcutWhileCommandPaletteVisible( - isCommandPaletteVisible: commandPaletteEffectiveInTargetWindow, - normalizedFlags: normalizedFlags, - chars: chars, - keyCode: event.keyCode - ) { - return true - } + // Apply shared window styling. + attachUpdateAccessory(to: window) + applyWindowDecorations(to: window) - // When the terminal has active IME composition (e.g. Korean, Japanese, Chinese - // input), don't intercept non-Cmd key events — let them flow through to the - // input method. Cmd-based shortcuts (Cmd+T, Cmd+Shift+L, etc.) should still - // work during composition since Cmd is never part of IME input sequences. - if !normalizedFlags.contains(.command), - let ghosttyView = cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder), - ghosttyView.hasMarkedText() { - return false + // Keep a strong reference so the window isn't deallocated. + let controller = MainWindowController(window: window) + controller.onClose = { [weak self, weak controller] in + guard let self, let controller else { return } + self.mainWindowControllers.removeAll(where: { $0 === controller }) } + window.delegate = controller + mainWindowControllers.append(controller) - // When the notifications popover is open, Escape should dismiss it immediately. - if flags.isEmpty, event.keyCode == 53, titlebarAccessoryController.dismissNotificationsPopoverIfShown() { - return true + registerMainWindow( + window, + windowId: windowId, + tabManager: tabManager, + sidebarState: sidebarState, + sidebarSelectionState: sidebarSelectionState + ) + installFileDropOverlay(on: window, tabManager: tabManager) + if TerminalController.shouldSuppressSocketCommandActivation() { + window.orderFront(nil) + if TerminalController.socketCommandAllowsInAppFocusMutations() { + setActiveMainWindow(window) + } + } else { + window.makeKeyAndOrderFront(nil) + setActiveMainWindow(window) + NSApp.activate(ignoringOtherApps: true) } - - // When the notifications popover is showing an empty state, consume plain typing - // so key presses do not leak through into the focused terminal. - if flags.isDisjoint(with: [.command, .control, .option]), - titlebarAccessoryController.isNotificationsPopoverShown(), - (notificationStore?.notifications.isEmpty ?? false) { - return true + if shouldTemporarilyDisallowFullScreenTiling { + DispatchQueue.main.async { [weak window] in + window?.collectionBehavior.remove(.fullScreenDisallowsTiling) + } } - - let hasEventWindowContext = shortcutEventHasAddressableWindow(event) - let didSynchronizeShortcutContext = synchronizeShortcutRoutingContext(event: event) - if hasEventWindowContext && !didSynchronizeShortcutContext { + if let restoredFrame { + window.setFrame(restoredFrame, display: true) #if DEBUG - dlog("handleCustomShortcut: unresolved event window context; bypassing app shortcut handling") + dlog( + "session.restore.frameApplied window=\(windowId.uuidString.prefix(8)) " + + "applied={\(debugNSRectDescription(window.frame))}" + ) #endif - return false } + return windowId + } + + @objc func checkForUpdates(_ sender: Any?) { + updateViewModel.overrideState = nil + updateController.checkForUpdates() + } + + func checkForUpdatesInCustomUI() { + updateViewModel.overrideState = nil + updateController.checkForUpdatesInCustomUI() + } + + func openWelcomeWorkspace() { + guard let context = preferredMainWindowContextForWorkspaceCreation(event: nil, debugSource: "welcome") else { + return + } + if let window = context.window ?? windowForMainWindowId(context.windowId) { + setActiveMainWindow(window) + bringToFront(window) + } + let workspace = context.tabManager.addWorkspace(select: true, autoWelcomeIfNeeded: false) + sendWelcomeCommandWhenReady(to: workspace) + } - // Keep keyboard routing deterministic after split close/reparent transitions: - // before processing shortcuts, converge first responder with the focused terminal panel. - if isControlD { -#if DEBUG - 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" - 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" - 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. - return false + func sendWelcomeCommandWhenReady(to workspace: Workspace, markShownOnSend: Bool = false) { + sendTextWhenReady("programa welcome\n", to: workspace) { + if markShownOnSend { + UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey) + } } + } - // Chrome-like omnibar navigation while holding Cmd+N / Ctrl+N / Cmd+P / Ctrl+P. - if let delta = commandOmnibarSelectionDelta(flags: flags, chars: chars) { - dispatchBrowserOmnibarSelectionMove(delta: delta) - startBrowserOmnibarSelectionRepeatIfNeeded(keyCode: event.keyCode, delta: delta) - return true - } + @objc func applyUpdateIfAvailable(_ sender: Any?) { + updateViewModel.overrideState = nil + updateController.installUpdate() + } - if let delta = browserOmnibarSelectionDeltaForArrowNavigation( - hasFocusedAddressBar: browserAddressBarFocusedPanelId != nil, - flags: event.modifierFlags, - keyCode: event.keyCode - ) { - dispatchBrowserOmnibarSelectionMove(delta: delta) - return true - } + @objc func attemptUpdate(_ sender: Any?) { + updateViewModel.overrideState = nil + updateController.attemptUpdate() + } - // Fast path for normal typing and terminal navigation keys (for example Up-arrow - // history): after command-palette/notification handling and browser omnibar - // arrow navigation above, plain key events have no app-level shortcut behavior. - if normalizedFlags.isEmpty && activeConfiguredShortcutChordPrefixForCurrentEvent == nil { - return false - } + func isProgramaCLIInstalledInPATH() -> Bool { + ProgramaCLIPathInstaller().isInstalled() + } - // Let omnibar-local Emacs navigation (Cmd/Ctrl+N/P) win while the browser - // address bar is focused. Without this, app-level Cmd+N can steal focus. - if shouldBypassAppShortcutForFocusedBrowserAddressBar(flags: flags, chars: chars) { - return false + @objc func installProgramaCLIInPath(_ sender: Any?) { + let installer = ProgramaCLIPathInstaller() + do { + let outcome = try installer.install() + var informativeText = String(localized: "cli.install.symlinkCreated", defaultValue: "Created symlink:\n\n\(outcome.destinationURL.path) -> \(outcome.sourceURL.path)") + if outcome.usedAdministratorPrivileges { + informativeText += "\n\n" + String(localized: "cli.install.adminRequired", defaultValue: "Administrator privileges were required to write to /usr/local/bin.") + } + presentCLIPathAlert( + title: String(localized: "cli.installed", defaultValue: "Programa CLI Installed"), + informativeText: informativeText, + style: .informational + ) + } catch { + presentCLIPathAlert( + title: String(localized: "cli.installFailed", defaultValue: "Couldn't Install Programa CLI"), + informativeText: error.localizedDescription, + style: .warning + ) } + } - if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, - armConfiguredShortcutChordIfNeeded(event: event) { - return true + @objc func uninstallProgramaCLIInPath(_ sender: Any?) { + let installer = ProgramaCLIPathInstaller() + do { + let outcome = try installer.uninstall() + let prefix = outcome.removedExistingEntry + ? String(localized: "cli.uninstall.removed", defaultValue: "Removed \(outcome.destinationURL.path).") + : String(localized: "cli.uninstall.notFound", defaultValue: "No Programa CLI symlink was found at \(outcome.destinationURL.path).") + var informativeText = prefix + if outcome.usedAdministratorPrivileges { + informativeText += "\n\n" + String(localized: "cli.uninstall.adminRequired", defaultValue: "Administrator privileges were required to modify /usr/local/bin.") + } + presentCLIPathAlert( + title: String(localized: "cli.uninstalled", defaultValue: "Programa CLI Uninstalled"), + informativeText: informativeText, + style: .informational + ) + } catch { + presentCLIPathAlert( + title: String(localized: "cli.uninstallFailed", defaultValue: "Couldn't Uninstall Programa CLI"), + informativeText: error.localizedDescription, + style: .warning + ) } + } - if matchConfiguredShortcut(event: event, action: .commandPalette) { - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.commandPalette") - return true - } + private func presentCLIPathAlert( + title: String, + informativeText: String, + style: NSAlert.Style + ) { + let alert = NSAlert() + alert.alertStyle = style + alert.messageText = title + alert.informativeText = informativeText + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) - if !hasFocusedAddressBarInShortcutContext, - matchConfiguredShortcut(event: event, action: .goToWorkspace) { - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.goToWorkspace") - return true + if let window = NSApp.keyWindow ?? NSApp.mainWindow { + alert.beginSheetModal(for: window, completionHandler: nil) + } else { + _ = alert.runModal() } + } - if matchConfiguredShortcut(event: event, action: .quit) { - return handleQuitShortcutWarning() - } - if matchConfiguredShortcut(event: event, action: .openSettings) { - openPreferencesWindow(debugSource: "shortcut.openSettings") - return true + @objc func restartSocketListener(_ sender: Any?) { + guard tabManager != nil else { + NSSound.beep() + return } - if matchConfiguredShortcut(event: event, action: .reloadConfiguration) { - GhosttyApp.shared.reloadConfiguration(source: "shortcut.reloadConfiguration") - return true + + guard socketListenerConfigurationIfEnabled() != nil else { + TerminalController.shared.stop() + NSSound.beep() + return } + restartSocketListenerIfEnabled(source: "menu.command") + } - if matchConfiguredShortcut(event: event, action: .toggleFullScreen) { - guard let targetWindow = mainWindowForShortcutEvent(event) else { - return false + private func setupMenuBarExtra() { + guard menuBarExtraController == nil else { return } + let store = TerminalNotificationStore.shared + menuBarExtraController = MenuBarExtraController( + notificationStore: store, + onShowNotifications: { [weak self] in + self?.showNotificationsPopoverFromMenuBar() + }, + onOpenNotification: { [weak self] notification in + _ = self?.openNotification( + tabId: notification.tabId, + surfaceId: notification.surfaceId, + notificationId: notification.id + ) + }, + onJumpToLatestUnread: { [weak self] in + self?.jumpToLatestUnread() + }, + onCheckForUpdates: { [weak self] in + self?.checkForUpdates(nil) + }, + onOpenPreferences: { [weak self] in + self?.openPreferencesWindow(debugSource: "menuBarExtra") + }, + onQuitApp: { + NSApp.terminate(nil) + } + ) + } + + private func installMenuBarVisibilityObserver() { + guard menuBarVisibilityObserver == nil else { return } + menuBarVisibilityObserver = NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.syncMenuBarExtraVisibility() } - targetWindow.toggleFullScreen(nil) - return true } + } - // Primary UI shortcuts - if matchConfiguredShortcut(event: event, action: .toggleSidebar) { - _ = toggleSidebarInActiveMainWindow() - return true + private func syncMenuBarExtraVisibility(defaults: UserDefaults = .standard) { + if MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults) { + setupMenuBarExtra() + return } - if matchConfiguredShortcut(event: event, action: .newTab) { + menuBarExtraController?.removeFromMenuBar() + menuBarExtraController = nil + } + + @MainActor + static func presentPreferencesWindow( + navigationTarget: SettingsNavigationTarget? = nil, + showFallbackSettingsWindow: @MainActor (SettingsNavigationTarget?) -> Void = { target in + SettingsWindowController.shared.show(navigationTarget: target) + }, + activateApplication: @MainActor () -> Void = { + NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + } + ) { #if DEBUG - dlog("shortcut.action name=newWorkspace \(debugShortcutRouteSnapshot(event: event))") + dlog("settings.open.present path=customWindowDirect") #endif - // 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) + showFallbackSettingsWindow(navigationTarget) + activateApplication() + if let window = SettingsWindowController.shared.window { + window.orderFrontRegardless() + window.makeKeyAndOrderFront(nil) + DispatchQueue.main.async { + window.orderFrontRegardless() + window.makeKeyAndOrderFront(nil) } - return true } +#if DEBUG + dlog("settings.open.present activate=1") +#endif + } - // New Window: Cmd+Shift+N - // Handled here instead of relying on SwiftUI's CommandGroup menu item because - // after a browser panel has been shown, SwiftUI's menu dispatch can silently - // consume the key equivalent without firing the action closure. - if matchConfiguredShortcut(event: event, action: .newWindow) { - openNewMainWindow(nil) - return true - } + @MainActor + func openPreferencesWindow(debugSource: String, navigationTarget: SettingsNavigationTarget? = nil) { +#if DEBUG + dlog("settings.open.request source=\(debugSource)") +#endif + Self.presentPreferencesWindow(navigationTarget: navigationTarget) + } - // Open Folder: Cmd+O - // Handled here to prevent AppKit's default NSDocumentController from opening - // the Documents folder when SwiftUI menu dispatch fails due to focus bugs. - if matchConfiguredShortcut(event: event, action: .openFolder) { - showOpenFolderPanel() - return true - } + @objc func openPreferencesWindow() { + openPreferencesWindow(debugSource: "appDelegate") + } - // Check Show Notifications shortcut - if matchConfiguredShortcut(event: event, action: .showNotifications) { - toggleNotificationsPopover(animated: false, anchorView: fullscreenControlsViewModel?.notificationsAnchorView) - return true - } + func refreshMenuBarExtraForDebug() { + menuBarExtraController?.refreshForDebugControls() + } - if matchConfiguredShortcut(event: event, action: .sendFeedback) { - guard let targetContext = preferredMainWindowContextForShortcuts(event: event), - let targetWindow = targetContext.window ?? windowForMainWindowId(targetContext.windowId) else { - return false + func showNotificationsPopoverFromMenuBar() { + let context: MainWindowContext? = { + if let keyWindow = NSApp.keyWindow, + let keyContext = contextForMainTerminalWindow(keyWindow) { + return keyContext } - setActiveMainWindow(targetWindow) - bringToFront(targetWindow) - NotificationCenter.default.post(name: .feedbackComposerRequested, object: targetWindow) - return true - } - - // Check Jump to Unread shortcut - if matchConfiguredShortcut(event: event, action: .jumpToUnread) { -#if DEBUG - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData(["jumpUnreadShortcutHandled": "1"]) + if let first = mainWindowContexts.values.first { + return first } -#endif - jumpToLatestUnread() - return true + let windowId = createMainWindow() + return mainWindowContexts.values.first(where: { $0.windowId == windowId }) + }() + + if let context, + let window = context.window ?? windowForMainWindowId(context.windowId) { + setActiveMainWindow(window) + bringToFront(window) } - // Flash the currently focused panel so the user can visually confirm focus. - if matchConfiguredShortcut(event: event, action: .triggerFlash) { - tabManager?.triggerFocusFlash() - return true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.titlebarAccessoryController.showNotificationsPopover(animated: false) } + } - // Surface navigation: Cmd+Shift+] / Cmd+Shift+[ - if matchConfiguredShortcut(event: event, action: .nextSurface) { - tabManager?.selectNextSurface() - return true + #if DEBUG + @objc func showUpdatePill(_ sender: Any?) { + updateViewModel.debugOverrideText = nil + updateViewModel.overrideState = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {}, dismiss: {})) + } + + @objc func showUpdatePillLongNightly(_ sender: Any?) { + updateViewModel.debugOverrideText = "Update Available: 0.32.0-nightly+20260216.abc1234" + updateViewModel.overrideState = .notFound(.init(acknowledgement: {})) + } + + @objc func showUpdatePillLoading(_ sender: Any?) { + updateViewModel.debugOverrideText = nil + updateViewModel.overrideState = .checking(.init(cancel: {})) + } + + @objc func hideUpdatePill(_ sender: Any?) { + updateViewModel.debugOverrideText = nil + updateViewModel.overrideState = .idle + } + + @objc func clearUpdatePillOverride(_ sender: Any?) { + updateViewModel.debugOverrideText = nil + updateViewModel.overrideState = nil + } +#endif + + @objc func copyUpdateLogs(_ sender: Any?) { + let logText = UpdateLogStore.shared.snapshot() + let payload: String + if logText.isEmpty { + payload = "No update logs captured.\nLog file: \(UpdateLogStore.shared.logPath())" + } else { + payload = logText + "\nLog file: \(UpdateLogStore.shared.logPath())" } - if matchConfiguredShortcut(event: event, action: .prevSurface) { - tabManager?.selectPreviousSurface() - return true + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(payload, forType: .string) + } + @objc func copyFocusLogs(_ sender: Any?) { + let logText = FocusLogStore.shared.snapshot() + let payload: String + if logText.isEmpty { + payload = "No focus logs captured.\nLog file: \(FocusLogStore.shared.logPath())" + } else { + payload = logText + "\nLog file: \(FocusLogStore.shared.logPath())" } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(payload, forType: .string) + } - if matchConfiguredShortcut(event: event, action: .toggleTerminalCopyMode) { - let handled = tabManager?.toggleFocusedTerminalCopyMode() ?? false + @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 dlog( - "shortcut.action name=toggleTerminalCopyMode handled=\(handled ? 1 : 0) " + - "\(debugShortcutRouteSnapshot(event: event))" + "reactGrab.pasteback h3.didCopy.drop " + + "reason=missingNotificationFields " + + "workspace=\(Self.debugShortId(notification.userInfo?[ReactGrabPastebackNotificationKey.workspaceId] as? UUID)) " + + "browser=\(Self.debugShortId(browserPanelId)) " + + "return=\(Self.debugShortId(notification.userInfo?[ReactGrabPastebackNotificationKey.returnPanelId] as? UUID)) " + + "hasContent=\((notification.userInfo?[ReactGrabPastebackNotificationKey.content] as? String) != nil ? 1 : 0)" ) #endif - // Only consume when a focused terminal actually handled the toggle. - // Otherwise allow the event to continue through the responder chain. - return handled + return } - // Workspace navigation: Cmd+Ctrl+] / Cmd+Ctrl+[ - if matchConfiguredShortcut(event: event, action: .nextSidebarTab) { + guard let manager = tabManagerFor(tabId: workspaceId), + let workspace = manager.tabs.first(where: { $0.id == workspaceId }) else { #if DEBUG - let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" dlog( - "ws.shortcut dir=next repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)" + "reactGrab.pasteback h3.didCopy.drop " + + "reason=missingWorkspace workspace=\(Self.debugShortId(workspaceId)) " + + "browser=\(Self.debugShortId(browserPanelId)) return=\(Self.debugShortId(returnPanelId))" ) #endif - tabManager?.selectNextTab() - return true + return } - if matchConfiguredShortcut(event: event, action: .prevSidebarTab) { + guard workspace.terminalPanel(for: returnPanelId) != nil else { #if DEBUG - let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" dlog( - "ws.shortcut dir=prev repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)" + "reactGrab.pasteback h3.didCopy.drop " + + "reason=missingReturnTerminal workspace=\(Self.debugShortId(workspaceId)) " + + "browser=\(Self.debugShortId(browserPanelId)) return=\(Self.debugShortId(returnPanelId)) " + + "focused=\(Self.debugShortId(workspace.focusedPanelId))" ) #endif - tabManager?.selectPreviousTab() - return true - } - - if matchConfiguredShortcut(event: event, action: .renameWorkspace) { - return requestRenameWorkspaceViaCommandPalette( - preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - ) + return } - if matchConfiguredShortcut(event: event, action: .editWorkspaceDescription) { #if DEBUG - dlog( - "shortcut.editWorkspaceDescription matched target={\(debugWindowToken(commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow))} " + - "\(debugShortcutRouteSnapshot(event: event))" - ) + dlog( + "reactGrab.pasteback h3.didCopy " + + "workspace=\(Self.debugShortId(workspaceId)) " + + "browser=\(Self.debugShortId(browserPanelId)) " + + "return=\(Self.debugShortId(returnPanelId)) " + + "focusedBefore=\(Self.debugShortId(workspace.focusedPanelId)) len=\(content.count)" + ) #endif - return requestEditWorkspaceDescriptionViaCommandPalette( - preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - ) - } + manager.focusTab(workspaceId, surfaceId: returnPanelId, suppressFlash: true) +#if DEBUG + dlog( + "reactGrab.pasteback h1.focusRequested " + + "workspace=\(Self.debugShortId(workspaceId)) " + + "return=\(Self.debugShortId(returnPanelId)) " + + "focusedAfterRequest=\(Self.debugShortId(workspace.focusedPanelId))" + ) +#endif + sendTextWhenReady(content, to: workspace, preferredPanelId: returnPanelId) + } - if matchConfiguredShortcut(event: event, action: .closeOtherTabsInPane) { - if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, - targetWindow.identifier?.rawValue == "cmux.settings" { - targetWindow.performClose(nil) - } else { - let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - if let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) { - terminalContext.tabManager.closeOtherTabsInFocusedPaneWithConfirmation() - } else { - tabManager?.closeOtherTabsInFocusedPaneWithConfirmation() - } - } - return true + nonisolated private static func debugShortId(_ id: UUID?) -> String { + id.map { String($0.uuidString.prefix(5)) } ?? "nil" + } + + static func resolveTerminalPanelForTextSend(in tab: Tab, preferredPanelId: UUID? = nil) -> TerminalPanel? { + if let preferredPanelId { + return tab.terminalPanel(for: preferredPanelId) } + return tab.focusedTerminalPanel + } - // Cmd+W must close the focused panel even if first-responder momentarily lags on a - // browser NSTextView during split focus transitions. - if matchConfiguredShortcut(event: event, action: .closeTab) { - let targetWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow - let routedManager = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager - // Browser popup windows primarily intercept Cmd+W in BrowserPopupPanel. - // This AppDelegate path is a fallback for cases where AppKit routes the - // event through the global shortcut handler first. - if let targetWindow = [targetWindow, NSApp.keyWindow] - .compactMap({ $0 }) - .first(where: { $0.identifier?.rawValue == "programa.browser-popup" }) { -#if DEBUG - dlog("shortcut.cmdW route=browserPopup") -#endif - targetWindow.performClose(nil) - return true - } else if let targetWindow, - programaWindowShouldOwnCloseShortcut(targetWindow) { - targetWindow.performClose(nil) - } else { - if let routedManager { + func sendTextWhenReady( + _ text: String, + to tab: Tab, + preferredPanelId: UUID? = nil, + beforeSend: (() -> Void)? = nil + ) { + let isReactGrabPasteback = preferredPanelId != nil #if DEBUG - let selectedWorkspace = routedManager.selectedWorkspace - 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")" - ) + let initialTargetPanel = Self.resolveTerminalPanelForTextSend( + in: tab, + preferredPanelId: preferredPanelId + ) + if isReactGrabPasteback { + dlog( + "reactGrab.pasteback h2.send.start " + + "workspace=\(Self.debugShortId(tab.id)) " + + "preferred=\(Self.debugShortId(preferredPanelId)) " + + "focused=\(Self.debugShortId(tab.focusedPanelId)) " + + "focusedTerminal=\(Self.debugShortId(tab.focusedTerminalPanel?.id)) " + + "resolved=\(Self.debugShortId(initialTargetPanel?.id)) " + + "surfaceReady=\(initialTargetPanel?.surface.surface != nil ? 1 : 0) len=\(text.count)" + ) + } #endif - routedManager.closeCurrentPanelWithConfirmation() - } else { + if let terminalPanel = Self.resolveTerminalPanelForTextSend( + in: tab, + preferredPanelId: preferredPanelId + ), + terminalPanel.surface.surface != nil { #if DEBUG - dlog("shortcut.cmdW route=noManager") + if isReactGrabPasteback { + dlog( + "reactGrab.pasteback h2.send.immediate " + + "workspace=\(Self.debugShortId(tab.id)) " + + "target=\(Self.debugShortId(terminalPanel.id)) len=\(text.count)" + ) + } #endif - return false - } + beforeSend?() + terminalPanel.sendText(text) +#if DEBUG + if isReactGrabPasteback { + dlog( + "reactGrab.pasteback h2.send.sent " + + "workspace=\(Self.debugShortId(tab.id)) " + + "target=\(Self.debugShortId(terminalPanel.id)) mode=immediate len=\(text.count)" + ) } - return true +#endif + return } - if matchConfiguredShortcut(event: event, action: .closeWorkspace) { - tabManager?.closeCurrentWorkspaceWithConfirmation() - return true - } + var resolved = false + var readyObserver: NSObjectProtocol? + var focusObserver: NSObjectProtocol? + var firstResponderObserver: NSObjectProtocol? + var panelsCancellable: AnyCancellable? - if matchConfiguredShortcut(event: event, action: .closeWindow) { - guard let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow else { - NSSound.beep() - return true + func cleanupObservers() { + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) } - closeWindowWithConfirmation(targetWindow) - return true - } - - if matchConfiguredShortcut(event: event, action: .renameTab) { - // Keep Cmd+R browser reload behavior when a browser panel is focused. - if tabManager?.focusedBrowserPanel != nil { - return false + if let focusObserver { + NotificationCenter.default.removeObserver(focusObserver) } - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteRenameTab(preferredWindow: targetWindow, source: "shortcut.renameTab") - return true + if let firstResponderObserver { + NotificationCenter.default.removeObserver(firstResponderObserver) + } + panelsCancellable?.cancel() } - // Numeric shortcuts for specific workspaces (9 = last workspace) - // Always consume the event when the digit matches to prevent Ghostty's - // goto_tab fallback from creating a new window when the index is out of bounds. - if let digit = numberedConfiguredShortcutDigit(event: event, action: .selectWorkspaceByNumber) { - if let manager = tabManager, - let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forDigit: digit, workspaceCount: manager.tabs.count) { + func finishIfReady() { + let terminalPanel = Self.resolveTerminalPanelForTextSend( + in: tab, + preferredPanelId: preferredPanelId + ) #if DEBUG + if isReactGrabPasteback { dlog( - "shortcut.action name=workspaceDigit digit=\(digit) targetIndex=\(targetIndex) manager=\(debugManagerToken(manager)) \(debugShortcutRouteSnapshot(event: event))" + "reactGrab.pasteback h2.finishIfReady " + + "workspace=\(Self.debugShortId(tab.id)) " + + "preferred=\(Self.debugShortId(preferredPanelId)) " + + "focused=\(Self.debugShortId(tab.focusedPanelId)) " + + "resolved=\(Self.debugShortId(terminalPanel?.id)) " + + "surfaceReady=\(terminalPanel?.surface.surface != nil ? 1 : 0) alreadyResolved=\(resolved ? 1 : 0)" ) + } #endif - manager.selectTab(at: targetIndex) + guard !resolved, + let terminalPanel, + terminalPanel.surface.surface != nil else { return } + resolved = true + cleanupObservers() + beforeSend?() + terminalPanel.sendText(text) +#if DEBUG + if isReactGrabPasteback { + dlog( + "reactGrab.pasteback h2.send.sent " + + "workspace=\(Self.debugShortId(tab.id)) " + + "target=\(Self.debugShortId(terminalPanel.id)) mode=delayed len=\(text.count)" + ) } - return true +#endif } - // Numeric shortcuts for surfaces within the focused pane (9 = last) - if let digit = numberedConfiguredShortcutDigit(event: event, action: .selectSurfaceByNumber) { - if digit == 9 { - tabManager?.selectLastSurface() - } else { - tabManager?.selectSurface(at: digit - 1) + panelsCancellable = tab.$panels + .map { _ in () } + .sink { _ in +#if DEBUG + if isReactGrabPasteback { + dlog( + "reactGrab.pasteback h2.panelsChanged " + + "workspace=\(Self.debugShortId(tab.id)) " + + "focused=\(Self.debugShortId(tab.focusedPanelId))" + ) + } +#endif + finishIfReady() } - 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 isReactGrabPasteback { + focusObserver = NotificationCenter.default.addObserver( + forName: .ghosttyDidFocusSurface, + object: nil, + queue: .main + ) { note in + guard let candidateTabId = note.userInfo?[GhosttyNotificationKey.tabId] as? UUID, + candidateTabId == tab.id, + let candidateSurfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { + return + } #if DEBUG - recordGotoSplitMoveIfNeeded(direction: .left) + dlog( + "reactGrab.pasteback h1.focusEvent " + + "workspace=\(Self.debugShortId(candidateTabId)) " + + "surface=\(Self.debugShortId(candidateSurfaceId)) " + + "target=\(Self.debugShortId(preferredPanelId)) " + + "match=\(candidateSurfaceId == preferredPanelId ? 1 : 0)" + ) #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) + } + firstResponderObserver = NotificationCenter.default.addObserver( + forName: .ghosttyDidBecomeFirstResponderSurface, + object: nil, + queue: .main + ) { note in + guard let candidateTabId = note.userInfo?[GhosttyNotificationKey.tabId] as? UUID, + candidateTabId == tab.id, + let candidateSurfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { + return + } #if DEBUG - recordGotoSplitMoveIfNeeded(direction: .right) + dlog( + "reactGrab.pasteback h1.firstResponderEvent " + + "workspace=\(Self.debugShortId(candidateTabId)) " + + "surface=\(Self.debugShortId(candidateSurfaceId)) " + + "target=\(Self.debugShortId(preferredPanelId)) " + + "match=\(candidateSurfaceId == preferredPanelId ? 1 : 0)" + ) #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) + 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 - recordGotoSplitMoveIfNeeded(direction: .up) + if isReactGrabPasteback { + dlog( + "reactGrab.pasteback h2.surfaceReadyEvent " + + "workspace=\(Self.debugShortId(workspaceId)) " + + "surface=\(Self.debugShortId(surfaceId)) " + + "target=\(Self.debugShortId(preferredPanelId)) " + + "match=\(surfaceId == preferredPanelId ? 1 : 0)" + ) + } #endif - return true + if let preferredPanelId, + let surfaceId, + surfaceId != preferredPanelId { + return + } + finishIfReady() } - if matchConfiguredDirectionalShortcut( - event: event, - action: .focusDown, - arrowGlyph: "↓", - arrowKeyCode: 125 - ) || (ghosttyGotoSplitDownShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↓", arrowKeyCode: 125) } ?? false) { - tabManager?.movePaneFocus(direction: .down) + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + if !resolved { #if DEBUG - recordGotoSplitMoveIfNeeded(direction: .down) + if isReactGrabPasteback { + dlog( + "reactGrab.pasteback h2.send.timeout " + + "workspace=\(Self.debugShortId(tab.id)) " + + "preferred=\(Self.debugShortId(preferredPanelId)) " + + "focused=\(Self.debugShortId(tab.focusedPanelId)) " + + "focusedTerminal=\(Self.debugShortId(tab.focusedTerminalPanel?.id))" + ) + } #endif - return true + cleanupObservers() + NSLog("Command send: surface not ready after 3.0s") + } } + } - if matchConfiguredShortcut(event: event, action: .toggleSplitZoom) { - _ = tabManager?.toggleFocusedSplitZoom() #if DEBUG - recordGotoSplitZoomIfNeeded() -#endif - return true + private let debugColorWorkspaceTitlePrefix = "Debug Color - " + let debugPerfWorkspaceTitlePrefix = "Debug Perf - " + var debugStressWorkspaceCreationInProgress = false + var debugStressLagProbeEnabled = false + let debugStressWorkspaceCount = 20 + let debugStressPaneCount = 4 + let debugStressTabsPerPane = 4 + let debugStressYieldInterval = 4 + let debugStressSurfaceLoadTimeoutSeconds: TimeInterval = 10.0 + + @objc func openDebugScrollbackTab(_ sender: Any?) { + guard let tabManager else { return } + let tab = tabManager.addTab() + let config = GhosttyConfig.load() + 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) + } + + @objc func openDebugLoremTab(_ sender: Any?) { + guard let tabManager else { return } + let tab = tabManager.addTab() + let lineCount = 2000 + let base = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore." + var lines: [String] = [] + lines.reserveCapacity(lineCount) + for index in 1...lineCount { + lines.append(String(format: "%04d %@", index, base)) } + let payload = lines.joined(separator: "\n") + "\n" + sendTextWhenReady(payload, to: tab) + } - // Split actions: Cmd+D / Cmd+Shift+D - if matchConfiguredShortcut(event: event, action: .splitRight) { -#if DEBUG - dlog("shortcut.action name=splitRight \(debugShortcutRouteSnapshot(event: event))") -#endif - if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .right) { - return true - } - _ = performSplitShortcut( - direction: .right, - preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - ) - return true + @objc func openDebugColorComparisonWorkspaces(_ sender: Any?) { + guard let tabManager else { return } + + let palette = WorkspaceTabColorSettings.palette() + guard !palette.isEmpty else { return } + + var existingByTitle: [String: Workspace] = [:] + for tab in tabManager.tabs { + guard let title = tab.customTitle, + title.hasPrefix(debugColorWorkspaceTitlePrefix) else { continue } + existingByTitle[title] = tab } - if matchConfiguredShortcut(event: event, action: .splitDown) { -#if DEBUG - dlog("shortcut.action name=splitDown \(debugShortcutRouteSnapshot(event: event))") -#endif - if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .down) { - return true + for entry in palette { + let title = "\(debugColorWorkspaceTitlePrefix)\(entry.name)" + let targetTab: Workspace + if let existing = existingByTitle[title] { + targetTab = existing + } else { + targetTab = tabManager.addTab() } - _ = performSplitShortcut( - direction: .down, - preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - ) - return true + tabManager.setCustomTitle(tabId: targetTab.id, title: title) + tabManager.setTabColor(tabId: targetTab.id, color: entry.hex) } + } - if matchConfiguredShortcut(event: event, action: .splitBrowserRight) { -#if DEBUG - dlog("shortcut.action name=splitBrowserRight \(debugShortcutRouteSnapshot(event: event))") -#endif - _ = performBrowserSplitShortcut(direction: .right) - return true - } - if matchConfiguredShortcut(event: event, action: .splitBrowserDown) { -#if DEBUG - dlog("shortcut.action name=splitBrowserDown \(debugShortcutRouteSnapshot(event: event))") #endif - _ = performBrowserSplitShortcut(direction: .down) - return true - } - // Surface navigation (legacy Ctrl+Tab support) - if matchTabShortcut(event: event, shortcut: StoredShortcut(key: "\t", command: false, shift: false, option: false, control: true)) { - tabManager?.selectNextSurface() - return true - } - if matchTabShortcut(event: event, shortcut: StoredShortcut(key: "\t", command: false, shift: true, option: false, control: true)) { - tabManager?.selectPreviousSurface() - return true - } - // New surface: Cmd+T - if matchConfiguredShortcut(event: event, action: .newSurface) { - tabManager?.newSurface() - return true - } + func attachUpdateAccessory(to window: NSWindow) { + titlebarAccessoryController.start() + titlebarAccessoryController.attach(to: window) + } - // Open browser: Cmd+Shift+L - if matchConfiguredShortcut(event: event, action: .openBrowser) { - _ = openBrowserAndFocusAddressBar(insertAtEnd: true) - return true - } + func applyWindowDecorations(to window: NSWindow) { + windowDecorationsController.apply(to: window) + } - if matchConfiguredShortcut(event: event, action: .focusBrowserAddressBar) { - if let focusedPanel = tabManager?.focusedBrowserPanel { - focusBrowserAddressBar(in: focusedPanel) - return true - } + func toggleNotificationsPopover(animated: Bool = true, anchorView: NSView? = nil) { + titlebarAccessoryController.toggleNotificationsPopover(animated: animated, anchorView: anchorView) + } - if let browserAddressBarFocusedPanelId, - focusBrowserAddressBar(panelId: browserAddressBarFocusedPanelId) { - return true - } + @discardableResult + func dismissNotificationsPopoverIfShown() -> Bool { + titlebarAccessoryController.dismissNotificationsPopoverIfShown() + } - if openBrowserAndFocusAddressBar(insertAtEnd: true) != nil { - return true - } - } + func isNotificationsPopoverShown() -> Bool { + titlebarAccessoryController.isNotificationsPopoverShown() + } - if matchConfiguredShortcut(event: event, action: .browserBack) { - guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { - return false + func jumpToLatestUnread() { + guard let notificationStore else { return } +#if DEBUG + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData([ + "jumpUnreadInvoked": "1", + "jumpUnreadNotificationCount": String(notificationStore.notifications.count), + ]) + } +#endif + // Prefer the latest unread that we can actually open. In early startup (especially on the VM), + // the window-context registry can lag behind model initialization, so fall back to whatever + // tab manager currently owns the tab. + for notification in notificationStore.notifications where !notification.isRead { + if openNotification(tabId: notification.tabId, surfaceId: notification.surfaceId, notificationId: notification.id) { + return } - focusedBrowserPanel.goBack() - return true } + } - if matchConfiguredShortcut(event: event, action: .browserForward) { - guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { - return false + static func installWindowResponderSwizzlesForTesting() { + _ = didInstallWindowKeyEquivalentSwizzle + _ = didInstallWindowFirstResponderSwizzle + _ = didInstallWindowSendEventSwizzle + } + +#if DEBUG + static func setWindowFirstResponderGuardTesting(currentEvent: NSEvent?, hitView: NSView?) { + programaFirstResponderGuardCurrentEventOverride = currentEvent + programaFirstResponderGuardHitViewOverride = hitView + } + + static func clearWindowFirstResponderGuardTesting() { + programaFirstResponderGuardCurrentEventOverride = nil + programaFirstResponderGuardHitViewOverride = nil + } +#endif + + private func installWindowResponderSwizzles() { + _ = Self.didInstallApplicationSendEventSwizzle + _ = Self.didInstallWindowKeyEquivalentSwizzle + _ = Self.didInstallWindowFirstResponderSwizzle + _ = Self.didInstallWindowSendEventSwizzle + } + + private func installShortcutMonitor() { + // Local monitor only receives events when app is active (not global) + shortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp, .flagsChanged]) { [weak self] event in + guard let self else { return event } + if event.type == .keyDown { +#if DEBUG + let phaseTotalStart = ProcessInfo.processInfo.systemUptime + let preludeStart = ProcessInfo.processInfo.systemUptime + var preludeMs: Double = 0 + var shortcutMs: Double = 0 + ProgramaTypingTiming.logEventDelay(path: "appMonitor", event: event) + let shortcutMonitorTraceEnabled = + ProcessInfo.processInfo.environment["PROGRAMA_SHORTCUT_MONITOR_TRACE"] == "1" + || UserDefaults.standard.bool(forKey: "programaShortcutMonitorTrace") + if shortcutMonitorTraceEnabled { + let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil") \(self.debugShortcutRouteSnapshot(event: event))" + ) + } + if let probeKind = self.developerToolsShortcutProbeKind(event: event) { + self.logDeveloperToolsShortcutSnapshot(phase: "monitor.pre.\(probeKind)", event: event) + } + preludeMs = (ProcessInfo.processInfo.systemUptime - preludeStart) * 1000.0 + let shortcutTimingStart = ProgramaTypingTiming.start() +#endif + let shortcutStart = ProcessInfo.processInfo.systemUptime + let handledByShortcut = self.handleCustomShortcut(event: event) +#if DEBUG + shortcutMs = (ProcessInfo.processInfo.systemUptime - shortcutStart) * 1000.0 + ProgramaTypingTiming.logDuration( + path: "appMonitor.handleCustomShortcut", + startedAt: shortcutTimingStart, + event: event, + extra: "handled=\(handledByShortcut ? 1 : 0)" + ) + let shortcutElapsedMs = (ProcessInfo.processInfo.systemUptime - shortcutStart) * 1000.0 + self.logSlowShortcutMonitorLatencyIfNeeded( + event: event, + handledByShortcut: handledByShortcut, + elapsedMs: shortcutElapsedMs + ) + let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0 + ProgramaTypingTiming.logBreakdown( + path: "appMonitor.phase", + totalMs: totalMs, + event: event, + thresholdMs: 0.75, + parts: [ + ("preludeMs", preludeMs), + ("shortcutMs", shortcutMs), + ], + extra: "handled=\(handledByShortcut ? 1 : 0)" + ) +#endif + if handledByShortcut { +#if DEBUG + dlog(" → consumed by handleCustomShortcut") +#endif + return nil // Consume the event + } + return event // Pass through } - focusedBrowserPanel.goForward() - return true - } - - if matchConfiguredShortcut(event: event, action: .browserReload) { - guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { - return false + self.handleBrowserOmnibarSelectionRepeatLifecycleEvent(event) + if self.clearEscapeSuppressionForKeyUp(event: event, consumeIfSuppressed: true) { + return nil } - focusedBrowserPanel.reload() - return true + return event } + } - // Safari defaults: - // - Option+Command+I => Show/Toggle Web Inspector - // - Option+Command+C => Show JavaScript Console - if matchConfiguredShortcut(event: event, action: .toggleBrowserDeveloperTools) { -#if DEBUG - logDeveloperToolsShortcutSnapshot(phase: "toggle.pre", event: event) -#endif - let didHandle = tabManager?.toggleDeveloperToolsFocusedBrowser() ?? false -#if DEBUG - logDeveloperToolsShortcutSnapshot(phase: "toggle.post", event: event, didHandle: didHandle) - DispatchQueue.main.async { [weak self] in - self?.logDeveloperToolsShortcutSnapshot(phase: "toggle.tick", didHandle: didHandle) - } -#endif - if !didHandle { NSSound.beep() } - return true + private func installShortcutDefaultsObserver() { + guard shortcutDefaultsObserver == nil else { return } + refreshConfiguredShortcutChordActions() + shortcutDefaultsObserver = NotificationCenter.default.addObserver( + forName: KeyboardShortcutSettings.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.refreshConfiguredShortcutChordActions() + self?.clearConfiguredShortcutChordState() + self?.scheduleSplitButtonTooltipRefreshAcrossWorkspaces() } + } - if matchConfiguredShortcut(event: event, action: .showBrowserJavaScriptConsole) { -#if DEBUG - logDeveloperToolsShortcutSnapshot(phase: "console.pre", event: event) -#endif - let didHandle = tabManager?.showJavaScriptConsoleFocusedBrowser() ?? false -#if DEBUG - logDeveloperToolsShortcutSnapshot(phase: "console.post", event: event, didHandle: didHandle) - DispatchQueue.main.async { [weak self] in - self?.logDeveloperToolsShortcutSnapshot(phase: "console.tick", didHandle: didHandle) - } -#endif - if !didHandle { NSSound.beep() } - return true + private func refreshConfiguredShortcutChordActions() { + configuredShortcutChordActions = KeyboardShortcutSettings.Action.allCases.filter { + KeyboardShortcutSettings.shortcut(for: $0).hasChord } + } - if matchConfiguredShortcut(event: event, action: .toggleReactGrab) { - let didHandle = tabManager?.toggleReactGrabFromCurrentFocus() ?? false - if !didHandle { NSSound.beep() } - return true + private func clearConfiguredShortcutChordState() { + pendingConfiguredShortcutChord = nil + activeConfiguredShortcutChordPrefixForCurrentEvent = nil + } + + /// Coalesce shortcut-default changes and refresh on the next runloop turn to + /// avoid mutating Bonsplit/SwiftUI-observed state during an active update pass. + private func scheduleSplitButtonTooltipRefreshAcrossWorkspaces() { + guard !splitButtonTooltipRefreshScheduled else { return } + splitButtonTooltipRefreshScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.splitButtonTooltipRefreshScheduled = false + self.refreshSplitButtonTooltipsAcrossWorkspaces() } + } - if matchConfiguredShortcut(event: event, action: .browserZoomIn) { - return tabManager?.zoomInFocusedBrowser() ?? false + private func refreshSplitButtonTooltipsAcrossWorkspaces() { + var refreshedManagers: Set = [] + if let manager = tabManager { + manager.refreshSplitButtonTooltips() + refreshedManagers.insert(ObjectIdentifier(manager)) + } + for context in mainWindowContexts.values { + let manager = context.tabManager + let identifier = ObjectIdentifier(manager) + guard refreshedManagers.insert(identifier).inserted else { continue } + manager.refreshSplitButtonTooltips() } + } - if matchConfiguredShortcut(event: event, action: .browserZoomOut) { - return tabManager?.zoomOutFocusedBrowser() ?? false + private func installGhosttyConfigObserver() { + guard ghosttyConfigObserver == nil else { return } + ghosttyConfigObserver = NotificationCenter.default.addObserver( + forName: .ghosttyConfigDidReload, + object: nil, + queue: .main + ) { [weak self] _ in + self?.refreshGhosttyGotoSplitShortcuts() } + } - if matchConfiguredShortcut(event: event, action: .browserZoomReset) { - return tabManager?.resetZoomFocusedBrowser() ?? false + private func refreshGhosttyGotoSplitShortcuts() { + guard let config = GhosttyApp.shared.config else { + ghosttyGotoSplitLeftShortcut = nil + ghosttyGotoSplitRightShortcut = nil + ghosttyGotoSplitUpShortcut = nil + ghosttyGotoSplitDownShortcut = nil + return } - if matchConfiguredShortcut(event: event, action: .find) { - guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { - return false + ghosttyGotoSplitLeftShortcut = storedShortcutFromGhosttyTrigger( + ghostty_config_trigger(config, "goto_split:left", UInt("goto_split:left".utf8.count)) + ) + ghosttyGotoSplitRightShortcut = storedShortcutFromGhosttyTrigger( + ghostty_config_trigger(config, "goto_split:right", UInt("goto_split:right".utf8.count)) + ) + ghosttyGotoSplitUpShortcut = storedShortcutFromGhosttyTrigger( + ghostty_config_trigger(config, "goto_split:up", UInt("goto_split:up".utf8.count)) + ) + ghosttyGotoSplitDownShortcut = storedShortcutFromGhosttyTrigger( + ghostty_config_trigger(config, "goto_split:down", UInt("goto_split:down".utf8.count)) + ) + } + + private func storedShortcutFromGhosttyTrigger(_ trigger: ghostty_input_trigger_s) -> StoredShortcut? { + let key: String + switch trigger.tag { + case GHOSTTY_TRIGGER_PHYSICAL: + switch trigger.key.physical { + case GHOSTTY_KEY_ARROW_LEFT: + key = "←" + case GHOSTTY_KEY_ARROW_RIGHT: + key = "→" + case GHOSTTY_KEY_ARROW_UP: + key = "↑" + case GHOSTTY_KEY_ARROW_DOWN: + key = "↓" + case GHOSTTY_KEY_A: key = "a" + case GHOSTTY_KEY_B: key = "b" + case GHOSTTY_KEY_C: key = "c" + case GHOSTTY_KEY_D: key = "d" + case GHOSTTY_KEY_E: key = "e" + case GHOSTTY_KEY_F: key = "f" + case GHOSTTY_KEY_G: key = "g" + case GHOSTTY_KEY_H: key = "h" + case GHOSTTY_KEY_I: key = "i" + case GHOSTTY_KEY_J: key = "j" + case GHOSTTY_KEY_K: key = "k" + case GHOSTTY_KEY_L: key = "l" + case GHOSTTY_KEY_M: key = "m" + case GHOSTTY_KEY_N: key = "n" + case GHOSTTY_KEY_O: key = "o" + case GHOSTTY_KEY_P: key = "p" + case GHOSTTY_KEY_Q: key = "q" + case GHOSTTY_KEY_R: key = "r" + case GHOSTTY_KEY_S: key = "s" + case GHOSTTY_KEY_T: key = "t" + case GHOSTTY_KEY_U: key = "u" + case GHOSTTY_KEY_V: key = "v" + case GHOSTTY_KEY_W: key = "w" + case GHOSTTY_KEY_X: key = "x" + case GHOSTTY_KEY_Y: key = "y" + case GHOSTTY_KEY_Z: key = "z" + case GHOSTTY_KEY_DIGIT_0: key = "0" + case GHOSTTY_KEY_DIGIT_1: key = "1" + case GHOSTTY_KEY_DIGIT_2: key = "2" + case GHOSTTY_KEY_DIGIT_3: key = "3" + case GHOSTTY_KEY_DIGIT_4: key = "4" + case GHOSTTY_KEY_DIGIT_5: key = "5" + case GHOSTTY_KEY_DIGIT_6: key = "6" + case GHOSTTY_KEY_DIGIT_7: key = "7" + case GHOSTTY_KEY_DIGIT_8: key = "8" + case GHOSTTY_KEY_DIGIT_9: key = "9" + case GHOSTTY_KEY_BRACKET_LEFT: key = "[" + case GHOSTTY_KEY_BRACKET_RIGHT: key = "]" + case GHOSTTY_KEY_MINUS: key = "-" + case GHOSTTY_KEY_EQUAL: key = "=" + case GHOSTTY_KEY_COMMA: key = "," + case GHOSTTY_KEY_PERIOD: key = "." + case GHOSTTY_KEY_SLASH: key = "/" + case GHOSTTY_KEY_SEMICOLON: key = ";" + case GHOSTTY_KEY_QUOTE: key = "'" + case GHOSTTY_KEY_BACKQUOTE: key = "`" + case GHOSTTY_KEY_BACKSLASH: key = "\\" + default: + return nil } - tabManager?.startSearch() - return true + case GHOSTTY_TRIGGER_UNICODE: + guard let scalar = UnicodeScalar(trigger.key.unicode) else { return nil } + key = String(Character(scalar)).lowercased() + case GHOSTTY_TRIGGER_CATCH_ALL: + return nil + default: + return nil } - if matchConfiguredShortcut(event: event, action: .findNext) { - guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { - return false - } - tabManager?.findNext() - return true + let mods = trigger.mods.rawValue + let command = (mods & GHOSTTY_MODS_SUPER.rawValue) != 0 + let shift = (mods & GHOSTTY_MODS_SHIFT.rawValue) != 0 + let option = (mods & GHOSTTY_MODS_ALT.rawValue) != 0 + let control = (mods & GHOSTTY_MODS_CTRL.rawValue) != 0 + + // Ignore bogus empty triggers. + if key.isEmpty || (!command && !shift && !option && !control) { + return nil } - if matchConfiguredShortcut(event: event, action: .findPrevious) { - guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { - return false - } - tabManager?.findPrevious() + return StoredShortcut(key: key, command: command, shift: shift, option: option, control: control) + } + + private func handleQuitShortcutWarning() -> Bool { + // Tagged DEV builds are ephemeral, skip quit confirmation entirely. + if SocketControlSettings.isTaggedDevBuild() { + NSApp.terminate(nil) return true } - if matchConfiguredShortcut(event: event, action: .hideFind) { - guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { - return false - } - tabManager?.hideFind() + if !QuitWarningSettings.isEnabled() { + NSApp.terminate(nil) return true } - if matchConfiguredShortcut(event: event, action: .useSelectionForFind) { - tabManager?.searchSelection() - return true + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(localized: "dialog.quitPrograma.title", defaultValue: "Quit Programa?") + alert.informativeText = String(localized: "dialog.quitPrograma.message", defaultValue: "This will close all windows and workspaces.") + alert.addButton(withTitle: String(localized: "dialog.quitPrograma.quit", defaultValue: "Quit")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.showsSuppressionButton = true + alert.suppressionButton?.title = String(localized: "dialog.dontWarnCmdQ", defaultValue: "Don't warn again for Cmd+Q") + + let response = alert.runModal() + if alert.suppressionButton?.state == .on { + QuitWarningSettings.setEnabled(false) } - if matchConfiguredShortcut(event: event, action: .reopenClosedBrowserPanel) { - _ = tabManager?.reopenMostRecentlyClosedBrowserPanel() - return true + if response == .alertFirstButtonReturn { + // Mark as confirmed so applicationShouldTerminate does not show a + // second alert when NSApp.terminate re-enters the delegate callback. + isQuitWarningConfirmed = true + NSApp.terminate(nil) } - - return false + return true } - private func shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: SplitDirection) -> Bool { + func promptRenameSelectedWorkspace() -> Bool { guard let tabManager, - let workspace = tabManager.selectedWorkspace, - let focusedPanelId = workspace.focusedPanelId, - let terminalPanel = workspace.terminalPanel(for: focusedPanelId) else { + let tabId = tabManager.selectedTabId, + let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { + NSSound.beep() return false } - let hostedView = terminalPanel.hostedView - let hostedSize = hostedView.bounds.size - let hostedHiddenInHierarchy = hostedView.isHiddenOrHasHiddenAncestor - let hostedAttachedToWindow = hostedView.window != nil - let firstResponderIsWindow = NSApp.keyWindow?.firstResponder is NSWindow - - let shouldSuppress = shouldSuppressSplitShortcutForTransientTerminalFocusInputs( - firstResponderIsWindow: firstResponderIsWindow, - hostedSize: hostedSize, - hostedHiddenInHierarchy: hostedHiddenInHierarchy, - hostedAttachedToWindow: hostedAttachedToWindow - ) - guard shouldSuppress else { return false } - - tabManager.reconcileFocusedPanelFromFirstResponderForKeyboard() - -#if DEBUG - let directionLabel: String - switch direction { - case .left: directionLabel = "left" - case .right: directionLabel = "right" - case .up: directionLabel = "up" - case .down: directionLabel = "down" + let alert = NSAlert() + alert.messageText = String(localized: "dialog.renameWorkspace.title", defaultValue: "Rename Workspace") + alert.informativeText = String(localized: "dialog.renameWorkspace.message", defaultValue: "Enter a custom name for this workspace.") + let input = NSTextField(string: tab.customTitle ?? tab.title) + input.placeholderString = String(localized: "dialog.renameWorkspace.placeholder", defaultValue: "Workspace name") + input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) + alert.accessoryView = input + alert.addButton(withTitle: String(localized: "common.rename", defaultValue: "Rename")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) } - let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - dlog( - "split.shortcut suppressed dir=\(directionLabel) reason=transient_focus_state " + - "fr=\(firstResponderType) hidden=\(hostedHiddenInHierarchy ? 1 : 0) " + - "attached=\(hostedAttachedToWindow ? 1 : 0) " + - "frame=\(String(format: "%.1fx%.1f", hostedSize.width, hostedSize.height))" - ) -#endif + + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { return true } + tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue) return true } + // Precedence encoded by this function's evaluation order (each phase is checked + // strictly after the previous one; the first phase that returns wins). Refs #95. + // 1. Setup: chord-prefix bookkeeping, Ctrl+D debug probe, close-confirmation-alert + // passthrough, modal/sheet passthrough, command-palette window/state computation. + // 2. Palette (highest real precedence): Escape-key routing (palette dismiss / + // suppressed-escape grace window), palette selection-navigation (arrow keys), + // palette interactive Return/dismiss handling, stale browser-address-bar-focus + // clear, palette "effective" actions (open palette / go-to-workspace + their + // chord arming), shouldConsumeShortcutWhileCommandPaletteVisible catch-all. + // 3. Browser/terminal pre-checks: terminal IME marked-text passthrough, notifications + // popover escape/typing consumption, shortcut-routing-context sync guard, Ctrl+D + // terminal-focus reconcile bypass, browser omnibar Cmd/Ctrl+N/P and arrow-key + // selection, empty-flags fast-path passthrough, browser-address-bar Emacs-nav + // bypass. + // 4. App-shortcut (lowest precedence, only reached once nothing above claimed the + // event): the flat table of ~55 `matchConfiguredShortcut`/digit/directional/tab + // checks, extracted verbatim into handleConfiguredAppShortcutActions(event:...). + private func handleCustomShortcut(event: NSEvent) -> Bool { + // `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.), + // charactersIgnoringModifiers returns non-ASCII characters that never match + // Latin shortcut keys. Normalize via KeyboardLayout so downstream comparisons + // (Cmd+1-9, Ctrl+1-9, omnibar N/P, command palette, etc.) work correctly. + let chars = KeyboardLayout.normalizedCharacters(for: event) + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let hasControl = flags.contains(.control) + let hasCommand = flags.contains(.command) + let hasOption = flags.contains(.option) + let isControlOnly = hasControl && !hasCommand && !hasOption + let controlDChar = chars == "d" || event.characters == "\u{04}" + let isControlD = isControlOnly && (controlDChar || event.keyCode == 2) + let configuredShortcutEventWindowNumber = configuredShortcutChordWindowNumber(for: event) + if let pendingConfiguredShortcutChord, + pendingConfiguredShortcutChord.windowNumber == configuredShortcutEventWindowNumber { + activeConfiguredShortcutChordPrefixForCurrentEvent = pendingConfiguredShortcutChord.firstStroke + } else { + activeConfiguredShortcutChordPrefixForCurrentEvent = nil + } + pendingConfiguredShortcutChord = nil + defer { activeConfiguredShortcutChordPrefixForCurrentEvent = nil } #if DEBUG - private func logBrowserZoomShortcutTrace( - stage: String, - event: NSEvent, - flags: NSEvent.ModifierFlags, - chars: String, - action: BrowserZoomShortcutAction? = nil, - handled: Bool? = nil - ) { - guard browserZoomShortcutTraceCandidate( - flags: flags, - chars: chars, - keyCode: event.keyCode, - literalChars: event.characters - ) else { - return + if isControlD { + writeChildExitKeyboardProbe( + [ + "probeAppShortcutCharsHex": childExitKeyboardProbeHex(event.characters), + "probeAppShortcutCharsIgnoringHex": childExitKeyboardProbeHex(event.charactersIgnoringModifiers), + "probeAppShortcutKeyCode": String(event.keyCode), + "probeAppShortcutModsRaw": String(event.modifierFlags.rawValue), + ], + increments: ["probeAppShortcutCtrlDSeenCount": 1] + ) } +#endif - let keyWindow = NSApp.keyWindow - let firstResponderType = keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let panel = tabManager?.focusedBrowserPanel - let panelToken = panel.map { String($0.id.uuidString.prefix(8)) } ?? "nil" - let panelZoom = panel?.webView.pageZoom ?? -1 - var line = - "zoom.shortcut stage=\(stage) event=\(NSWindow.keyDescription(event)) " + - "chars='\(chars)' flags=\(browserZoomShortcutTraceFlagsString(flags)) " + - "action=\(browserZoomShortcutTraceActionString(action)) keyWin=\(keyWindow?.windowNumber ?? -1) " + - "fr=\(firstResponderType) panel=\(panelToken) zoom=\(String(format: "%.3f", panelZoom)) " + - "addrBarId=\(browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")" - if let handled { - line += " handled=\(handled ? 1 : 0)" + // Don't steal shortcuts from close-confirmation alerts. Keep standard alert key + // equivalents working and avoid surprising actions while the confirmation is up. + let closeConfirmationTitles = [ + String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"), + String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?"), + String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), + String(localized: "dialog.closeOtherTabs.title", defaultValue: "Close other tabs?"), + String(localized: "dialog.closeWindow.title", defaultValue: "Close window?"), + ] + let closeConfirmationPanel = NSApp.windows + .compactMap { $0 as? NSPanel } + .first { panel in + guard panel.isVisible, let root = panel.contentView else { return false } + return closeConfirmationTitles.contains { title in + findStaticText(in: root, equals: title) + } + } + if let closeConfirmationPanel { + // Special-case: Cmd+D should confirm destructive close on alerts. + // XCUITest key events often hit the app-level local monitor first, so forward the key + // equivalent to the alert panel explicitly. + if matchShortcut( + event: event, + shortcut: StoredShortcut(key: "d", command: true, shift: false, option: false, control: false) + ), + let root = closeConfirmationPanel.contentView, + let closeButton = findButton( + in: root, + titled: String(localized: "common.close", defaultValue: "Close") + ) { + closeButton.performClick(nil) + return true + } + return false } - dlog(line) - } - private func browserFocusStateSnapshot() -> String { - let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" - let focused = tabManager?.selectedWorkspace?.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - let addressBar = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - let keyWindow = NSApp.keyWindow?.windowNumber ?? -1 - let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - return "selected=\(selected) focused=\(focused) addr=\(addressBar) keyWin=\(keyWindow) fr=\(firstResponderType)" - } - - private func redactedDebugURL(_ url: URL?) -> String { - guard let url else { return "nil" } - guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return "" + if NSApp.modalWindow != nil || NSApp.keyWindow?.attachedSheet != nil { + return false } - components.user = nil - components.password = nil - components.query = nil - components.fragment = nil - return components.string ?? "" - } -#endif - @discardableResult - private func focusBrowserAddressBar(panelId: UUID) -> Bool { - guard let tabManager, - let workspace = tabManager.selectedWorkspace, - let panel = workspace.browserPanel(for: panelId) else { + let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) + let commandPaletteTargetWindow = commandPaletteWindowForShortcutEvent(event) + let commandPaletteShortcutWindow = shouldHandleCommandPaletteShortcutEvent( + event, + paletteWindow: commandPaletteTargetWindow + ) ? commandPaletteTargetWindow : nil + let commandPaletteVisibleInTargetWindow = commandPaletteShortcutWindow.map { + isCommandPaletteVisible(for: $0) + } ?? false + let commandPalettePendingOpenInTargetWindow = commandPaletteTargetWindow.map { + isCommandPalettePendingOpen(for: $0) + } ?? false + let commandPaletteOverlayVisibleInTargetWindow = commandPaletteTargetWindow.map { + isCommandPaletteOverlayPresented(in: $0) + } ?? false + let commandPaletteResponderActiveInTargetWindow = commandPaletteTargetWindow.map { + isCommandPaletteResponderActive(in: $0) + } ?? false + let commandPaletteInteractiveInTargetWindow = + commandPaletteVisibleInTargetWindow + || commandPaletteOverlayVisibleInTargetWindow + || commandPaletteResponderActiveInTargetWindow + let commandPaletteEffectiveInTargetWindow = + commandPaletteInteractiveInTargetWindow + || commandPalettePendingOpenInTargetWindow + #if DEBUG + if event.keyCode == 36 || event.keyCode == 76 { dlog( - "browser.focus.addressBar.route panel=\(panelId.uuidString.prefix(5)) " + - "result=miss \(browserFocusStateSnapshot())" + "shortcut.return.raw " + + "interactive=\(commandPaletteInteractiveInTargetWindow ? 1 : 0) " + + "effective=\(commandPaletteEffectiveInTargetWindow ? 1 : 0) " + + "target={\(debugWindowToken(commandPaletteTargetWindow))} " + + "shortcutWindow={\(debugWindowToken(commandPaletteShortcutWindow))} " + + "responderTarget=\(commandPaletteResponderActiveInTargetWindow ? 1 : 0) " + + "overlayTarget=\(commandPaletteOverlayVisibleInTargetWindow ? 1 : 0) " + + "pendingTarget=\(commandPalettePendingOpenInTargetWindow ? 1 : 0) " + + "\(debugShortcutRouteSnapshot(event: event))" ) -#endif - return false - } -#if DEBUG - dlog( - "browser.focus.addressBar.route panel=\(panel.id.uuidString.prefix(5)) " + - "workspace=\(workspace.id.uuidString.prefix(5)) result=hit \(browserFocusStateSnapshot())" - ) -#endif - workspace.focusPanel(panel.id) -#if DEBUG - let focusedAfter = workspace.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - dlog( - "browser.focus.addressBar.route panel=\(panel.id.uuidString.prefix(5)) " + - "workspace=\(workspace.id.uuidString.prefix(5)) focusedAfter=\(focusedAfter)" - ) -#endif - focusBrowserAddressBar(in: panel) - return true - } - - @discardableResult - func openBrowserAndFocusAddressBar(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? { - let preferredProfileID = - tabManager?.focusedBrowserPanel?.profileID - ?? tabManager?.selectedWorkspace?.preferredBrowserProfileID - guard let panelId = tabManager?.openBrowser( - url: url, - preferredProfileID: preferredProfileID, - insertAtEnd: insertAtEnd - ) else { + } +#endif + + if normalizedFlags.isEmpty, event.keyCode == 53 { + let activePaletteWindow = activeCommandPaletteWindow() + let escapePaletteWindow: NSWindow? = { + if let targetWindow = commandPaletteTargetWindow { + guard commandPaletteEffectiveInTargetWindow else { + return nil + } + return targetWindow + } + return activePaletteWindow + }() #if DEBUG dlog( - "browser.focus.openAndFocus result=open_failed insertAtEnd=\(insertAtEnd ? 1 : 0) " + - "url=\(redactedDebugURL(url)) \(browserFocusStateSnapshot())" + "shortcut.escape route target={\(debugWindowToken(commandPaletteTargetWindow))} " + + "active={\(debugWindowToken(activePaletteWindow))} " + + "visibleTarget=\(commandPaletteVisibleInTargetWindow ? 1 : 0) " + + "pendingTarget=\(commandPalettePendingOpenInTargetWindow ? 1 : 0) " + + "overlayTarget=\(commandPaletteOverlayVisibleInTargetWindow ? 1 : 0) " + + "responderTarget=\(commandPaletteResponderActiveInTargetWindow ? 1 : 0) " + + "effectiveTarget=\(commandPaletteEffectiveInTargetWindow ? 1 : 0) " + + "\(debugShortcutRouteSnapshot(event: event))" ) + if commandPaletteTargetWindow != nil, + !commandPaletteVisibleInTargetWindow, + !commandPalettePendingOpenInTargetWindow, + (commandPaletteOverlayVisibleInTargetWindow || commandPaletteResponderActiveInTargetWindow) { + dlog( + "shortcut.escape stateMismatch target={\(debugWindowToken(commandPaletteTargetWindow))} " + + "overlayTarget=\(commandPaletteOverlayVisibleInTargetWindow ? 1 : 0) " + + "responderTarget=\(commandPaletteResponderActiveInTargetWindow ? 1 : 0)" + ) + } #endif - return nil - } + if let paletteWindow = escapePaletteWindow, + isCommandPaletteEffectivelyVisible(in: paletteWindow) { + if commandPaletteMarkedTextInput(in: paletteWindow) != nil { #if DEBUG - dlog( - "browser.focus.openAndFocus result=open_ok panel=\(panelId.uuidString.prefix(5)) " + - "insertAtEnd=\(insertAtEnd ? 1 : 0) url=\(redactedDebugURL(url))" - ) + dlog( + "shortcut.escape imeMarkedTextBypass consumed=0 target={\(debugWindowToken(paletteWindow))}" + ) #endif + return false + } + clearCommandPalettePendingOpen(for: paletteWindow) + beginCommandPaletteEscapeSuppression(for: paletteWindow) + NotificationCenter.default.post(name: .commandPaletteToggleRequested, object: paletteWindow) #if DEBUG - let didFocus = focusBrowserAddressBar(panelId: panelId) - dlog( - "browser.focus.openAndFocus result=focus_request panel=\(panelId.uuidString.prefix(5)) " + - "focused=\(didFocus ? 1 : 0) \(browserFocusStateSnapshot())" - ) -#else - _ = focusBrowserAddressBar(panelId: panelId) + dlog("shortcut.escape paletteDismiss consumed=1 target={\(debugWindowToken(paletteWindow))}") #endif - return panelId - } - - private func focusBrowserAddressBar(in panel: BrowserPanel) { + return true + } + let suppressionWindow = commandPaletteTargetWindow + ?? event.window + ?? NSApp.keyWindow + ?? NSApp.mainWindow + if shouldConsumeSuppressedEscape(event: event, window: suppressionWindow) { #if DEBUG - let requestId = panel.requestAddressBarFocus() - dlog( - "browser.focus.addressBar.request panel=\(panel.id.uuidString.prefix(5)) " + - "request=\(requestId.uuidString.prefix(8)) \(browserFocusStateSnapshot())" - ) -#else - _ = panel.requestAddressBarFocus() + dlog( + "shortcut.escape suppressionConsume consumed=1 target={\(debugWindowToken(suppressionWindow))} " + + "repeat=\(event.isARepeat ? 1 : 0)" + ) #endif - browserAddressBarFocusedPanelId = panel.id + return true + } + if let requestAge = recentCommandPaletteRequestAge(for: suppressionWindow) { + beginCommandPaletteEscapeSuppression(for: suppressionWindow) #if DEBUG - dlog( - "browser.focus.addressBar.sticky panel=\(panel.id.uuidString.prefix(5)) " + - "request=\(requestId.uuidString.prefix(8)) \(browserFocusStateSnapshot())" - ) + dlog( + "shortcut.escape requestGraceConsume consumed=1 target={\(debugWindowToken(suppressionWindow))} " + + "ageMs=\(Int(requestAge * 1000)) repeat=\(event.isARepeat ? 1 : 0)" + ) #endif - NotificationCenter.default.post(name: .browserFocusAddressBar, object: panel.id) + return true + } #if DEBUG - dlog( - "browser.focus.addressBar.notify panel=\(panel.id.uuidString.prefix(5)) " + - "request=\(requestId.uuidString.prefix(8))" - ) + dlog( + "shortcut.escape paletteDismiss consumed=0 target={\(debugWindowToken(commandPaletteTargetWindow))} " + + "active={\(debugWindowToken(activePaletteWindow))}" + ) #endif - } + } - func focusedBrowserAddressBarPanelId() -> UUID? { - browserAddressBarFocusedPanelId - } + let paletteUsesInlineTextHandling = commandPaletteShortcutWindow.map { + isCommandPaletteMultilineTextResponderActive(in: $0) + } ?? false - private func focusedBrowserAddressBarPanelIdForShortcutEvent(_ event: NSEvent) -> UUID? { - guard let panelId = browserAddressBarFocusedPanelId else { return nil } + let paletteSelectionDelta = commandPaletteSelectionDeltaForKeyboardNavigation( + flags: event.modifierFlags, + chars: chars, + keyCode: event.keyCode + ) - guard let context = preferredMainWindowContextForShortcutRouting(event: event) else { -#if DEBUG - dlog( - "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + - "accepted=0 reason=no_context event=\(NSWindow.keyDescription(event))" + if shouldRouteCommandPaletteSelectionNavigation( + delta: paletteSelectionDelta, + isInteractive: commandPaletteInteractiveInTargetWindow, + usesInlineTextHandling: paletteUsesInlineTextHandling + ), + let delta = paletteSelectionDelta, + let paletteWindow = commandPaletteShortcutWindow { + NotificationCenter.default.post( + name: .commandPaletteMoveSelection, + object: paletteWindow, + userInfo: ["delta": delta] + ) + return true + } + + if commandPaletteInteractiveInTargetWindow, + let paletteWindow = commandPaletteShortcutWindow { + let paletteFieldEditorHasMarkedText = commandPaletteFieldEditorHasMarkedText(in: paletteWindow) + let paletteSnapshot = mainWindowId(for: paletteWindow).map(commandPaletteSnapshot(windowId:)) ?? .empty + let paletteUsesInlineReturnHandling = paletteUsesInlineTextHandling + if normalizedFlags.isEmpty, event.keyCode == 53 { + if paletteFieldEditorHasMarkedText { + return false + } + NotificationCenter.default.post(name: .commandPaletteDismissRequested, object: paletteWindow) + return true + } + + let shouldSubmitPalette = shouldSubmitCommandPaletteWithReturn( + keyCode: event.keyCode, + flags: event.modifierFlags, + mode: paletteSnapshot.mode ) +#if DEBUG + if event.keyCode == 36 || event.keyCode == 76 { + dlog( + "shortcut.palette.return target={\(debugWindowToken(paletteWindow))} " + + "mode=\(paletteSnapshot.mode) " + + "inline=\(paletteUsesInlineReturnHandling ? 1 : 0) " + + "submit=\(shouldSubmitPalette ? 1 : 0) " + + "marked=\(paletteFieldEditorHasMarkedText ? 1 : 0) " + + "\(debugShortcutRouteSnapshot(event: event))" + ) + } #endif - return nil + if paletteUsesInlineReturnHandling, + event.keyCode == 36 || event.keyCode == 76 { + return false + } + if shouldSubmitPalette { + if paletteFieldEditorHasMarkedText { + return false + } + NotificationCenter.default.post(name: .commandPaletteSubmitRequested, object: paletteWindow) + return true + } } - guard let workspace = context.tabManager.selectedWorkspace else { + // Guard against stale browserAddressBarFocusedPanelId after focus transitions + // (e.g., split that doesn't properly blur the address bar). If the first responder + // is a terminal surface, the address bar can't be focused. + if browserAddressBarFocusedPanelId != nil, + cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder) != nil { #if DEBUG + let stalePanelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" dlog( - "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + - "accepted=0 reason=no_workspace event=\(NSWindow.keyDescription(event))" + "browser.focus.addressBar.staleClear panel=\(stalePanelToken) " + + "reason=terminal_first_responder fr=\(firstResponderType)" ) #endif - return nil + browserAddressBarFocusedPanelId = nil + stopBrowserOmnibarSelectionRepeat() + } + + // Keep Cmd+P/Cmd+N inside the focused browser omnibar for Chrome-like + // suggestion navigation, and avoid opening command palette switcher. + // Scope the omnibar check to the shortcut's routed window context so a + // focused omnibar in another window does not suppress Cmd+P here. + let hasFocusedAddressBarInShortcutContext = focusedBrowserAddressBarPanelIdForShortcutEvent(event) != nil + + if commandPaletteEffectiveInTargetWindow { + if matchConfiguredShortcut(event: event, action: .commandPalette) { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.commandPalette") + return true + } + + if !hasFocusedAddressBarInShortcutContext, + matchConfiguredShortcut(event: event, action: .goToWorkspace) { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.goToWorkspace") + return true + } + + if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, + armConfiguredShortcutChordIfNeeded(event: event, actions: [.commandPalette]) { + return true + } + + if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, + !hasFocusedAddressBarInShortcutContext, + armConfiguredShortcutChordIfNeeded(event: event, actions: [.goToWorkspace]) { + return true + } + } + + if shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: commandPaletteEffectiveInTargetWindow, + normalizedFlags: normalizedFlags, + chars: chars, + keyCode: event.keyCode + ) { + return true + } + + // When the terminal has active IME composition (e.g. Korean, Japanese, Chinese + // input), don't intercept non-Cmd key events — let them flow through to the + // input method. Cmd-based shortcuts (Cmd+T, Cmd+Shift+L, etc.) should still + // work during composition since Cmd is never part of IME input sequences. + if !normalizedFlags.contains(.command), + let ghosttyView = cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder), + ghosttyView.hasMarkedText() { + return false + } + + // When the notifications popover is open, Escape should dismiss it immediately. + if flags.isEmpty, event.keyCode == 53, titlebarAccessoryController.dismissNotificationsPopoverIfShown() { + return true + } + + // When the notifications popover is showing an empty state, consume plain typing + // so key presses do not leak through into the focused terminal. + if flags.isDisjoint(with: [.command, .control, .option]), + titlebarAccessoryController.isNotificationsPopoverShown(), + (notificationStore?.notifications.isEmpty ?? false) { + return true } - guard workspace.browserPanel(for: panelId) != nil else { + let hasEventWindowContext = shortcutEventHasAddressableWindow(event) + let didSynchronizeShortcutContext = synchronizeShortcutRoutingContext(event: event) + if hasEventWindowContext && !didSynchronizeShortcutContext { #if DEBUG - 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))" - ) + dlog("handleCustomShortcut: unresolved event window context; bypassing app shortcut handling") #endif - return nil + return false } + // Keep keyboard routing deterministic after split close/reparent transitions: + // before processing shortcuts, converge first responder with the focused terminal panel. + if isControlD { #if DEBUG - dlog( - "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + - "accepted=1 workspace=\(workspace.id.uuidString.prefix(5)) event=\(NSWindow.keyDescription(event))" - ) + 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" + dlog("shortcut.ctrlD stage=preReconcile selected=\(selected) focused=\(focused) fr=\(frType)") #endif - return panelId - } - - @discardableResult - func requestBrowserAddressBarFocus(panelId: UUID) -> Bool { - focusBrowserAddressBar(panelId: panelId) - } + tabManager?.reconcileFocusedPanelFromFirstResponderForKeyboard() + #if DEBUG + let frAfterType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + 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. + return false + } - private func shouldBypassAppShortcutForFocusedBrowserAddressBar( - flags: NSEvent.ModifierFlags, - chars: String - ) -> Bool { - guard browserAddressBarFocusedPanelId != nil else { return false } - let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) - let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control] - guard isCommandOrControlOnly else { return false } - let shouldBypass = chars == "n" || chars == "p" -#if DEBUG - if shouldBypass { - let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - dlog( - "browser.focus.addressBar.shortcutBypass panel=\(panelToken) " + - "chars=\(chars) flags=\(normalizedFlags.rawValue)" - ) + // Chrome-like omnibar navigation while holding Cmd+N / Ctrl+N / Cmd+P / Ctrl+P. + if let delta = commandOmnibarSelectionDelta(flags: flags, chars: chars) { + dispatchBrowserOmnibarSelectionMove(delta: delta) + startBrowserOmnibarSelectionRepeatIfNeeded(keyCode: event.keyCode, delta: delta) + return true } -#endif - return shouldBypass - } - private func commandOmnibarSelectionDelta( - flags: NSEvent.ModifierFlags, - chars: String - ) -> Int? { - browserOmnibarSelectionDeltaForCommandNavigation( + if let delta = browserOmnibarSelectionDeltaForArrowNavigation( hasFocusedAddressBar: browserAddressBarFocusedPanelId != nil, - flags: flags, - chars: chars - ) - } - - private func dispatchBrowserOmnibarSelectionMove(delta: Int) { - guard delta != 0 else { return } - guard let panelId = browserAddressBarFocusedPanelId else { return } -#if DEBUG - dlog( - "browser.focus.omnibar.selectionMove panel=\(panelId.uuidString.prefix(5)) " + - "delta=\(delta) repeatKey=\(browserOmnibarRepeatKeyCode.map(String.init) ?? "nil")" - ) -#endif - NotificationCenter.default.post( - name: .browserMoveOmnibarSelection, - object: panelId, - userInfo: ["delta": delta] - ) - } + flags: event.modifierFlags, + keyCode: event.keyCode + ) { + dispatchBrowserOmnibarSelectionMove(delta: delta) + return true + } - private func startBrowserOmnibarSelectionRepeatIfNeeded(keyCode: UInt16, delta: Int) { - guard delta != 0 else { return } - guard browserAddressBarFocusedPanelId != nil else { -#if DEBUG - dlog( - "browser.focus.omnibar.repeat.start key=\(keyCode) delta=\(delta) " + - "result=skip_no_focused_address_bar" - ) -#endif - return + // Fast path for normal typing and terminal navigation keys (for example Up-arrow + // history): after command-palette/notification handling and browser omnibar + // arrow navigation above, plain key events have no app-level shortcut behavior. + if normalizedFlags.isEmpty && activeConfiguredShortcutChordPrefixForCurrentEvent == nil { + return false } - if browserOmnibarRepeatKeyCode == keyCode, browserOmnibarRepeatDelta == delta { -#if DEBUG - let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - dlog( - "browser.focus.omnibar.repeat.start panel=\(panelToken) " + - "key=\(keyCode) delta=\(delta) result=reuse" - ) -#endif - return + // Let omnibar-local Emacs navigation (Cmd/Ctrl+N/P) win while the browser + // address bar is focused. Without this, app-level Cmd+N can steal focus. + if shouldBypassAppShortcutForFocusedBrowserAddressBar(flags: flags, chars: chars) { + return false } - stopBrowserOmnibarSelectionRepeat() - browserOmnibarRepeatKeyCode = keyCode - browserOmnibarRepeatDelta = delta -#if DEBUG - let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - dlog( - "browser.focus.omnibar.repeat.start panel=\(panelToken) " + - "key=\(keyCode) delta=\(delta) result=armed" + return handleConfiguredAppShortcutActions( + event: event, + commandPaletteTargetWindow: commandPaletteTargetWindow, + hasFocusedAddressBarInShortcutContext: hasFocusedAddressBarInShortcutContext ) -#endif + } - let start = DispatchWorkItem { [weak self] in - self?.scheduleBrowserOmnibarSelectionRepeatTick() + // Extracted verbatim from the tail of handleCustomShortcut(event:) -- the flat table of + // ~55 app-level shortcut checks (lowest precedence phase; only reached once the palette + // and browser/terminal pre-checks above have declined the event). Evaluation order is + // preserved exactly: this is pure code motion, not a reordering. Refs #95. + private func handleConfiguredAppShortcutActions( + event: NSEvent, + commandPaletteTargetWindow: NSWindow?, + hasFocusedAddressBarInShortcutContext: Bool + ) -> Bool { + if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, + armConfiguredShortcutChordIfNeeded(event: event) { + return true } - browserOmnibarRepeatStartWorkItem = start - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: start) - } - private func scheduleBrowserOmnibarSelectionRepeatTick() { - browserOmnibarRepeatStartWorkItem = nil - guard browserAddressBarFocusedPanelId != nil else { -#if DEBUG - dlog("browser.focus.omnibar.repeat.tick result=stop_no_focused_address_bar") -#endif - stopBrowserOmnibarSelectionRepeat() - return + if matchConfiguredShortcut(event: event, action: .commandPalette) { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.commandPalette") + return true } - guard browserOmnibarRepeatKeyCode != nil else { return } -#if DEBUG - let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - dlog( - "browser.focus.omnibar.repeat.tick panel=\(panelToken) " + - "delta=\(browserOmnibarRepeatDelta)" - ) -#endif - dispatchBrowserOmnibarSelectionMove(delta: browserOmnibarRepeatDelta) + if !hasFocusedAddressBarInShortcutContext, + matchConfiguredShortcut(event: event, action: .goToWorkspace) { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.goToWorkspace") + return true + } - let tick = DispatchWorkItem { [weak self] in - self?.scheduleBrowserOmnibarSelectionRepeatTick() + if matchConfiguredShortcut(event: event, action: .quit) { + return handleQuitShortcutWarning() + } + if matchConfiguredShortcut(event: event, action: .openSettings) { + openPreferencesWindow(debugSource: "shortcut.openSettings") + return true + } + if matchConfiguredShortcut(event: event, action: .reloadConfiguration) { + GhosttyApp.shared.reloadConfiguration(source: "shortcut.reloadConfiguration") + return true } - browserOmnibarRepeatTickWorkItem = tick - DispatchQueue.main.asyncAfter(deadline: .now() + 0.055, execute: tick) - } - private func stopBrowserOmnibarSelectionRepeat() { -#if DEBUG - let previousKeyCode = browserOmnibarRepeatKeyCode - let previousDelta = browserOmnibarRepeatDelta -#endif - browserOmnibarRepeatStartWorkItem?.cancel() - browserOmnibarRepeatTickWorkItem?.cancel() - browserOmnibarRepeatStartWorkItem = nil - browserOmnibarRepeatTickWorkItem = nil - browserOmnibarRepeatKeyCode = nil - browserOmnibarRepeatDelta = 0 -#if DEBUG - if previousKeyCode != nil || previousDelta != 0 { - dlog( - "browser.focus.omnibar.repeat.stop key=\(previousKeyCode.map(String.init) ?? "nil") " + - "delta=\(previousDelta)" - ) + if matchConfiguredShortcut(event: event, action: .toggleFullScreen) { + guard let targetWindow = mainWindowForShortcutEvent(event) else { + return false + } + targetWindow.toggleFullScreen(nil) + return true } -#endif - } - private func handleBrowserOmnibarSelectionRepeatLifecycleEvent(_ event: NSEvent) { - guard browserOmnibarRepeatKeyCode != nil else { return } + // Primary UI shortcuts + if matchConfiguredShortcut(event: event, action: .toggleSidebar) { + _ = toggleSidebarInActiveMainWindow() + return true + } - switch event.type { - case .keyUp: - if event.keyCode == browserOmnibarRepeatKeyCode { + if matchConfiguredShortcut(event: event, action: .newTab) { #if DEBUG - dlog( - "browser.focus.omnibar.repeat.lifecycle event=keyUp key=\(event.keyCode) " + - "action=stop" - ) + dlog("shortcut.action name=newWorkspace \(debugShortcutRouteSnapshot(event: event))") #endif - stopBrowserOmnibarSelectionRepeat() - } - case .flagsChanged: - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if !flags.contains(.command) { -#if DEBUG - dlog( - "browser.focus.omnibar.repeat.lifecycle event=flagsChanged " + - "flags=\(flags.rawValue) action=stop" + // 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 - stopBrowserOmnibarSelectionRepeat() + #endif + openNewMainWindow(nil) } - default: - break + return true } - } - private func isLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool { - programaIsLikelyWebInspectorResponder(responder) - } + // New Window: Cmd+Shift+N + // Handled here instead of relying on SwiftUI's CommandGroup menu item because + // after a browser panel has been shown, SwiftUI's menu dispatch can silently + // consume the key equivalent without firing the action closure. + if matchConfiguredShortcut(event: event, action: .newWindow) { + openNewMainWindow(nil) + return true + } -#if DEBUG - private func developerToolsShortcutProbeKind(event: NSEvent) -> String? { - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) { - return "toggle.configured" + // Open Folder: Cmd+O + // Handled here to prevent AppKit's default NSDocumentController from opening + // the Documents folder when SwiftUI menu dispatch fails due to focus bugs. + if matchConfiguredShortcut(event: event, action: .openFolder) { + showOpenFolderPanel() + return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showBrowserJavaScriptConsole)) { - return "console.configured" + + // Check Show Notifications shortcut + if matchConfiguredShortcut(event: event, action: .showNotifications) { + toggleNotificationsPopover(animated: false, anchorView: fullscreenControlsViewModel?.notificationsAnchorView) + return true } - let chars = (event.charactersIgnoringModifiers ?? "").lowercased() - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if flags == [.command, .option] { - if chars == "i" || event.keyCode == 34 { - return "toggle.literal" - } - if chars == "c" || event.keyCode == 8 { - return "console.literal" + if matchConfiguredShortcut(event: event, action: .sendFeedback) { + guard let targetContext = preferredMainWindowContextForShortcuts(event: event), + let targetWindow = targetContext.window ?? windowForMainWindowId(targetContext.windowId) else { + return false } + setActiveMainWindow(targetWindow) + bringToFront(targetWindow) + NotificationCenter.default.post(name: .feedbackComposerRequested, object: targetWindow) + return true } - return nil - } - private func logDeveloperToolsShortcutSnapshot( - phase: String, - event: NSEvent? = nil, - didHandle: Bool? = nil - ) { - let keyWindow = NSApp.keyWindow - let firstResponder = keyWindow?.firstResponder - let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" - let eventDescription = event.map(NSWindow.keyDescription) ?? "none" - if let browser = tabManager?.focusedBrowserPanel { - var line = - "browser.devtools shortcut=\(phase) panel=\(browser.id.uuidString.prefix(5)) " + - "\(browser.debugDeveloperToolsStateSummary()) \(browser.debugDeveloperToolsGeometrySummary()) " + - "keyWin=\(keyWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)" - if let didHandle { - line += " handled=\(didHandle ? 1 : 0)" + // Check Jump to Unread shortcut + if matchConfiguredShortcut(event: event, action: .jumpToUnread) { +#if DEBUG + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData(["jumpUnreadShortcutHandled": "1"]) } - dlog(line) - return +#endif + jumpToLatestUnread() + return true } - var line = - "browser.devtools shortcut=\(phase) panel=nil keyWin=\(keyWindow?.windowNumber ?? -1) " + - "fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)" - if let didHandle { - line += " handled=\(didHandle ? 1 : 0)" + + // Flash the currently focused panel so the user can visually confirm focus. + if matchConfiguredShortcut(event: event, action: .triggerFlash) { + tabManager?.triggerFocusFlash() + return true } - dlog(line) - } -#endif - private func prepareFocusedBrowserDevToolsForSplit(directionLabel: String) { - guard let browser = tabManager?.focusedBrowserPanel else { return } - guard browser.shouldPreserveWebViewAttachmentDuringTransientHide() else { return } - guard let keyWindow = NSApp.keyWindow else { return } - guard isLikelyWebInspectorResponder(keyWindow.firstResponder) else { return } + // Surface navigation: Cmd+Shift+] / Cmd+Shift+[ + if matchConfiguredShortcut(event: event, action: .nextSurface) { + tabManager?.selectNextSurface() + return true + } + if matchConfiguredShortcut(event: event, action: .prevSurface) { + tabManager?.selectPreviousSurface() + return true + } - let beforeResponder = keyWindow.firstResponder - let movedToWebView = keyWindow.makeFirstResponder(browser.webView) - let movedToNil = movedToWebView ? false : keyWindow.makeFirstResponder(nil) + if matchConfiguredShortcut(event: event, action: .toggleTerminalCopyMode) { + let handled = tabManager?.toggleFocusedTerminalCopyMode() ?? false +#if DEBUG + dlog( + "shortcut.action name=toggleTerminalCopyMode handled=\(handled ? 1 : 0) " + + "\(debugShortcutRouteSnapshot(event: event))" + ) +#endif + // Only consume when a focused terminal actually handled the toggle. + // Otherwise allow the event to continue through the responder chain. + return handled + } - #if DEBUG - let beforeType = beforeResponder.map { String(describing: type(of: $0)) } ?? "nil" - let beforePtr = beforeResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" - 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" - 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())" - ) - #endif - } + // Workspace navigation: Cmd+Ctrl+] / Cmd+Ctrl+[ + if matchConfiguredShortcut(event: event, action: .nextSidebarTab) { +#if DEBUG + let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "ws.shortcut dir=next repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)" + ) +#endif + tabManager?.selectNextTab() + return true + } - @discardableResult - func performSplitShortcut(direction: SplitDirection, preferredWindow: NSWindow? = nil) -> Bool { - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow - let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) - _ = synchronizeActiveMainWindowContext(preferredWindow: targetWindow) + if matchConfiguredShortcut(event: event, action: .prevSidebarTab) { +#if DEBUG + let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "ws.shortcut dir=prev repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)" + ) +#endif + tabManager?.selectPreviousTab() + return true + } - let directionLabel: String - switch direction { - case .left: directionLabel = "left" - case .right: directionLabel = "right" - case .up: directionLabel = "up" - case .down: directionLabel = "down" + if matchConfiguredShortcut(event: event, action: .renameWorkspace) { + return requestRenameWorkspaceViaCommandPalette( + preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) } - #if DEBUG - let keyWindow = NSApp.keyWindow - let firstResponder = keyWindow?.firstResponder - let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" - let firstResponderWindow: Int = { - if let v = firstResponder as? NSView { - return v.window?.windowNumber ?? -1 - } - if let w = firstResponder as? NSWindow { - return w.windowNumber - } - return -1 - }() - let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)" - 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" - dlog("split.shortcut dir=\(directionLabel) pre panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)") - } else { - dlog("split.shortcut dir=\(directionLabel) pre panel=nil \(splitContext)") + if matchConfiguredShortcut(event: event, action: .editWorkspaceDescription) { +#if DEBUG + dlog( + "shortcut.editWorkspaceDescription matched target={\(debugWindowToken(commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow))} " + + "\(debugShortcutRouteSnapshot(event: event))" + ) +#endif + return requestEditWorkspaceDescriptionViaCommandPalette( + preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) } - #endif - prepareFocusedBrowserDevToolsForSplit(directionLabel: directionLabel) - let didCreateSplit: Bool = { - if let terminalContext { - return terminalContext.tabManager.createSplit( - tabId: terminalContext.workspaceId, - surfaceId: terminalContext.panelId, - direction: direction - ) != nil + if matchConfiguredShortcut(event: event, action: .closeOtherTabsInPane) { + if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, + targetWindow.identifier?.rawValue == "cmux.settings" { + targetWindow.performClose(nil) + } else { + let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + if let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) { + terminalContext.tabManager.closeOtherTabsInFocusedPaneWithConfirmation() + } else { + tabManager?.closeOtherTabsInFocusedPaneWithConfirmation() + } } - return tabManager?.createSplit(direction: direction) != nil - }() + return true + } + + // Cmd+W must close the focused panel even if first-responder momentarily lags on a + // browser NSTextView during split focus transitions. + if matchConfiguredShortcut(event: event, action: .closeTab) { + let targetWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow + let routedManager = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager + // Browser popup windows primarily intercept Cmd+W in BrowserPopupPanel. + // This AppDelegate path is a fallback for cases where AppKit routes the + // event through the global shortcut handler first. + if let targetWindow = [targetWindow, NSApp.keyWindow] + .compactMap({ $0 }) + .first(where: { $0.identifier?.rawValue == "programa.browser-popup" }) { +#if DEBUG + dlog("shortcut.cmdW route=browserPopup") +#endif + targetWindow.performClose(nil) + return true + } else if let targetWindow, + programaWindowShouldOwnCloseShortcut(targetWindow) { + targetWindow.performClose(nil) + } else { + if let routedManager { +#if DEBUG + let selectedWorkspace = routedManager.selectedWorkspace + 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")" + ) +#endif + routedManager.closeCurrentPanelWithConfirmation() + } else { #if DEBUG - DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in - let keyWindow = NSApp.keyWindow - let firstResponder = keyWindow?.firstResponder - let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" - let firstResponderWindow: Int = { - if let v = firstResponder as? NSView { - return v.window?.windowNumber ?? -1 - } - if let w = firstResponder as? NSWindow { - return w.windowNumber + dlog("shortcut.cmdW route=noManager") +#endif + return false } - return -1 - }() - let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)" - 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" - dlog("split.shortcut dir=\(directionLabel) post panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)") - } else { - dlog("split.shortcut dir=\(directionLabel) post panel=nil \(splitContext)") } + return true } - recordGotoSplitSplitIfNeeded(direction: direction) -#endif - return didCreateSplit - } - - @discardableResult - func performBrowserSplitShortcut(direction: SplitDirection) -> Bool { - _ = synchronizeActiveMainWindowContext(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow) - #if DEBUG - let directionLabel: String - switch direction { - case .left: directionLabel = "left" - case .right: directionLabel = "right" - case .up: directionLabel = "up" - case .down: directionLabel = "down" + if matchConfiguredShortcut(event: event, action: .closeWorkspace) { + tabManager?.closeCurrentWorkspaceWithConfirmation() + return true } - let selectedTabBefore = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil" - let focusedPanelBefore = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil" - dlog( - "split.browser.shortcut pre dir=\(directionLabel) " + - "tab=\(selectedTabBefore) focusedPanel=\(focusedPanelBefore)" - ) - #endif - guard let panelId = tabManager?.createBrowserSplit(direction: direction) else { - #if DEBUG - dlog("split.browser.shortcut failed dir=\(directionLabel)") - #endif - return false + if matchConfiguredShortcut(event: event, action: .closeWindow) { + guard let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow else { + NSSound.beep() + return true + } + closeWindowWithConfirmation(targetWindow) + return true } - #if DEBUG - let selectedTabAfter = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil" - let focusedPanelAfter = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil" - dlog( - "split.browser.shortcut post dir=\(directionLabel) " + - "created=\(panelId.uuidString.prefix(5)) tab=\(selectedTabAfter) focusedPanel=\(focusedPanelAfter)" - ) - #endif - - _ = focusBrowserAddressBar(panelId: panelId) - return true - } - - /// Allow AppKit-backed browser surfaces (WKWebView) to route non-menu shortcuts - /// through the same app-level shortcut handler used by the local key monitor. - @discardableResult - func handleBrowserSurfaceKeyEquivalent(_ event: NSEvent) -> Bool { - handleCustomShortcut(event: event) - } - - @discardableResult - func requestRenameWorkspaceViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteRenameWorkspace( - preferredWindow: targetWindow, - source: "shortcut.renameWorkspace" - ) - return true - } + if matchConfiguredShortcut(event: event, action: .renameTab) { + // Keep Cmd+R browser reload behavior when a browser panel is focused. + if tabManager?.focusedBrowserPanel != nil { + return false + } + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteRenameTab(preferredWindow: targetWindow, source: "shortcut.renameTab") + return true + } - @discardableResult - func requestEditWorkspaceDescriptionViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + // Numeric shortcuts for specific workspaces (9 = last workspace) + // Always consume the event when the digit matches to prevent Ghostty's + // goto_tab fallback from creating a new window when the index is out of bounds. + if let digit = numberedConfiguredShortcutDigit(event: event, action: .selectWorkspaceByNumber) { + if let manager = tabManager, + let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forDigit: digit, workspaceCount: manager.tabs.count) { #if DEBUG - dlog( - "shortcut.editWorkspaceDescription request target={\(debugWindowToken(targetWindow))} " + - "fr=\(targetWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" - ) + dlog( + "shortcut.action name=workspaceDigit digit=\(digit) targetIndex=\(targetIndex) manager=\(debugManagerToken(manager)) \(debugShortcutRouteSnapshot(event: event))" + ) #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 { - return handleCustomShortcut(event: event) + manager.selectTab(at: targetIndex) + } + return true } - handleBrowserOmnibarSelectionRepeatLifecycleEvent(event) - return clearEscapeSuppressionForKeyUp(event: event, consumeIfSuppressed: true) - } - - func debugMarkCommandPaletteOpenPending(window: NSWindow) { - markCommandPaletteOpenRequested(for: window) - } - @discardableResult - func debugSetCommandPalettePendingOpenAge(window: NSWindow, age: TimeInterval) -> Bool { - guard let windowId = mainWindowId(for: window) else { return false } - commandPalettePendingOpenByWindowId[windowId] = true - commandPaletteRecentRequestAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime - max(age, 0) - return true - } - - // Test hook: remap a window context under a detached window key so direct - // ObjectIdentifier(window) lookups fail and fallback logic is exercised. - @discardableResult - func debugInjectWindowContextKeyMismatch(windowId: UUID) -> Bool { - guard let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }), - let window = context.window ?? windowForMainWindowId(windowId) else { - return false + // Numeric shortcuts for surfaces within the focused pane (9 = last) + if let digit = numberedConfiguredShortcutDigit(event: event, action: .selectSurfaceByNumber) { + if digit == 9 { + tabManager?.selectLastSurface() + } else { + tabManager?.selectSurface(at: digit - 1) + } + return true } - let detachedWindow = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 16, height: 16), - styleMask: [.borderless], - backing: .buffered, - defer: false - ) - debugDetachedContextWindows.append(detachedWindow) - - let contextKeys = mainWindowContexts.compactMap { key, value in - value === context ? key : nil + // 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 } - for key in contextKeys { - mainWindowContexts.removeValue(forKey: key) + 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 } - mainWindowContexts[ObjectIdentifier(detachedWindow)] = context - context.window = window - 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 - - private func findButton(in view: NSView, titled title: String) -> NSButton? { - if let button = view as? NSButton, button.title == title { - return button + return true } - for subview in view.subviews { - if let found = findButton(in: subview, titled: title) { - return found - } + if matchConfiguredDirectionalShortcut( + 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 + return true } - return nil - } - private func findStaticText(in view: NSView, equals text: String) -> Bool { - if let field = view as? NSTextField, field.stringValue == text { + if matchConfiguredShortcut(event: event, action: .toggleSplitZoom) { + _ = tabManager?.toggleFocusedSplitZoom() +#if DEBUG + recordGotoSplitZoomIfNeeded() +#endif + return true + } + + // Split actions: Cmd+D / Cmd+Shift+D + if matchConfiguredShortcut(event: event, action: .splitRight) { +#if DEBUG + dlog("shortcut.action name=splitRight \(debugShortcutRouteSnapshot(event: event))") +#endif + if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .right) { + return true + } + _ = performSplitShortcut( + direction: .right, + preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) return true } - for subview in view.subviews { - if findStaticText(in: subview, equals: text) { + + if matchConfiguredShortcut(event: event, action: .splitDown) { +#if DEBUG + dlog("shortcut.action name=splitDown \(debugShortcutRouteSnapshot(event: event))") +#endif + if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .down) { return true } + _ = performSplitShortcut( + direction: .down, + preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) + return true } - return false - } - 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 { - return false - } - return matchShortcutStroke(event: event, stroke: secondStroke) + if matchConfiguredShortcut(event: event, action: .splitBrowserRight) { +#if DEBUG + dlog("shortcut.action name=splitBrowserRight \(debugShortcutRouteSnapshot(event: event))") +#endif + _ = performBrowserSplitShortcut(direction: .right) + return true } - guard !shortcut.hasChord else { return false } - return matchShortcutStroke(event: event, stroke: shortcut.firstStroke) - } - private func numberedConfiguredShortcutDigit( - event: NSEvent, - action: KeyboardShortcutSettings.Action - ) -> Int? { - let shortcut = KeyboardShortcutSettings.shortcut(for: action) - if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { - guard let secondStroke = shortcut.secondStroke, - shortcut.firstStroke == prefix else { - return nil - } - return numberedShortcutDigit(event: event, stroke: secondStroke) + if matchConfiguredShortcut(event: event, action: .splitBrowserDown) { +#if DEBUG + dlog("shortcut.action name=splitBrowserDown \(debugShortcutRouteSnapshot(event: event))") +#endif + _ = performBrowserSplitShortcut(direction: .down) + return true } - guard !shortcut.hasChord else { return nil } - return numberedShortcutDigit(event: event, stroke: shortcut.firstStroke) - } - private func matchConfiguredDirectionalShortcut( - event: NSEvent, - action: KeyboardShortcutSettings.Action, - arrowGlyph: String, - arrowKeyCode: UInt16 - ) -> Bool { - let shortcut = KeyboardShortcutSettings.shortcut(for: action) - if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { - guard let secondStroke = shortcut.secondStroke, - shortcut.firstStroke == prefix else { - return false - } - return matchDirectionalShortcut( - event: event, - stroke: secondStroke, - arrowGlyph: arrowGlyph, - arrowKeyCode: arrowKeyCode - ) + // Surface navigation (legacy Ctrl+Tab support) + if matchTabShortcut(event: event, shortcut: StoredShortcut(key: "\t", command: false, shift: false, option: false, control: true)) { + tabManager?.selectNextSurface() + return true + } + if matchTabShortcut(event: event, shortcut: StoredShortcut(key: "\t", command: false, shift: true, option: false, control: true)) { + tabManager?.selectPreviousSurface() + return true } - guard !shortcut.hasChord else { return false } - return matchDirectionalShortcut( - event: event, - stroke: shortcut.firstStroke, - arrowGlyph: arrowGlyph, - arrowKeyCode: arrowKeyCode - ) - } - private func configuredShortcutChordWindowNumber(for event: NSEvent) -> Int? { - if let window = mainWindowForShortcutEvent(event) { - return window.windowNumber + // New surface: Cmd+T + if matchConfiguredShortcut(event: event, action: .newSurface) { + tabManager?.newSurface() + return true } - if let window = event.window { - return window.windowNumber + + // Open browser: Cmd+Shift+L + if matchConfiguredShortcut(event: event, action: .openBrowser) { + _ = openBrowserAndFocusAddressBar(insertAtEnd: true) + return true } - return event.windowNumber > 0 ? event.windowNumber : nil - } - private func armConfiguredShortcutChordIfNeeded( - event: NSEvent, - actions: [KeyboardShortcutSettings.Action]? = nil - ) -> Bool { - 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( - firstStroke: shortcut.firstStroke, - windowNumber: configuredShortcutChordWindowNumber(for: event) - ) + if matchConfiguredShortcut(event: event, action: .focusBrowserAddressBar) { + if let focusedPanel = tabManager?.focusedBrowserPanel { + focusBrowserAddressBar(in: focusedPanel) return true } - } - return false - } - /// Match a shortcut stroke against an event, handling normal keys. - private func matchShortcutStroke(event: NSEvent, stroke: ShortcutStroke) -> Bool { - // Some keys can include extra flags (e.g. .function) depending on the responder chain. - // Strip those for consistent matching across first responders (terminal, WebKit, etc). - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) - guard flags == stroke.modifierFlags else { return false } + if let browserAddressBarFocusedPanelId, + focusBrowserAddressBar(panelId: browserAddressBarFocusedPanelId) { + return true + } - let shortcutKey = stroke.key.lowercased() - if shortcutKey == "\r" { - return event.keyCode == 36 || event.keyCode == 76 + if openBrowserAndFocusAddressBar(insertAtEnd: true) != nil { + return true + } } - let eventCharsIgnoringModifiers = event.charactersIgnoringModifiers - if shortcutCharacterMatches( - eventCharacter: eventCharsIgnoringModifiers, - shortcutKey: shortcutKey, - applyShiftSymbolNormalization: flags.contains(.shift), - eventKeyCode: event.keyCode - ) { + if matchConfiguredShortcut(event: event, action: .browserBack) { + guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { + return false + } + focusedBrowserPanel.goBack() return true } - // For command-based shortcuts, trust AppKit's layout-aware characters when present. - // Keep this strict for letter shortcuts to avoid physical-key collisions across layouts, - // while still allowing keyCode fallback for digit/punctuation shortcuts on non-US layouts. - // When a non-Latin input source is active (Russian, Korean, Chinese, Japanese, etc.), - // charactersIgnoringModifiers returns non-ASCII characters that can never match - // a Latin shortcut key — skip this guard and fall through to layout-based matching. - let hasEventChars = !(eventCharsIgnoringModifiers?.isEmpty ?? true) - let eventCharsAreASCII = eventCharsIgnoringModifiers?.allSatisfy(\.isASCII) ?? true - let shortcutKeyIsDigit = shortcutKey.count == 1 && shortcutKey.first?.isNumber == true - if shortcutKeyIsDigit, - hasEventChars, - eventCharsAreASCII, - digitForNumberKeyCode(event.keyCode) == nil { - return false - } - if hasEventChars, - eventCharsAreASCII, - flags.contains(.command), - !flags.contains(.control), - shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) { - return false + if matchConfiguredShortcut(event: event, action: .browserForward) { + guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { + return false + } + focusedBrowserPanel.goForward() + return true } - // Match using the current keyboard layout so Command shortcuts stay character-based - // across layouts (QWERTY, Dvorak, etc.) instead of being tied to ANSI physical keys. - let layoutCharacter = shortcutLayoutCharacterProvider(event.keyCode, event.modifierFlags) - if shortcutCharacterMatches( - eventCharacter: layoutCharacter, - shortcutKey: shortcutKey, - applyShiftSymbolNormalization: false, - eventKeyCode: event.keyCode - ) { + if matchConfiguredShortcut(event: event, action: .browserReload) { + guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { + return false + } + focusedBrowserPanel.reload() return true } - // Control-key combos can surface as ASCII control characters (e.g. Ctrl+H => backspace), - // so keep ANSI keyCode fallback for control-modified shortcuts. Also allow fallback for - // command punctuation shortcuts, since some non-US layouts report different characters - // for the same physical key even when menu-equivalent semantics should still apply. - // When a non-Latin input source is active (Russian, Korean, Chinese, Japanese, etc.), - // event chars carry no usable Latin key identity. Always allow keyCode fallback as a - // safety net — even when the layout-based translation resolved a character, the - // physical key code is the definitive identifier for the intended shortcut. - // For empty-character events (synthetic/browser key equivalents), preserve the original - // behavior: only fall back when the layout translation also failed. - let hasUsableEventChars = hasEventChars && eventCharsAreASCII - let allowANSIKeyCodeFallback = flags.contains(.control) - || (flags.contains(.command) - && !flags.contains(.control) - && ( - !shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) - || (hasEventChars && !eventCharsAreASCII) - || (!hasEventChars && (layoutCharacter?.isEmpty ?? true)) - )) - if allowANSIKeyCodeFallback, let expectedKeyCode = keyCodeForShortcutKey(shortcutKey) { - return event.keyCode == expectedKeyCode + // Safari defaults: + // - Option+Command+I => Show/Toggle Web Inspector + // - Option+Command+C => Show JavaScript Console + if matchConfiguredShortcut(event: event, action: .toggleBrowserDeveloperTools) { +#if DEBUG + logDeveloperToolsShortcutSnapshot(phase: "toggle.pre", event: event) +#endif + let didHandle = tabManager?.toggleDeveloperToolsFocusedBrowser() ?? false +#if DEBUG + logDeveloperToolsShortcutSnapshot(phase: "toggle.post", event: event, didHandle: didHandle) + DispatchQueue.main.async { [weak self] in + self?.logDeveloperToolsShortcutSnapshot(phase: "toggle.tick", didHandle: didHandle) + } +#endif + if !didHandle { NSSound.beep() } + return true } - return false - } - - private func matchShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { - guard !shortcut.hasChord else { return false } - return matchShortcutStroke(event: event, stroke: shortcut.firstStroke) - } - private func numberedShortcutDigit(event: NSEvent, stroke: ShortcutStroke) -> Int? { - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) - guard flags == stroke.modifierFlags else { return nil } - let numberKeyDigit = digitForNumberKeyCode(event.keyCode) + if matchConfiguredShortcut(event: event, action: .showBrowserJavaScriptConsole) { +#if DEBUG + logDeveloperToolsShortcutSnapshot(phase: "console.pre", event: event) +#endif + let didHandle = tabManager?.showJavaScriptConsoleFocusedBrowser() ?? false +#if DEBUG + logDeveloperToolsShortcutSnapshot(phase: "console.post", event: event, didHandle: didHandle) + DispatchQueue.main.async { [weak self] in + self?.logDeveloperToolsShortcutSnapshot(phase: "console.tick", didHandle: didHandle) + } +#endif + if !didHandle { NSSound.beep() } + return true + } - if let digit = numberedShortcutDigit( - eventCharacter: event.charactersIgnoringModifiers, - applyShiftSymbolNormalization: flags.contains(.shift), - eventKeyCode: event.keyCode - ) { - return digit + if matchConfiguredShortcut(event: event, action: .toggleReactGrab) { + let didHandle = tabManager?.toggleReactGrabFromCurrentFocus() ?? false + if !didHandle { NSSound.beep() } + return true } - let eventCharsIgnoringModifiers = event.charactersIgnoringModifiers - let hasUsableASCIIEventChars = !(eventCharsIgnoringModifiers?.isEmpty ?? true) - && (eventCharsIgnoringModifiers?.allSatisfy(\.isASCII) ?? true) - if !hasUsableASCIIEventChars || numberKeyDigit != nil { - let layoutCharacter = shortcutLayoutCharacterProvider(event.keyCode, event.modifierFlags) - if let digit = numberedShortcutDigit( - eventCharacter: layoutCharacter, - applyShiftSymbolNormalization: false, - eventKeyCode: event.keyCode - ) { - return digit - } + if matchConfiguredShortcut(event: event, action: .browserZoomIn) { + return tabManager?.zoomInFocusedBrowser() ?? false } - return numberKeyDigit - } + if matchConfiguredShortcut(event: event, action: .browserZoomOut) { + return tabManager?.zoomOutFocusedBrowser() ?? false + } - private func numberedShortcutDigit(event: NSEvent, shortcut: StoredShortcut) -> Int? { - guard !shortcut.hasChord else { return nil } - return numberedShortcutDigit(event: event, stroke: shortcut.firstStroke) - } + if matchConfiguredShortcut(event: event, action: .browserZoomReset) { + return tabManager?.resetZoomFocusedBrowser() ?? false + } - private func numberedShortcutDigit( - eventCharacter: String?, - applyShiftSymbolNormalization: Bool, - eventKeyCode: UInt16 - ) -> Int? { - guard let eventCharacter, !eventCharacter.isEmpty else { return nil } - let normalized = normalizedShortcutEventCharacter( - eventCharacter, - applyShiftSymbolNormalization: applyShiftSymbolNormalization, - eventKeyCode: eventKeyCode - ) - guard let digit = Int(normalized), (1...9).contains(digit) else { return nil } - return digit - } + if matchConfiguredShortcut(event: event, action: .find) { + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false + } + tabManager?.startSearch() + return true + } - private func shouldRequireCharacterMatchForCommandShortcut(shortcutKey: String) -> Bool { - guard shortcutKey.count == 1, let scalar = shortcutKey.unicodeScalars.first else { - return false + if matchConfiguredShortcut(event: event, action: .findNext) { + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false + } + tabManager?.findNext() + return true } - return CharacterSet.letters.contains(scalar) - } - private func shortcutCharacterMatches( - eventCharacter: String?, - shortcutKey: String, - applyShiftSymbolNormalization: Bool, - eventKeyCode: UInt16 - ) -> Bool { - guard let eventCharacter, !eventCharacter.isEmpty else { return false } - if normalizedShortcutEventCharacter( - eventCharacter, - applyShiftSymbolNormalization: applyShiftSymbolNormalization, - eventKeyCode: eventKeyCode - ) == shortcutKey { + if matchConfiguredShortcut(event: event, action: .findPrevious) { + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false + } + tabManager?.findPrevious() return true } - return false - } - private func normalizedShortcutEventCharacter( - _ eventCharacter: String, - applyShiftSymbolNormalization: Bool, - eventKeyCode: UInt16 - ) -> String { - let lowered = eventCharacter.lowercased() - guard applyShiftSymbolNormalization else { return lowered } + if matchConfiguredShortcut(event: event, action: .hideFind) { + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false + } + tabManager?.hideFind() + return true + } - switch lowered { - case "{": return "[" - case "}": return "]" - case "<": return eventKeyCode == 43 ? "," : lowered // kVK_ANSI_Comma - case ">": return eventKeyCode == 47 ? "." : lowered // kVK_ANSI_Period - case "?": return "/" - case ":": return ";" - case "\"": return "'" - case "|": return "\\" - case "~": return "`" - case "+": return "=" - case "_": return "-" - case "!": return eventKeyCode == 18 ? "1" : lowered // kVK_ANSI_1 - case "@": return eventKeyCode == 19 ? "2" : lowered // kVK_ANSI_2 - case "#": return eventKeyCode == 20 ? "3" : lowered // kVK_ANSI_3 - case "$": return eventKeyCode == 21 ? "4" : lowered // kVK_ANSI_4 - case "%": return eventKeyCode == 23 ? "5" : lowered // kVK_ANSI_5 - case "^": return eventKeyCode == 22 ? "6" : lowered // kVK_ANSI_6 - case "&": return eventKeyCode == 26 ? "7" : lowered // kVK_ANSI_7 - case "*": return eventKeyCode == 28 ? "8" : lowered // kVK_ANSI_8 - case "(": return eventKeyCode == 25 ? "9" : lowered // kVK_ANSI_9 - case ")": return eventKeyCode == 29 ? "0" : lowered // kVK_ANSI_0 - default: return lowered + if matchConfiguredShortcut(event: event, action: .useSelectionForFind) { + tabManager?.searchSelection() + return true } - } - private func keyCodeForShortcutKey(_ key: String) -> UInt16? { - // Matches macOS ANSI key codes. This is intentionally limited to keys we - // support in StoredShortcut/ghostty trigger translation. - switch key { - case "a": return 0 // kVK_ANSI_A - case "s": return 1 // kVK_ANSI_S - case "d": return 2 // kVK_ANSI_D - case "f": return 3 // kVK_ANSI_F - case "h": return 4 // kVK_ANSI_H - case "g": return 5 // kVK_ANSI_G - case "z": return 6 // kVK_ANSI_Z - case "x": return 7 // kVK_ANSI_X - case "c": return 8 // kVK_ANSI_C - case "v": return 9 // kVK_ANSI_V - case "b": return 11 // kVK_ANSI_B - case "q": return 12 // kVK_ANSI_Q - case "w": return 13 // kVK_ANSI_W - case "e": return 14 // kVK_ANSI_E - case "r": return 15 // kVK_ANSI_R - case "y": return 16 // kVK_ANSI_Y - case "t": return 17 // kVK_ANSI_T - case "1": return 18 // kVK_ANSI_1 - case "2": return 19 // kVK_ANSI_2 - case "3": return 20 // kVK_ANSI_3 - case "4": return 21 // kVK_ANSI_4 - case "6": return 22 // kVK_ANSI_6 - case "5": return 23 // kVK_ANSI_5 - case "=": return 24 // kVK_ANSI_Equal - case "9": return 25 // kVK_ANSI_9 - case "7": return 26 // kVK_ANSI_7 - case "-": return 27 // kVK_ANSI_Minus - case "8": return 28 // kVK_ANSI_8 - case "0": return 29 // kVK_ANSI_0 - case "]": return 30 // kVK_ANSI_RightBracket - case "o": return 31 // kVK_ANSI_O - case "u": return 32 // kVK_ANSI_U - case "[": return 33 // kVK_ANSI_LeftBracket - case "i": return 34 // kVK_ANSI_I - case "p": return 35 // kVK_ANSI_P - case "l": return 37 // kVK_ANSI_L - case "j": return 38 // kVK_ANSI_J - case "'": return 39 // kVK_ANSI_Quote - case "k": return 40 // kVK_ANSI_K - case ";": return 41 // kVK_ANSI_Semicolon - case "\\": return 42 // kVK_ANSI_Backslash - case ",": return 43 // kVK_ANSI_Comma - case "/": return 44 // kVK_ANSI_Slash - case "n": return 45 // kVK_ANSI_N - case "m": return 46 // kVK_ANSI_M - case ".": return 47 // kVK_ANSI_Period - case "`": return 50 // kVK_ANSI_Grave - case "\r": return 36 // kVK_Return - case "←": return 123 // kVK_LeftArrow - case "→": return 124 // kVK_RightArrow - case "↓": return 125 // kVK_DownArrow - case "↑": return 126 // kVK_UpArrow - default: - return nil + if matchConfiguredShortcut(event: event, action: .reopenClosedBrowserPanel) { + _ = tabManager?.reopenMostRecentlyClosedBrowserPanel() + return true } + + return false } - private func digitForNumberKeyCode(_ keyCode: UInt16) -> Int? { - switch keyCode { - case 18: return 1 // kVK_ANSI_1 - case 19: return 2 // kVK_ANSI_2 - case 20: return 3 // kVK_ANSI_3 - case 21: return 4 // kVK_ANSI_4 - case 23: return 5 // kVK_ANSI_5 - case 22: return 6 // kVK_ANSI_6 - case 26: return 7 // kVK_ANSI_7 - case 28: return 8 // kVK_ANSI_8 - case 25: return 9 // kVK_ANSI_9 - default: - return nil + private func shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: SplitDirection) -> Bool { + guard let tabManager, + let workspace = tabManager.selectedWorkspace, + let focusedPanelId = workspace.focusedPanelId, + let terminalPanel = workspace.terminalPanel(for: focusedPanelId) else { + return false } - } - /// Match arrow key shortcuts using keyCode - /// Arrow keys include .numericPad and .function in their modifierFlags, so strip those before comparing. - private func matchArrowShortcut(event: NSEvent, stroke: ShortcutStroke, keyCode: UInt16) -> Bool { - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) - return event.keyCode == keyCode && flags == stroke.modifierFlags - } + let hostedView = terminalPanel.hostedView + let hostedSize = hostedView.bounds.size + let hostedHiddenInHierarchy = hostedView.isHiddenOrHasHiddenAncestor + let hostedAttachedToWindow = hostedView.window != nil + let firstResponderIsWindow = NSApp.keyWindow?.firstResponder is NSWindow - /// Match tab key shortcuts using keyCode 48 - private func matchTabShortcut(event: NSEvent, stroke: ShortcutStroke) -> Bool { - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - return event.keyCode == 48 && flags == stroke.modifierFlags - } + let shouldSuppress = shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: firstResponderIsWindow, + hostedSize: hostedSize, + hostedHiddenInHierarchy: hostedHiddenInHierarchy, + hostedAttachedToWindow: hostedAttachedToWindow + ) + guard shouldSuppress else { return false } - private func matchTabShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { - guard !shortcut.hasChord else { return false } - return matchTabShortcut(event: event, stroke: shortcut.firstStroke) + tabManager.reconcileFocusedPanelFromFirstResponderForKeyboard() + +#if DEBUG + let directionLabel: String + switch direction { + case .left: directionLabel = "left" + case .right: directionLabel = "right" + case .up: directionLabel = "up" + case .down: directionLabel = "down" + } + let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "split.shortcut suppressed dir=\(directionLabel) reason=transient_focus_state " + + "fr=\(firstResponderType) hidden=\(hostedHiddenInHierarchy ? 1 : 0) " + + "attached=\(hostedAttachedToWindow ? 1 : 0) " + + "frame=\(String(format: "%.1fx%.1f", hostedSize.width, hostedSize.height))" + ) +#endif + return true } - /// Directional shortcuts default to arrow keys, but the shortcut recorder only supports letter/number keys. - /// Support both so users can customize pane navigation (e.g. Cmd+Ctrl+H/J/K/L). - private func matchDirectionalShortcut( +#if DEBUG + private func logBrowserZoomShortcutTrace( + stage: String, event: NSEvent, - stroke: ShortcutStroke, - arrowGlyph: String, - arrowKeyCode: UInt16 - ) -> Bool { - if stroke.key == arrowGlyph { - return matchArrowShortcut(event: event, stroke: stroke, keyCode: arrowKeyCode) + flags: NSEvent.ModifierFlags, + chars: String, + action: BrowserZoomShortcutAction? = nil, + handled: Bool? = nil + ) { + guard browserZoomShortcutTraceCandidate( + flags: flags, + chars: chars, + keyCode: event.keyCode, + literalChars: event.characters + ) else { + return } - return matchShortcutStroke(event: event, stroke: stroke) - } - private func matchDirectionalShortcut( - event: NSEvent, - shortcut: StoredShortcut, - arrowGlyph: String, - arrowKeyCode: UInt16 - ) -> Bool { - guard !shortcut.hasChord else { return false } - return matchDirectionalShortcut( - event: event, - stroke: shortcut.firstStroke, - arrowGlyph: arrowGlyph, - arrowKeyCode: arrowKeyCode - ) + let keyWindow = NSApp.keyWindow + let firstResponderType = keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let panel = tabManager?.focusedBrowserPanel + let panelToken = panel.map { String($0.id.uuidString.prefix(8)) } ?? "nil" + let panelZoom = panel?.webView.pageZoom ?? -1 + var line = + "zoom.shortcut stage=\(stage) event=\(NSWindow.keyDescription(event)) " + + "chars='\(chars)' flags=\(browserZoomShortcutTraceFlagsString(flags)) " + + "action=\(browserZoomShortcutTraceActionString(action)) keyWin=\(keyWindow?.windowNumber ?? -1) " + + "fr=\(firstResponderType) panel=\(panelToken) zoom=\(String(format: "%.3f", panelZoom)) " + + "addrBarId=\(browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")" + if let handled { + line += " handled=\(handled ? 1 : 0)" + } + dlog(line) } - func validateMenuItem(_ item: NSMenuItem) -> Bool { - updateController.validateMenuItem(item) + private func browserFocusStateSnapshot() -> String { + let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let focused = tabManager?.selectedWorkspace?.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let addressBar = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let keyWindow = NSApp.keyWindow?.windowNumber ?? -1 + let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + return "selected=\(selected) focused=\(focused) addr=\(addressBar) keyWin=\(keyWindow) fr=\(firstResponderType)" } + private func redactedDebugURL(_ url: URL?) -> String { + guard let url else { return "nil" } + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return "" + } + components.user = nil + components.password = nil + components.query = nil + components.fragment = nil + return components.string ?? "" + } +#endif - private func configureUserNotifications() { - let actions = [ - UNNotificationAction( - identifier: TerminalNotificationStore.actionShowIdentifier, - title: "Show" + @discardableResult + private func focusBrowserAddressBar(panelId: UUID) -> Bool { + guard let tabManager, + let workspace = tabManager.selectedWorkspace, + let panel = workspace.browserPanel(for: panelId) else { +#if DEBUG + dlog( + "browser.focus.addressBar.route panel=\(panelId.uuidString.prefix(5)) " + + "result=miss \(browserFocusStateSnapshot())" ) - ] - - let category = UNNotificationCategory( - identifier: TerminalNotificationStore.categoryIdentifier, - actions: actions, - intentIdentifiers: [], - options: [.customDismissAction] +#endif + return false + } +#if DEBUG + dlog( + "browser.focus.addressBar.route panel=\(panel.id.uuidString.prefix(5)) " + + "workspace=\(workspace.id.uuidString.prefix(5)) result=hit \(browserFocusStateSnapshot())" ) - - let center = UNUserNotificationCenter.current() - center.setNotificationCategories([category]) - center.delegate = self +#endif + workspace.focusPanel(panel.id) +#if DEBUG + let focusedAfter = workspace.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.addressBar.route panel=\(panel.id.uuidString.prefix(5)) " + + "workspace=\(workspace.id.uuidString.prefix(5)) focusedAfter=\(focusedAfter)" + ) +#endif + focusBrowserAddressBar(in: panel) + return true } - private func disableNativeTabbingShortcut() { - guard let menu = NSApp.mainMenu else { return } - disableMenuItemShortcut(in: menu, action: #selector(NSWindow.toggleTabBar(_:))) + @discardableResult + func openBrowserAndFocusAddressBar(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? { + let preferredProfileID = + tabManager?.focusedBrowserPanel?.profileID + ?? tabManager?.selectedWorkspace?.preferredBrowserProfileID + guard let panelId = tabManager?.openBrowser( + url: url, + preferredProfileID: preferredProfileID, + insertAtEnd: insertAtEnd + ) else { +#if DEBUG + dlog( + "browser.focus.openAndFocus result=open_failed insertAtEnd=\(insertAtEnd ? 1 : 0) " + + "url=\(redactedDebugURL(url)) \(browserFocusStateSnapshot())" + ) +#endif + return nil + } +#if DEBUG + 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) + dlog( + "browser.focus.openAndFocus result=focus_request panel=\(panelId.uuidString.prefix(5)) " + + "focused=\(didFocus ? 1 : 0) \(browserFocusStateSnapshot())" + ) +#else + _ = focusBrowserAddressBar(panelId: panelId) +#endif + return panelId } - private func disableMenuItemShortcut(in menu: NSMenu, action: Selector) { - for item in menu.items { - if item.action == action { - item.keyEquivalent = "" - item.keyEquivalentModifierMask = [] - item.isEnabled = false - } - if let submenu = item.submenu { - disableMenuItemShortcut(in: submenu, action: action) - } - } + private func focusBrowserAddressBar(in panel: BrowserPanel) { +#if DEBUG + let requestId = panel.requestAddressBarFocus() + dlog( + "browser.focus.addressBar.request panel=\(panel.id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8)) \(browserFocusStateSnapshot())" + ) +#else + _ = panel.requestAddressBarFocus() +#endif + browserAddressBarFocusedPanelId = panel.id +#if DEBUG + 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 + dlog( + "browser.focus.addressBar.notify panel=\(panel.id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8))" + ) +#endif } - private func ensureApplicationIcon() { - // Manual light/dark override removed; the icon always tracks system - // appearance. Clear any stale pre-removal override so the dock-tile - // plugin (which reads the raw key cross-process) falls back too. - UserDefaults.standard.removeObject(forKey: "appIconMode") - AppIconAppearanceObserver.shared.startObserving() + func focusedBrowserAddressBarPanelId() -> UUID? { + browserAddressBarFocusedPanelId } - private func scheduleLaunchServicesBundleRegistration( - bundleURL: URL = Bundle.main.bundleURL.standardizedFileURL, - scheduler: @escaping (@escaping @Sendable () -> Void) -> Void = AppDelegate.enqueueLaunchServicesRegistrationWork, - register: @escaping (CFURL) -> OSStatus = { url in - LSRegisterURL(url, true) - }, - breadcrumb: @escaping (_ message: String, _ data: [String: Any]) -> Void = { _, _ in } - ) { - let normalizedURL = bundleURL.standardizedFileURL - breadcrumb("launchservices.register.schedule", [ - "bundlePath": normalizedURL.path - ]) + private func focusedBrowserAddressBarPanelIdForShortcutEvent(_ event: NSEvent) -> UUID? { + guard let panelId = browserAddressBarFocusedPanelId else { return nil } - scheduler { - let startedAt = CFAbsoluteTimeGetCurrent() - let registerStatus = register(normalizedURL as CFURL) - let durationMs = Int(((CFAbsoluteTimeGetCurrent() - startedAt) * 1000).rounded()) + guard let context = preferredMainWindowContextForShortcutRouting(event: event) else { +#if DEBUG + dlog( + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + + "accepted=0 reason=no_context event=\(NSWindow.keyDescription(event))" + ) +#endif + return nil + } - breadcrumb("launchservices.register.complete", [ - "bundlePath": normalizedURL.path, - "status": Int(registerStatus), - "durationMs": durationMs - ]) + guard let workspace = context.tabManager.selectedWorkspace else { +#if DEBUG + dlog( + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + + "accepted=0 reason=no_workspace event=\(NSWindow.keyDescription(event))" + ) +#endif + return nil + } - if registerStatus != noErr { - NSLog("LaunchServices registration failed (status: \(registerStatus)) for \(normalizedURL.path)") - } + guard workspace.browserPanel(for: panelId) != nil else { +#if DEBUG + 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))" + ) +#endif + return nil } - } #if DEBUG - func scheduleLaunchServicesBundleRegistrationForTesting( - bundleURL: URL, - scheduler: @escaping (@escaping @Sendable () -> Void) -> Void, - register: @escaping (CFURL) -> OSStatus, - breadcrumb: @escaping (_ message: String, _ data: [String: Any]) -> Void = { _, _ in } - ) { - scheduleLaunchServicesBundleRegistration( - bundleURL: bundleURL, - scheduler: scheduler, - register: register, - breadcrumb: breadcrumb + dlog( + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + + "accepted=1 workspace=\(workspace.id.uuidString.prefix(5)) event=\(NSWindow.keyDescription(event))" ) - } #endif + return panelId + } - private func enforceSingleInstance() { - guard let bundleId = Bundle.main.bundleIdentifier else { return } - let currentPid = ProcessInfo.processInfo.processIdentifier + @discardableResult + func requestBrowserAddressBarFocus(panelId: UUID) -> Bool { + focusBrowserAddressBar(panelId: panelId) + } - for app in NSRunningApplication.runningApplications(withBundleIdentifier: bundleId) { - guard app.processIdentifier != currentPid else { continue } - app.terminate() - if !app.isTerminated { - _ = app.forceTerminate() - } + private func shouldBypassAppShortcutForFocusedBrowserAddressBar( + flags: NSEvent.ModifierFlags, + chars: String + ) -> Bool { + guard browserAddressBarFocusedPanelId != nil else { return false } + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control] + guard isCommandOrControlOnly else { return false } + let shouldBypass = chars == "n" || chars == "p" +#if DEBUG + if shouldBypass { + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.addressBar.shortcutBypass panel=\(panelToken) " + + "chars=\(chars) flags=\(normalizedFlags.rawValue)" + ) } +#endif + return shouldBypass } - private func observeDuplicateLaunches() { - guard let bundleId = Bundle.main.bundleIdentifier else { return } - let embeddedCLIURL = Bundle.main.bundleURL - .appendingPathComponent("Contents/Resources/bin/programa", isDirectory: false) - .standardizedFileURL - .resolvingSymlinksInPath() - let currentPid = ProcessInfo.processInfo.processIdentifier + private func commandOmnibarSelectionDelta( + flags: NSEvent.ModifierFlags, + chars: String + ) -> Int? { + browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: browserAddressBarFocusedPanelId != nil, + flags: flags, + chars: chars + ) + } - workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver( - forName: NSWorkspace.didLaunchApplicationNotification, - object: nil, - queue: .main - ) { [weak self] notification in - guard self != nil else { return } - guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return } - guard app.bundleIdentifier == bundleId, app.processIdentifier != currentPid else { return } - if let executableURL = app.executableURL? - .standardizedFileURL - .resolvingSymlinksInPath(), - executableURL == embeddedCLIURL { - return - } + private func dispatchBrowserOmnibarSelectionMove(delta: Int) { + guard delta != 0 else { return } + guard let panelId = browserAddressBarFocusedPanelId else { return } +#if DEBUG + dlog( + "browser.focus.omnibar.selectionMove panel=\(panelId.uuidString.prefix(5)) " + + "delta=\(delta) repeatKey=\(browserOmnibarRepeatKeyCode.map(String.init) ?? "nil")" + ) +#endif + NotificationCenter.default.post( + name: .browserMoveOmnibarSelection, + object: panelId, + userInfo: ["delta": delta] + ) + } - app.terminate() - if !app.isTerminated { - _ = app.forceTerminate() - } - NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + private func startBrowserOmnibarSelectionRepeatIfNeeded(keyCode: UInt16, delta: Int) { + guard delta != 0 else { return } + guard browserAddressBarFocusedPanelId != nil else { +#if DEBUG + dlog( + "browser.focus.omnibar.repeat.start key=\(keyCode) delta=\(delta) " + + "result=skip_no_focused_address_bar" + ) +#endif + return } - } - func userNotificationCenter( - _ center: UNUserNotificationCenter, - didReceive response: UNNotificationResponse, - withCompletionHandler completionHandler: @escaping () -> Void - ) { - handleNotificationResponse(response) - completionHandler() - } + if browserOmnibarRepeatKeyCode == keyCode, browserOmnibarRepeatDelta == delta { +#if DEBUG + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.omnibar.repeat.start panel=\(panelToken) " + + "key=\(keyCode) delta=\(delta) result=reuse" + ) +#endif + return + } - func userNotificationCenter( - _ center: UNUserNotificationCenter, - willPresent notification: UNNotification, - withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void - ) { - var options: UNNotificationPresentationOptions = [.banner, .list] - if notification.request.content.sound != nil { - options.insert(.sound) + stopBrowserOmnibarSelectionRepeat() + browserOmnibarRepeatKeyCode = keyCode + browserOmnibarRepeatDelta = delta +#if DEBUG + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.omnibar.repeat.start panel=\(panelToken) " + + "key=\(keyCode) delta=\(delta) result=armed" + ) +#endif + + let start = DispatchWorkItem { [weak self] in + self?.scheduleBrowserOmnibarSelectionRepeatTick() } - completionHandler(options) + browserOmnibarRepeatStartWorkItem = start + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: start) } - private func handleNotificationResponse(_ response: UNNotificationResponse) { - guard let tabIdString = response.notification.request.content.userInfo["tabId"] as? String, - let tabId = UUID(uuidString: tabIdString) else { + private func scheduleBrowserOmnibarSelectionRepeatTick() { + browserOmnibarRepeatStartWorkItem = nil + guard browserAddressBarFocusedPanelId != nil else { +#if DEBUG + dlog("browser.focus.omnibar.repeat.tick result=stop_no_focused_address_bar") +#endif + stopBrowserOmnibarSelectionRepeat() return } - let surfaceId: UUID? = { - guard let surfaceIdString = response.notification.request.content.userInfo["surfaceId"] as? String else { - return nil - } - return UUID(uuidString: surfaceIdString) - }() + guard browserOmnibarRepeatKeyCode != nil else { return } - switch response.actionIdentifier { - case UNNotificationDefaultActionIdentifier, TerminalNotificationStore.actionShowIdentifier: - let notificationId: UUID? = { - if let id = UUID(uuidString: response.notification.request.identifier) { - return id - } - if let idString = response.notification.request.content.userInfo["notificationId"] as? String, - let id = UUID(uuidString: idString) { - return id - } - return nil - }() - DispatchQueue.main.async { - _ = self.openNotification(tabId: tabId, surfaceId: surfaceId, notificationId: notificationId) - } - case UNNotificationDismissActionIdentifier: - DispatchQueue.main.async { - if let notificationId = UUID(uuidString: response.notification.request.identifier) { - self.notificationStore?.markRead(id: notificationId) - } else if let notificationIdString = response.notification.request.content.userInfo["notificationId"] as? String, - let notificationId = UUID(uuidString: notificationIdString) { - self.notificationStore?.markRead(id: notificationId) - } - } - default: - break +#if DEBUG + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.omnibar.repeat.tick panel=\(panelToken) " + + "delta=\(browserOmnibarRepeatDelta)" + ) +#endif + dispatchBrowserOmnibarSelectionMove(delta: browserOmnibarRepeatDelta) + + let tick = DispatchWorkItem { [weak self] in + self?.scheduleBrowserOmnibarSelectionRepeatTick() } + browserOmnibarRepeatTickWorkItem = tick + DispatchQueue.main.asyncAfter(deadline: .now() + 0.055, execute: tick) } - 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 } - self.setActiveMainWindow(window) + private func stopBrowserOmnibarSelectionRepeat() { +#if DEBUG + let previousKeyCode = browserOmnibarRepeatKeyCode + let previousDelta = browserOmnibarRepeatDelta +#endif + browserOmnibarRepeatStartWorkItem?.cancel() + browserOmnibarRepeatTickWorkItem?.cancel() + browserOmnibarRepeatStartWorkItem = nil + browserOmnibarRepeatTickWorkItem = nil + browserOmnibarRepeatKeyCode = nil + browserOmnibarRepeatDelta = 0 +#if DEBUG + if previousKeyCode != nil || previousDelta != 0 { + dlog( + "browser.focus.omnibar.repeat.stop key=\(previousKeyCode.map(String.init) ?? "nil") " + + "delta=\(previousDelta)" + ) } +#endif } - private func installBrowserAddressBarFocusObservers() { - guard browserAddressBarFocusObserver == nil, browserAddressBarBlurObserver == nil else { return } + private func handleBrowserOmnibarSelectionRepeatLifecycleEvent(_ event: NSEvent) { + guard browserOmnibarRepeatKeyCode != nil else { return } - browserAddressBarFocusObserver = NotificationCenter.default.addObserver( - 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.browserPanel(for: panelId)?.beginSuppressWebViewFocusForAddressBar() - self.browserAddressBarFocusedPanelId = panelId - self.stopBrowserOmnibarSelectionRepeat() + switch event.type { + case .keyUp: + if event.keyCode == browserOmnibarRepeatKeyCode { #if DEBUG - dlog("addressBar FOCUS panelId=\(panelId.uuidString.prefix(8))") + dlog( + "browser.focus.omnibar.repeat.lifecycle event=keyUp key=\(event.keyCode) " + + "action=stop" + ) #endif - } - - browserAddressBarBlurObserver = NotificationCenter.default.addObserver( - forName: .browserDidBlurAddressBar, - 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() + stopBrowserOmnibarSelectionRepeat() + } + case .flagsChanged: + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if !flags.contains(.command) { #if DEBUG - dlog("addressBar BLUR panelId=\(panelId.uuidString.prefix(8))") + dlog( + "browser.focus.omnibar.repeat.lifecycle event=flagsChanged " + + "flags=\(flags.rawValue) action=stop" + ) #endif + stopBrowserOmnibarSelectionRepeat() } + default: + break } } - private func browserPanel(for panelId: UUID) -> BrowserPanel? { - return tabManager?.selectedWorkspace?.browserPanel(for: panelId) - } - - fileprivate func browserFindBarIsVisible(for webView: ProgramaWebView) -> Bool { - browserPanelOwning(webView)?.searchState != nil - } - - private func shouldLetFocusedBrowserOwnFindShortcut(_ event: NSEvent) -> Bool { - let shortcutWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow - let shortcutResponder = shortcutWindow?.firstResponder - let owningWebView = tabManager?.focusedBrowserPanel?.webView as? ProgramaWebView - guard let owningWebView else { return false } - return shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst( - event, - responder: shortcutResponder, - owningWebView: owningWebView - ) + private func isLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool { + programaIsLikelyWebInspectorResponder(responder) } - private func browserPanelOwning(_ webView: ProgramaWebView) -> BrowserPanel? { - var candidateManagers: [TabManager] = [] - var seenManagers = Set() - - func appendCandidate(_ manager: TabManager?) { - guard let manager else { return } - let identifier = ObjectIdentifier(manager) - guard seenManagers.insert(identifier).inserted else { return } - candidateManagers.append(manager) - } - - if let window = webView.window, - let context = contextForMainWindow(window) { - appendCandidate(context.tabManager) +#if DEBUG + private func developerToolsShortcutProbeKind(event: NSEvent) -> String? { + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) { + return "toggle.configured" } - appendCandidate(tabManager) - for context in mainWindowContexts.values { - appendCandidate(context.tabManager) + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showBrowserJavaScriptConsole)) { + return "console.configured" } - for manager in candidateManagers { - if let panel = browserPanelOwning(webView, in: manager) { - return panel + let chars = (event.charactersIgnoringModifiers ?? "").lowercased() + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if flags == [.command, .option] { + if chars == "i" || event.keyCode == 34 { + return "toggle.literal" + } + if chars == "c" || event.keyCode == 8 { + return "console.literal" } } return nil } - private func browserPanelOwning(_ webView: ProgramaWebView, in manager: TabManager) -> BrowserPanel? { - for workspace in manager.tabs { - if let panel = workspace.panels.values - .compactMap({ $0 as? BrowserPanel }) - .first(where: { $0.webView === webView }) { - return panel + private func logDeveloperToolsShortcutSnapshot( + phase: String, + event: NSEvent? = nil, + didHandle: Bool? = nil + ) { + let keyWindow = NSApp.keyWindow + let firstResponder = keyWindow?.firstResponder + let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + let eventDescription = event.map(NSWindow.keyDescription) ?? "none" + if let browser = tabManager?.focusedBrowserPanel { + var line = + "browser.devtools shortcut=\(phase) panel=\(browser.id.uuidString.prefix(5)) " + + "\(browser.debugDeveloperToolsStateSummary()) \(browser.debugDeveloperToolsGeometrySummary()) " + + "keyWin=\(keyWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)" + if let didHandle { + line += " handled=\(didHandle ? 1 : 0)" } + dlog(line) + return } - return nil + var line = + "browser.devtools shortcut=\(phase) panel=nil keyWin=\(keyWindow?.windowNumber ?? -1) " + + "fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)" + if let didHandle { + line += " handled=\(didHandle ? 1 : 0)" + } + dlog(line) } - - private func setActiveMainWindow(_ window: NSWindow) { - guard let context = contextForMainTerminalWindow(window) else { return } -#if DEBUG - let beforeManagerToken = debugManagerToken(tabManager) #endif - tabManager = context.tabManager - sidebarState = context.sidebarState - sidebarSelectionState = context.sidebarSelectionState - TerminalController.shared.setActiveTabManager(context.tabManager) -#if DEBUG + + private func prepareFocusedBrowserDevToolsForSplit(directionLabel: String) { + guard let browser = tabManager?.focusedBrowserPanel else { return } + guard browser.shouldPreserveWebViewAttachmentDuringTransientHide() else { return } + guard let keyWindow = NSApp.keyWindow else { return } + guard isLikelyWebInspectorResponder(keyWindow.firstResponder) else { return } + + let beforeResponder = keyWindow.firstResponder + let movedToWebView = keyWindow.makeFirstResponder(browser.webView) + let movedToNil = movedToWebView ? false : keyWindow.makeFirstResponder(nil) + + #if DEBUG + let beforeType = beforeResponder.map { String(describing: type(of: $0)) } ?? "nil" + let beforePtr = beforeResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + 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" dlog( - "mainWindow.active window={\(debugWindowToken(window))} context={\(debugContextToken(context))} beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) \(debugShortcutRouteSnapshot())" + "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())" ) -#endif + #endif } - private func unregisterMainWindow(_ window: NSWindow) { - // Reset cascade point so the next new window appears near the closing - // window's position, matching upstream Ghostty behavior. - let frame = window.frame - lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY) + @discardableResult + func performSplitShortcut(direction: SplitDirection, preferredWindow: NSWindow? = nil) -> Bool { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) + _ = synchronizeActiveMainWindowContext(preferredWindow: targetWindow) - // Keep geometry available as a fallback even if the full session snapshot - // is removed when the last window closes. - persistWindowGeometry(from: window) - guard let removed = unregisterMainWindowContext(for: window) else { return } - commandPaletteVisibilityByWindowId.removeValue(forKey: removed.windowId) - commandPalettePendingOpenByWindowId.removeValue(forKey: removed.windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: removed.windowId) - commandPaletteEscapeSuppressionByWindowId.remove(removed.windowId) - commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: removed.windowId) - commandPaletteSelectionByWindowId.removeValue(forKey: removed.windowId) - commandPaletteSnapshotByWindowId.removeValue(forKey: removed.windowId) + let directionLabel: String + switch direction { + case .left: directionLabel = "left" + case .right: directionLabel = "right" + case .up: directionLabel = "up" + case .down: directionLabel = "down" + } - // Avoid stale notifications that can no longer be opened once the owning window is gone. - if let store = notificationStore { - for tab in removed.tabManager.tabs { - store.clearNotifications(forTabId: tab.id) + #if DEBUG + let keyWindow = NSApp.keyWindow + let firstResponder = keyWindow?.firstResponder + let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + let firstResponderWindow: Int = { + if let v = firstResponder as? NSView { + return v.window?.windowNumber ?? -1 + } + if let w = firstResponder as? NSWindow { + return w.windowNumber } + return -1 + }() + let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)" + 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" + dlog("split.shortcut dir=\(directionLabel) pre panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)") + } else { + dlog("split.shortcut dir=\(directionLabel) pre panel=nil \(splitContext)") } + #endif - if tabManager === removed.tabManager { - // Repoint "active" pointers to any remaining main terminal window. - let nextContext: MainWindowContext? = { - if let keyWindow = NSApp.keyWindow, - let ctx = contextForMainTerminalWindow(keyWindow, reindex: false) { - return ctx + prepareFocusedBrowserDevToolsForSplit(directionLabel: directionLabel) + let didCreateSplit: Bool = { + if let terminalContext { + return terminalContext.tabManager.createSplit( + tabId: terminalContext.workspaceId, + surfaceId: terminalContext.panelId, + direction: direction + ) != nil + } + return tabManager?.createSplit(direction: direction) != nil + }() +#if DEBUG + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in + let keyWindow = NSApp.keyWindow + let firstResponder = keyWindow?.firstResponder + let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + let firstResponderWindow: Int = { + if let v = firstResponder as? NSView { + return v.window?.windowNumber ?? -1 } - return mainWindowContexts.values.first + if let w = firstResponder as? NSWindow { + return w.windowNumber + } + return -1 }() - - if let nextContext { - tabManager = nextContext.tabManager - sidebarState = nextContext.sidebarState - sidebarSelectionState = nextContext.sidebarSelectionState - TerminalController.shared.setActiveTabManager(nextContext.tabManager) + let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)" + 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" + dlog("split.shortcut dir=\(directionLabel) post panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)") } else { - tabManager = nil - sidebarState = nil - sidebarSelectionState = nil - TerminalController.shared.setActiveTabManager(nil) + dlog("split.shortcut dir=\(directionLabel) post panel=nil \(splitContext)") } } - - // During app termination we already persisted a full snapshot (with scrollback) - // in applicationShouldTerminate/applicationWillTerminate. Saving again here would - // overwrite it as windows tear down one-by-one, dropping closed windows and replay. - if Self.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: isTerminatingApp) { - _ = saveSessionSnapshot( - includeScrollback: false, - removeWhenEmpty: Self.shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister( - isTerminatingApp: isTerminatingApp - ) - ) - } + recordGotoSplitSplitIfNeeded(direction: direction) +#endif + return didCreateSplit } - private func isMainTerminalWindow(_ window: NSWindow) -> Bool { - if mainWindowContexts[ObjectIdentifier(window)] != nil { - return true + @discardableResult + func performBrowserSplitShortcut(direction: SplitDirection) -> Bool { + _ = synchronizeActiveMainWindowContext(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow) + + #if DEBUG + let directionLabel: String + switch direction { + case .left: directionLabel = "left" + case .right: directionLabel = "right" + case .up: directionLabel = "up" + case .down: directionLabel = "down" } - guard let raw = window.identifier?.rawValue else { return false } - return raw == "cmux.main" || raw.hasPrefix("cmux.main.") - } + let selectedTabBefore = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil" + let focusedPanelBefore = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil" + dlog( + "split.browser.shortcut pre dir=\(directionLabel) " + + "tab=\(selectedTabBefore) focusedPanel=\(focusedPanelBefore)" + ) + #endif - private func contextContainingTabId(_ tabId: UUID) -> MainWindowContext? { - for context in mainWindowContexts.values { - if context.tabManager.tabs.contains(where: { $0.id == tabId }) { - return context - } + guard let panelId = tabManager?.createBrowserSplit(direction: direction) else { + #if DEBUG + dlog("split.browser.shortcut failed dir=\(directionLabel)") + #endif + return false } - return nil - } - /// Returns the `TabManager` that owns `tabId`, if any. - func tabManagerFor(tabId: UUID) -> TabManager? { - contextContainingTabId(tabId)?.tabManager - } + #if DEBUG + let selectedTabAfter = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil" + let focusedPanelAfter = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil" + dlog( + "split.browser.shortcut post dir=\(directionLabel) " + + "created=\(panelId.uuidString.prefix(5)) tab=\(selectedTabAfter) focusedPanel=\(focusedPanelAfter)" + ) + #endif - private func workspaceForMainActor(tabId: UUID) -> Workspace? { - contextContainingTabId(tabId)?.tabManager.tabs.first(where: { $0.id == tabId }) + _ = focusBrowserAddressBar(panelId: panelId) + return true } - /// Returns the `Workspace` that owns `tabId`, if any. - @MainActor - func workspaceFor(tabId: UUID) -> Workspace? { - workspaceForMainActor(tabId: tabId) + /// Allow AppKit-backed browser surfaces (WKWebView) to route non-menu shortcuts + /// through the same app-level shortcut handler used by the local key monitor. + @discardableResult + func handleBrowserSurfaceKeyEquivalent(_ event: NSEvent) -> Bool { + handleCustomShortcut(event: event) } - func closeMainWindowContainingTabId(_ tabId: UUID) { - guard let context = contextContainingTabId(tabId) else { return } - let expectedIdentifier = "cmux.main.\(context.windowId.uuidString)" - let window: NSWindow? = context.window ?? NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) - window?.performClose(nil) + @discardableResult + func requestRenameWorkspaceViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteRenameWorkspace( + preferredWindow: targetWindow, + source: "shortcut.renameWorkspace" + ) + return true } @discardableResult - func openNotification(tabId: UUID, surfaceId: UUID?, notificationId: UUID?) -> Bool { -#if DEBUG - let isJumpUnreadUITest = ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" - if isJumpUnreadUITest { - writeJumpUnreadTestData([ - "jumpUnreadOpenCalled": "1", - "jumpUnreadOpenTabId": tabId.uuidString, - "jumpUnreadOpenSurfaceId": surfaceId?.uuidString ?? "", - ]) - } -#endif - guard let context = contextContainingTabId(tabId) else { -#if DEBUG - recordMultiWindowNotificationOpenFailureIfNeeded( - tabId: tabId, - surfaceId: surfaceId, - notificationId: notificationId, - reason: "missing_context" - ) -#endif -#if DEBUG - if isJumpUnreadUITest { - writeJumpUnreadTestData(["jumpUnreadOpenContextFound": "0", "jumpUnreadOpenUsedFallback": "1"]) - } -#endif - let ok = openNotificationFallback(tabId: tabId, surfaceId: surfaceId, notificationId: notificationId) + func requestEditWorkspaceDescriptionViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow #if DEBUG - if isJumpUnreadUITest { - writeJumpUnreadTestData(["jumpUnreadOpenResult": ok ? "1" : "0"]) - } + dlog( + "shortcut.editWorkspaceDescription request target={\(debugWindowToken(targetWindow))} " + + "fr=\(targetWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" + ) #endif - return ok - } + requestCommandPaletteEditWorkspaceDescription( + preferredWindow: targetWindow, + source: "shortcut.editWorkspaceDescription" + ) + return true + } + #if DEBUG - if isJumpUnreadUITest { - writeJumpUnreadTestData(["jumpUnreadOpenContextFound": "1", "jumpUnreadOpenUsedFallback": "0"]) + // 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 { + return handleCustomShortcut(event: event) } -#endif - return openNotificationInContext(context, tabId: tabId, surfaceId: surfaceId, notificationId: notificationId) + handleBrowserOmnibarSelectionRepeatLifecycleEvent(event) + return clearEscapeSuppressionForKeyUp(event: event, consumeIfSuppressed: true) } - private func openNotificationInContext(_ context: MainWindowContext, tabId: UUID, surfaceId: UUID?, notificationId: UUID?) -> Bool { - let expectedIdentifier = "cmux.main.\(context.windowId.uuidString)" - let window: NSWindow? = context.window ?? NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) - guard let window else { -#if DEBUG - recordMultiWindowNotificationOpenFailureIfNeeded( - tabId: tabId, - surfaceId: surfaceId, - notificationId: notificationId, - reason: "missing_window expectedIdentifier=\(expectedIdentifier)" - ) -#endif - return false + func debugMarkCommandPaletteOpenPending(window: NSWindow) { + markCommandPaletteOpenRequested(for: window) + } + + @discardableResult + func debugSetCommandPalettePendingOpenAge(window: NSWindow, age: TimeInterval) -> Bool { + guard let windowId = mainWindowId(for: window) else { return false } + updateCommandPaletteState(for: windowId) { state in + state.pendingOpen = true + state.recentRequestAt = ProcessInfo.processInfo.systemUptime - max(age, 0) } + return true + } - context.sidebarSelectionState.selection = .tabs - bringToFront(window) - guard context.tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) else { -#if DEBUG - recordMultiWindowNotificationOpenFailureIfNeeded( - tabId: tabId, - surfaceId: surfaceId, - notificationId: notificationId, - reason: "focus_failed" - ) - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData(["jumpUnreadOpenResult": "0"]) - } -#endif + // Test hook: remap a window context under a detached window key so direct + // ObjectIdentifier(window) lookups fail and fallback logic is exercised. + @discardableResult + func debugInjectWindowContextKeyMismatch(windowId: UUID) -> Bool { + guard let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }), + let window = context.window ?? windowForMainWindowId(windowId) else { return false } -#if DEBUG - // UI test support: Jump-to-unread asserts that the correct workspace/panel is focused. - // Recording via first-responder can be flaky on the VM, so verify focus via the model. - recordJumpUnreadFocusFromModelIfNeeded( - tabManager: context.tabManager, - tabId: tabId, - expectedSurfaceId: surfaceId + let detachedWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 16, height: 16), + styleMask: [.borderless], + backing: .buffered, + defer: false ) -#endif + debugDetachedContextWindows.append(detachedWindow) - if let notificationId, let store = notificationStore { - markReadIfFocused( - notificationId: notificationId, - tabId: tabId, - surfaceId: surfaceId, - tabManager: context.tabManager, - notificationStore: store - ) + let contextKeys = mainWindowContexts.compactMap { key, value in + value === context ? key : nil } - -#if DEBUG - recordMultiWindowNotificationFocusIfNeeded( - windowId: context.windowId, - tabId: tabId, - surfaceId: surfaceId, - sidebarSelection: context.sidebarSelectionState.selection - ) - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData(["jumpUnreadOpenInContext": "1", "jumpUnreadOpenResult": "1"]) + for key in contextKeys { + mainWindowContexts.removeValue(forKey: key) } -#endif + mainWindowContexts[ObjectIdentifier(detachedWindow)] = context + context.window = window return true } +#endif - private func openNotificationFallback(tabId: UUID, surfaceId: UUID?, notificationId: UUID?) -> Bool { - // If the owning window context hasn't been registered yet, fall back to the "active" window. - guard let tabManager else { -#if DEBUG - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData(["jumpUnreadFallbackFail": "missing_tabManager"]) + private func findButton(in view: NSView, titled title: String) -> NSButton? { + if let button = view as? NSButton, button.title == title { + return button + } + for subview in view.subviews { + if let found = findButton(in: subview, titled: title) { + return found } -#endif - return false } - guard tabManager.tabs.contains(where: { $0.id == tabId }) else { -#if DEBUG - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData(["jumpUnreadFallbackFail": "tab_not_in_active_manager"]) + return nil + } + + private func findStaticText(in view: NSView, equals text: String) -> Bool { + if let field = view as? NSTextField, field.stringValue == text { + return true + } + for subview in view.subviews { + if findStaticText(in: subview, equals: text) { + return true + } + } + return false + } + + 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 { + return false } -#endif - return false + return matchShortcutStroke(event: event, stroke: secondStroke) } - guard let window = (NSApp.keyWindow ?? NSApp.windows.first(where: { isMainTerminalWindow($0) })) else { -#if DEBUG - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData(["jumpUnreadFallbackFail": "missing_window"]) + guard !shortcut.hasChord else { return false } + return matchShortcutStroke(event: event, stroke: shortcut.firstStroke) + } + + private func numberedConfiguredShortcutDigit( + event: NSEvent, + action: KeyboardShortcutSettings.Action + ) -> Int? { + let shortcut = KeyboardShortcutSettings.shortcut(for: action) + if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { + guard let secondStroke = shortcut.secondStroke, + shortcut.firstStroke == prefix else { + return nil } -#endif - return false + return numberedShortcutDigit(event: event, stroke: secondStroke) } + guard !shortcut.hasChord else { return nil } + return numberedShortcutDigit(event: event, stroke: shortcut.firstStroke) + } - sidebarSelectionState?.selection = .tabs - bringToFront(window) - guard tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) else { -#if DEBUG - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData([ - "jumpUnreadFallbackFail": "focus_failed", - "jumpUnreadOpenResult": "0", - ]) + private func matchConfiguredDirectionalShortcut( + event: NSEvent, + action: KeyboardShortcutSettings.Action, + arrowGlyph: String, + arrowKeyCode: UInt16 + ) -> Bool { + let shortcut = KeyboardShortcutSettings.shortcut(for: action) + if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { + guard let secondStroke = shortcut.secondStroke, + shortcut.firstStroke == prefix else { + return false } -#endif - return false + return matchDirectionalShortcut( + event: event, + stroke: secondStroke, + arrowGlyph: arrowGlyph, + arrowKeyCode: arrowKeyCode + ) } - -#if DEBUG - recordJumpUnreadFocusFromModelIfNeeded( - tabManager: tabManager, - tabId: tabId, - expectedSurfaceId: surfaceId + guard !shortcut.hasChord else { return false } + return matchDirectionalShortcut( + event: event, + stroke: shortcut.firstStroke, + arrowGlyph: arrowGlyph, + arrowKeyCode: arrowKeyCode ) -#endif + } - if let notificationId, let store = notificationStore { - markReadIfFocused( - notificationId: notificationId, - tabId: tabId, - surfaceId: surfaceId, - tabManager: tabManager, - notificationStore: store - ) + private func configuredShortcutChordWindowNumber(for event: NSEvent) -> Int? { + if let window = mainWindowForShortcutEvent(event) { + return window.windowNumber } -#if DEBUG - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData(["jumpUnreadOpenInFallback": "1", "jumpUnreadOpenResult": "1"]) + if let window = event.window { + return window.windowNumber } -#endif - return true + return event.windowNumber > 0 ? event.windowNumber : nil } -#if DEBUG - private func recordJumpUnreadFocusFromModelIfNeeded( - tabManager: TabManager, - tabId: UUID, - expectedSurfaceId: UUID? - ) { - let env = ProcessInfo.processInfo.environment - guard env["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" else { return } - guard let expectedSurfaceId else { return } + private func armConfiguredShortcutChordIfNeeded( + event: NSEvent, + actions: [KeyboardShortcutSettings.Action]? = nil + ) -> Bool { + 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( + firstStroke: shortcut.firstStroke, + windowNumber: configuredShortcutChordWindowNumber(for: event) + ) + return true + } + } + return false + } - // Ensure the expectation is armed even if the view doesn't become first responder. - armJumpUnreadFocusRecord(tabId: tabId, surfaceId: expectedSurfaceId) + /// Match a shortcut stroke against an event, handling normal keys. + private func matchShortcutStroke(event: NSEvent, stroke: ShortcutStroke) -> Bool { + // Some keys can include extra flags (e.g. .function) depending on the responder chain. + // Strip those for consistent matching across first responders (terminal, WebKit, etc). + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + guard flags == stroke.modifierFlags else { return false } - if tabManager.selectedTabId == tabId, - tabManager.focusedSurfaceId(for: tabId) == expectedSurfaceId { - recordJumpUnreadFocusIfExpected(tabId: tabId, surfaceId: expectedSurfaceId) - return + let shortcutKey = stroke.key.lowercased() + if shortcutKey == "\r" { + return event.keyCode == 36 || event.keyCode == 76 } - var resolved = false - var observers: [NSObjectProtocol] = [] - var cancellables: [AnyCancellable] = [] + let eventCharsIgnoringModifiers = event.charactersIgnoringModifiers + if shortcutCharacterMatches( + eventCharacter: eventCharsIgnoringModifiers, + shortcutKey: shortcutKey, + applyShiftSymbolNormalization: flags.contains(.shift), + eventKeyCode: event.keyCode + ) { + return true + } - func cleanup() { - observers.forEach { NotificationCenter.default.removeObserver($0) } - observers.removeAll() - cancellables.forEach { $0.cancel() } - cancellables.removeAll() + // For command-based shortcuts, trust AppKit's layout-aware characters when present. + // Keep this strict for letter shortcuts to avoid physical-key collisions across layouts, + // while still allowing keyCode fallback for digit/punctuation shortcuts on non-US layouts. + // When a non-Latin input source is active (Russian, Korean, Chinese, Japanese, etc.), + // charactersIgnoringModifiers returns non-ASCII characters that can never match + // a Latin shortcut key — skip this guard and fall through to layout-based matching. + let hasEventChars = !(eventCharsIgnoringModifiers?.isEmpty ?? true) + let eventCharsAreASCII = eventCharsIgnoringModifiers?.allSatisfy(\.isASCII) ?? true + let shortcutKeyIsDigit = shortcutKey.count == 1 && shortcutKey.first?.isNumber == true + if shortcutKeyIsDigit, + hasEventChars, + eventCharsAreASCII, + digitForNumberKeyCode(event.keyCode) == nil { + return false + } + if hasEventChars, + eventCharsAreASCII, + flags.contains(.command), + !flags.contains(.control), + shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) { + return false } - @MainActor - func finishIfFocused() { - guard !resolved else { return } - guard tabManager.selectedTabId == tabId, - tabManager.focusedSurfaceId(for: tabId) == expectedSurfaceId else { - return - } - resolved = true - cleanup() - self.recordJumpUnreadFocusIfExpected(tabId: tabId, surfaceId: expectedSurfaceId) + // Match using the current keyboard layout so Command shortcuts stay character-based + // across layouts (QWERTY, Dvorak, etc.) instead of being tied to ANSI physical keys. + let layoutCharacter = shortcutLayoutCharacterProvider(event.keyCode, event.modifierFlags) + if shortcutCharacterMatches( + eventCharacter: layoutCharacter, + shortcutKey: shortcutKey, + applyShiftSymbolNormalization: false, + eventKeyCode: event.keyCode + ) { + return true } - observers.append(NotificationCenter.default.addObserver( - forName: .ghosttyDidFocusSurface, - object: nil, - queue: .main - ) { note in - guard let surfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, - surfaceId == expectedSurfaceId else { return } - Task { @MainActor in finishIfFocused() } - }) - cancellables.append(tabManager.$selectedTabId.sink { _ in - Task { @MainActor in finishIfFocused() } - }) - if let workspace = tabManager.tabs.first(where: { $0.id == tabId }) { - cancellables.append(workspace.$panels - .map { _ in () } - .sink { _ in - Task { @MainActor in finishIfFocused() } - }) + // Control-key combos can surface as ASCII control characters (e.g. Ctrl+H => backspace), + // so keep ANSI keyCode fallback for control-modified shortcuts. Also allow fallback for + // command punctuation shortcuts, since some non-US layouts report different characters + // for the same physical key even when menu-equivalent semantics should still apply. + // When a non-Latin input source is active (Russian, Korean, Chinese, Japanese, etc.), + // event chars carry no usable Latin key identity. Always allow keyCode fallback as a + // safety net — even when the layout-based translation resolved a character, the + // physical key code is the definitive identifier for the intended shortcut. + // For empty-character events (synthetic/browser key equivalents), preserve the original + // behavior: only fall back when the layout translation also failed. + let hasUsableEventChars = hasEventChars && eventCharsAreASCII + let allowANSIKeyCodeFallback = flags.contains(.control) + || (flags.contains(.command) + && !flags.contains(.control) + && ( + !shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) + || (hasEventChars && !eventCharsAreASCII) + || (!hasEventChars && (layoutCharacter?.isEmpty ?? true)) + )) + if allowANSIKeyCodeFallback, let expectedKeyCode = keyCodeForShortcutKey(shortcutKey) { + return event.keyCode == expectedKeyCode } - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - Task { @MainActor in - guard !resolved else { return } - cleanup() + return false + } + + private func matchShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { + guard !shortcut.hasChord else { return false } + return matchShortcutStroke(event: event, stroke: shortcut.firstStroke) + } + + private func numberedShortcutDigit(event: NSEvent, stroke: ShortcutStroke) -> Int? { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + guard flags == stroke.modifierFlags else { return nil } + let numberKeyDigit = digitForNumberKeyCode(event.keyCode) + + if let digit = numberedShortcutDigit( + eventCharacter: event.charactersIgnoringModifiers, + applyShiftSymbolNormalization: flags.contains(.shift), + eventKeyCode: event.keyCode + ) { + return digit + } + + let eventCharsIgnoringModifiers = event.charactersIgnoringModifiers + let hasUsableASCIIEventChars = !(eventCharsIgnoringModifiers?.isEmpty ?? true) + && (eventCharsIgnoringModifiers?.allSatisfy(\.isASCII) ?? true) + if !hasUsableASCIIEventChars || numberKeyDigit != nil { + let layoutCharacter = shortcutLayoutCharacterProvider(event.keyCode, event.modifierFlags) + if let digit = numberedShortcutDigit( + eventCharacter: layoutCharacter, + applyShiftSymbolNormalization: false, + eventKeyCode: event.keyCode + ) { + return digit } } - Task { @MainActor in finishIfFocused() } + + return numberKeyDigit } -#endif - func tabTitle(for tabId: UUID) -> String? { - if let context = contextContainingTabId(tabId) { - return context.tabManager.tabs.first(where: { $0.id == tabId })?.title - } - return tabManager?.tabs.first(where: { $0.id == tabId })?.title + private func numberedShortcutDigit(event: NSEvent, shortcut: StoredShortcut) -> Int? { + guard !shortcut.hasChord else { return nil } + return numberedShortcutDigit(event: event, stroke: shortcut.firstStroke) } - private func bringToFront(_ window: NSWindow) { - if TerminalController.shouldSuppressSocketCommandActivation(), - !TerminalController.socketCommandAllowsInAppFocusMutations() { - return - } - setActiveMainWindow(window) - if window.isMiniaturized { - window.deminiaturize(nil) - } - window.makeKeyAndOrderFront(nil) - // Improve reliability across Spaces / when other helper panels are key. - NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + private func numberedShortcutDigit( + eventCharacter: String?, + applyShiftSymbolNormalization: Bool, + eventKeyCode: UInt16 + ) -> Int? { + guard let eventCharacter, !eventCharacter.isEmpty else { return nil } + let normalized = normalizedShortcutEventCharacter( + eventCharacter, + applyShiftSymbolNormalization: applyShiftSymbolNormalization, + eventKeyCode: eventKeyCode + ) + guard let digit = Int(normalized), (1...9).contains(digit) else { return nil } + return digit } - private func markReadIfFocused( - notificationId: UUID, - tabId: UUID, - surfaceId: UUID?, - tabManager: TabManager, - notificationStore: TerminalNotificationStore - ) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - guard tabManager.selectedTabId == tabId else { return } - if let surfaceId { - guard tabManager.focusedSurfaceId(for: tabId) == surfaceId else { return } - } - notificationStore.markRead(id: notificationId) + private func shouldRequireCharacterMatchForCommandShortcut(shortcutKey: String) -> Bool { + guard shortcutKey.count == 1, let scalar = shortcutKey.unicodeScalars.first else { + return false } + return CharacterSet.letters.contains(scalar) } -#if DEBUG - private func recordMultiWindowNotificationOpenFailureIfNeeded( - tabId: UUID, - surfaceId: UUID?, - notificationId: UUID?, - reason: String - ) { - let env = ProcessInfo.processInfo.environment - guard let path = env["PROGRAMA_UI_TEST_MULTI_WINDOW_NOTIF_PATH"], !path.isEmpty else { return } - - let contextSummaries: [String] = mainWindowContexts.values.map { ctx in - let tabIds = ctx.tabManager.tabs.map { $0.id.uuidString }.joined(separator: ",") - let hasWindow = (ctx.window != nil) ? "1" : "0" - return "windowId=\(ctx.windowId.uuidString) hasWindow=\(hasWindow) tabs=[\(tabIds)]" + private func shortcutCharacterMatches( + eventCharacter: String?, + shortcutKey: String, + applyShiftSymbolNormalization: Bool, + eventKeyCode: UInt16 + ) -> Bool { + guard let eventCharacter, !eventCharacter.isEmpty else { return false } + if normalizedShortcutEventCharacter( + eventCharacter, + applyShiftSymbolNormalization: applyShiftSymbolNormalization, + eventKeyCode: eventKeyCode + ) == shortcutKey { + return true } - - writeMultiWindowNotificationTestData([ - "focusToken": UUID().uuidString, - "openFailureTabId": tabId.uuidString, - "openFailureSurfaceId": surfaceId?.uuidString ?? "", - "openFailureNotificationId": notificationId?.uuidString ?? "", - "openFailureReason": reason, - "openFailureContexts": contextSummaries.joined(separator: "; "), - ], at: path) + return false } -#endif - - /// Configures a Programa main window's AppKit-level presentation (identifier, - /// titlebar, movability, transparency, glass effect, decorations) and registers - /// it for window-context tracking, then installs the file-drop overlay. - /// - /// Extracted verbatim from `ContentView`'s `WindowAccessor` trailing closure - /// (nuclear-review CV2b) so this AppKit window mutation lives with the - /// window-context layer instead of the SwiftUI view layer. `ContentView` still - /// owns tracking its own `@State` (`observedWindow`, `isFullScreen`, - /// `titlebarPadding`) — this method returns the computed titlebar padding so - /// the caller can decide whether to update that `@State`. - @discardableResult - func configureMainWindow( - _ window: NSWindow, - windowId: UUID, - windowIdentifier: String, - tabManager: TabManager, - sidebarState: SidebarState, - sidebarSelectionState: SidebarSelectionState, - sidebarBlendMode: String, - bgGlassEnabled: Bool, - bgGlassTintHex: String, - bgGlassTintOpacity: Double - ) -> CGFloat { - window.identifier = NSUserInterfaceItemIdentifier(windowIdentifier) - window.titlebarAppearsTransparent = true - // Keep window immovable; the sidebar's WindowDragHandleView handles - // drag-to-move via performDrag with temporary movable override. - // isMovableByWindowBackground=true breaks tab reordering, and - // isMovable=true blocks clicks on sidebar buttons in minimal mode. - window.isMovableByWindowBackground = false - window.isMovable = false - window.styleMask.insert(.fullSizeContentView) - // Keep content below the titlebar so drags on Bonsplit's tab bar don't - // get interpreted as window drags. - let computedTitlebarHeight = window.frame.height - window.contentLayoutRect.height - let nextTitlebarPadding = max(28, min(72, computedTitlebarHeight)) -#if DEBUG - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_MODE"] == "1" { - UpdateLogStore.shared.append("ui test window accessor: id=\(windowIdentifier) visible=\(window.isVisible)") - } -#endif - // User settings decide whether window glass is active. The native Tahoe - // NSGlassEffectView path vs the older NSVisualEffectView fallback is chosen - // inside WindowGlassEffect.apply. - let currentThemeBackground = GhosttyBackgroundTheme.currentColor() - let shouldApplyWindowGlass = cmuxShouldApplyWindowGlass( - sidebarBlendMode: sidebarBlendMode, - bgGlassEnabled: bgGlassEnabled, - glassEffectAvailable: WindowGlassEffect.isAvailable - ) - let shouldForceTransparentHosting = - shouldApplyWindowGlass || currentThemeBackground.alphaComponent < 0.999 + private func normalizedShortcutEventCharacter( + _ eventCharacter: String, + applyShiftSymbolNormalization: Bool, + eventKeyCode: UInt16 + ) -> String { + let lowered = eventCharacter.lowercased() + guard applyShiftSymbolNormalization else { return lowered } - if shouldForceTransparentHosting { - window.isOpaque = false - // Keep the window clear whenever translucency is active. Relying only on - // terminal focus-driven updates can leave stale opaque window fills. - window.backgroundColor = NSColor.white.withAlphaComponent(0.001) - // Configure contentView hierarchy for transparency. - if let contentView = window.contentView { - ContentView.makeViewHierarchyTransparent(contentView) - } - } else { - // Browser-focused workspaces may not have an active terminal panel to refresh - // the NSWindow background. Keep opaque theme changes applied here as well. - window.backgroundColor = currentThemeBackground - window.isOpaque = currentThemeBackground.alphaComponent >= 0.999 + switch lowered { + case "{": return "[" + case "}": return "]" + case "<": return eventKeyCode == 43 ? "," : lowered // kVK_ANSI_Comma + case ">": return eventKeyCode == 47 ? "." : lowered // kVK_ANSI_Period + case "?": return "/" + case ":": return ";" + case "\"": return "'" + case "|": return "\\" + case "~": return "`" + case "+": return "=" + case "_": return "-" + case "!": return eventKeyCode == 18 ? "1" : lowered // kVK_ANSI_1 + case "@": return eventKeyCode == 19 ? "2" : lowered // kVK_ANSI_2 + case "#": return eventKeyCode == 20 ? "3" : lowered // kVK_ANSI_3 + case "$": return eventKeyCode == 21 ? "4" : lowered // kVK_ANSI_4 + case "%": return eventKeyCode == 23 ? "5" : lowered // kVK_ANSI_5 + case "^": return eventKeyCode == 22 ? "6" : lowered // kVK_ANSI_6 + case "&": return eventKeyCode == 26 ? "7" : lowered // kVK_ANSI_7 + case "*": return eventKeyCode == 28 ? "8" : lowered // kVK_ANSI_8 + case "(": return eventKeyCode == 25 ? "9" : lowered // kVK_ANSI_9 + case ")": return eventKeyCode == 29 ? "0" : lowered // kVK_ANSI_0 + default: return lowered } + } - if shouldApplyWindowGlass { - // Apply liquid glass effect to the window with tint from settings - let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity) - WindowGlassEffect.apply(to: window, tintColor: tintColor) - } else { - WindowGlassEffect.remove(from: window) + private func keyCodeForShortcutKey(_ key: String) -> UInt16? { + // Matches macOS ANSI key codes. This is intentionally limited to keys we + // support in StoredShortcut/ghostty trigger translation. + switch key { + case "a": return 0 // kVK_ANSI_A + case "s": return 1 // kVK_ANSI_S + case "d": return 2 // kVK_ANSI_D + case "f": return 3 // kVK_ANSI_F + case "h": return 4 // kVK_ANSI_H + case "g": return 5 // kVK_ANSI_G + case "z": return 6 // kVK_ANSI_Z + case "x": return 7 // kVK_ANSI_X + case "c": return 8 // kVK_ANSI_C + case "v": return 9 // kVK_ANSI_V + case "b": return 11 // kVK_ANSI_B + case "q": return 12 // kVK_ANSI_Q + case "w": return 13 // kVK_ANSI_W + case "e": return 14 // kVK_ANSI_E + case "r": return 15 // kVK_ANSI_R + case "y": return 16 // kVK_ANSI_Y + case "t": return 17 // kVK_ANSI_T + case "1": return 18 // kVK_ANSI_1 + case "2": return 19 // kVK_ANSI_2 + case "3": return 20 // kVK_ANSI_3 + case "4": return 21 // kVK_ANSI_4 + case "6": return 22 // kVK_ANSI_6 + case "5": return 23 // kVK_ANSI_5 + case "=": return 24 // kVK_ANSI_Equal + case "9": return 25 // kVK_ANSI_9 + case "7": return 26 // kVK_ANSI_7 + case "-": return 27 // kVK_ANSI_Minus + case "8": return 28 // kVK_ANSI_8 + case "0": return 29 // kVK_ANSI_0 + case "]": return 30 // kVK_ANSI_RightBracket + case "o": return 31 // kVK_ANSI_O + case "u": return 32 // kVK_ANSI_U + case "[": return 33 // kVK_ANSI_LeftBracket + case "i": return 34 // kVK_ANSI_I + case "p": return 35 // kVK_ANSI_P + case "l": return 37 // kVK_ANSI_L + case "j": return 38 // kVK_ANSI_J + case "'": return 39 // kVK_ANSI_Quote + case "k": return 40 // kVK_ANSI_K + case ";": return 41 // kVK_ANSI_Semicolon + case "\\": return 42 // kVK_ANSI_Backslash + case ",": return 43 // kVK_ANSI_Comma + case "/": return 44 // kVK_ANSI_Slash + case "n": return 45 // kVK_ANSI_N + case "m": return 46 // kVK_ANSI_M + case ".": return 47 // kVK_ANSI_Period + case "`": return 50 // kVK_ANSI_Grave + case "\r": return 36 // kVK_Return + case "←": return 123 // kVK_LeftArrow + case "→": return 124 // kVK_RightArrow + case "↓": return 125 // kVK_DownArrow + case "↑": return 126 // kVK_UpArrow + default: + return nil } - AppDelegate.shared?.attachUpdateAccessory(to: window) - AppDelegate.shared?.applyWindowDecorations(to: window) - AppDelegate.shared?.registerMainWindow( - window, - windowId: windowId, - tabManager: tabManager, - sidebarState: sidebarState, - sidebarSelectionState: sidebarSelectionState - ) - installFileDropOverlay(on: window, tabManager: tabManager) - - return nextTitlebarPadding } -} - -@MainActor -final class MenuBarExtraController: NSObject, NSMenuDelegate { - private let statusItem: NSStatusItem - private let menu = NSMenu(title: "Programa") - private let notificationStore: TerminalNotificationStore - private let onShowNotifications: () -> Void - private let onOpenNotification: (TerminalNotification) -> Void - private let onJumpToLatestUnread: () -> Void - private let onCheckForUpdates: () -> Void - private let onOpenPreferences: () -> Void - private let onQuitApp: () -> Void - private var notificationsCancellable: AnyCancellable? - private let buildHintTitle: String? - - private let stateHintItem = NSMenuItem(title: String(localized: "statusMenu.noUnread", defaultValue: "No unread notifications"), action: nil, keyEquivalent: "") - private let buildHintItem = NSMenuItem(title: "", action: nil, keyEquivalent: "") - private let notificationListSeparator = NSMenuItem.separator() - private let notificationSectionSeparator = NSMenuItem.separator() - private let showNotificationsItem = NSMenuItem(title: String(localized: "statusMenu.showNotifications", defaultValue: "Show Notifications"), action: nil, keyEquivalent: "") - private let jumpToUnreadItem = NSMenuItem(title: String(localized: "statusMenu.jumpToLatestUnread", defaultValue: "Jump to Latest Unread"), action: nil, keyEquivalent: "") - private let markAllReadItem = NSMenuItem(title: String(localized: "statusMenu.markAllRead", defaultValue: "Mark All Read"), action: nil, keyEquivalent: "") - private let clearAllItem = NSMenuItem(title: String(localized: "statusMenu.clearAll", defaultValue: "Clear All"), action: nil, keyEquivalent: "") - private let checkForUpdatesItem = NSMenuItem(title: String(localized: "menu.checkForUpdates", defaultValue: "Check for Updates…"), action: nil, keyEquivalent: "") - private let preferencesItem = NSMenuItem(title: String(localized: "menu.preferences", defaultValue: "Preferences…"), action: nil, keyEquivalent: "") - private let quitItem = NSMenuItem(title: String(localized: "menu.quitPrograma", defaultValue: "Quit Programa"), action: nil, keyEquivalent: "") - - private var notificationItems: [NSMenuItem] = [] - private let maxInlineNotificationItems = 6 - - init( - notificationStore: TerminalNotificationStore, - onShowNotifications: @escaping () -> Void, - onOpenNotification: @escaping (TerminalNotification) -> Void, - onJumpToLatestUnread: @escaping () -> Void, - onCheckForUpdates: @escaping () -> Void, - onOpenPreferences: @escaping () -> Void, - onQuitApp: @escaping () -> Void - ) { - self.notificationStore = notificationStore - self.onShowNotifications = onShowNotifications - self.onOpenNotification = onOpenNotification - self.onJumpToLatestUnread = onJumpToLatestUnread - self.onCheckForUpdates = onCheckForUpdates - self.onOpenPreferences = onOpenPreferences - self.onQuitApp = onQuitApp - self.buildHintTitle = MenuBarBuildHintFormatter.menuTitle() - self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - super.init() - - buildMenu() - statusItem.menu = menu - if let button = statusItem.button { - button.imagePosition = .imageOnly - button.imageScaling = .scaleProportionallyDown - button.image = MenuBarIconRenderer.makeImage(unreadCount: 0) - button.toolTip = "Programa" + private func digitForNumberKeyCode(_ keyCode: UInt16) -> Int? { + switch keyCode { + case 18: return 1 // kVK_ANSI_1 + case 19: return 2 // kVK_ANSI_2 + case 20: return 3 // kVK_ANSI_3 + case 21: return 4 // kVK_ANSI_4 + case 23: return 5 // kVK_ANSI_5 + case 22: return 6 // kVK_ANSI_6 + case 26: return 7 // kVK_ANSI_7 + case 28: return 8 // kVK_ANSI_8 + case 25: return 9 // kVK_ANSI_9 + default: + return nil } - - notificationsCancellable = notificationStore.$notifications - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refreshUI() - } - - refreshUI() } - private func buildMenu() { - menu.autoenablesItems = false - menu.delegate = self - - stateHintItem.isEnabled = false - menu.addItem(stateHintItem) - if let buildHintTitle { - buildHintItem.title = buildHintTitle - buildHintItem.isEnabled = false - menu.addItem(buildHintItem) - } - - menu.addItem(notificationListSeparator) - notificationSectionSeparator.isHidden = true - menu.addItem(notificationSectionSeparator) - - showNotificationsItem.target = self - showNotificationsItem.action = #selector(showNotificationsAction) - menu.addItem(showNotificationsItem) - - jumpToUnreadItem.target = self - jumpToUnreadItem.action = #selector(jumpToUnreadAction) - menu.addItem(jumpToUnreadItem) - - markAllReadItem.target = self - markAllReadItem.action = #selector(markAllReadAction) - menu.addItem(markAllReadItem) - - clearAllItem.target = self - clearAllItem.action = #selector(clearAllAction) - menu.addItem(clearAllItem) - - menu.addItem(.separator()) - - checkForUpdatesItem.target = self - checkForUpdatesItem.action = #selector(checkForUpdatesAction) - menu.addItem(checkForUpdatesItem) - - preferencesItem.target = self - preferencesItem.action = #selector(preferencesAction) - menu.addItem(preferencesItem) - - menu.addItem(.separator()) - - quitItem.target = self - quitItem.action = #selector(quitAction) - menu.addItem(quitItem) + /// Match arrow key shortcuts using keyCode + /// Arrow keys include .numericPad and .function in their modifierFlags, so strip those before comparing. + private func matchArrowShortcut(event: NSEvent, stroke: ShortcutStroke, keyCode: UInt16) -> Bool { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function]) + return event.keyCode == keyCode && flags == stroke.modifierFlags } - func menuWillOpen(_ menu: NSMenu) { - refreshUI() + /// Match tab key shortcuts using keyCode 48 + private func matchTabShortcut(event: NSEvent, stroke: ShortcutStroke) -> Bool { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + return event.keyCode == 48 && flags == stroke.modifierFlags } - func refreshForDebugControls() { - refreshUI() + private func matchTabShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { + guard !shortcut.hasChord else { return false } + return matchTabShortcut(event: event, stroke: shortcut.firstStroke) } - func removeFromMenuBar() { - notificationsCancellable?.cancel() - notificationsCancellable = nil - statusItem.menu = nil - NSStatusBar.system.removeStatusItem(statusItem) + /// Directional shortcuts default to arrow keys, but the shortcut recorder only supports letter/number keys. + /// Support both so users can customize pane navigation (e.g. Cmd+Ctrl+H/J/K/L). + private func matchDirectionalShortcut( + event: NSEvent, + stroke: ShortcutStroke, + arrowGlyph: String, + arrowKeyCode: UInt16 + ) -> Bool { + if stroke.key == arrowGlyph { + return matchArrowShortcut(event: event, stroke: stroke, keyCode: arrowKeyCode) + } + return matchShortcutStroke(event: event, stroke: stroke) } - private func refreshUI() { - let snapshot = NotificationMenuSnapshotBuilder.make( - notifications: notificationStore.notifications, - maxInlineNotificationItems: maxInlineNotificationItems + private func matchDirectionalShortcut( + event: NSEvent, + shortcut: StoredShortcut, + arrowGlyph: String, + arrowKeyCode: UInt16 + ) -> Bool { + guard !shortcut.hasChord else { return false } + return matchDirectionalShortcut( + event: event, + stroke: shortcut.firstStroke, + arrowGlyph: arrowGlyph, + arrowKeyCode: arrowKeyCode ) - let actualUnreadCount = snapshot.unreadCount - - let displayedUnreadCount: Int -#if DEBUG - displayedUnreadCount = MenuBarIconDebugSettings.displayedUnreadCount(actualUnreadCount: actualUnreadCount) -#else - displayedUnreadCount = actualUnreadCount -#endif + } - stateHintItem.title = snapshot.stateHintTitle + func validateMenuItem(_ item: NSMenuItem) -> Bool { + updateController.validateMenuItem(item) + } - applyShortcut(KeyboardShortcutSettings.shortcut(for: .showNotifications), to: showNotificationsItem) - applyShortcut(KeyboardShortcutSettings.shortcut(for: .jumpToUnread), to: jumpToUnreadItem) - jumpToUnreadItem.isEnabled = snapshot.hasUnreadNotifications - markAllReadItem.isEnabled = snapshot.hasUnreadNotifications - clearAllItem.isEnabled = snapshot.hasNotifications + private func configureUserNotifications() { + let actions = [ + UNNotificationAction( + identifier: TerminalNotificationStore.actionShowIdentifier, + title: "Show" + ) + ] - rebuildInlineNotificationItems(recentNotifications: snapshot.recentNotifications) + let category = UNNotificationCategory( + identifier: TerminalNotificationStore.categoryIdentifier, + actions: actions, + intentIdentifiers: [], + options: [.customDismissAction] + ) - if let button = statusItem.button { - button.image = MenuBarIconRenderer.makeImage(unreadCount: displayedUnreadCount) - button.toolTip = displayedUnreadCount == 0 - ? "Programa" - : displayedUnreadCount == 1 - ? "Programa: " + String(localized: "statusMenu.tooltip.unread.one", defaultValue: "1 unread notification") - : "Programa: " + String(localized: "statusMenu.tooltip.unread.other", defaultValue: "\(displayedUnreadCount) unread notifications") - } + let center = UNUserNotificationCenter.current() + center.setNotificationCategories([category]) + center.delegate = self } - private func applyShortcut(_ shortcut: StoredShortcut, to item: NSMenuItem) { - guard let keyEquivalent = shortcut.menuItemKeyEquivalent else { - item.keyEquivalent = "" - item.keyEquivalentModifierMask = [] - return - } - item.keyEquivalent = keyEquivalent - item.keyEquivalentModifierMask = shortcut.modifierFlags + private func disableNativeTabbingShortcut() { + guard let menu = NSApp.mainMenu else { return } + disableMenuItemShortcut(in: menu, action: #selector(NSWindow.toggleTabBar(_:))) } - private func rebuildInlineNotificationItems(recentNotifications: [TerminalNotification]) { - for item in notificationItems { - menu.removeItem(item) - } - notificationItems.removeAll(keepingCapacity: true) - - notificationListSeparator.isHidden = recentNotifications.isEmpty - notificationSectionSeparator.isHidden = recentNotifications.isEmpty - guard !recentNotifications.isEmpty else { return } - - let insertionIndex = menu.index(of: showNotificationsItem) - guard insertionIndex >= 0 else { return } - - for (offset, notification) in recentNotifications.enumerated() { - let tabTitle = AppDelegate.shared?.tabTitle(for: notification.tabId) - let item = makeNotificationItem(notification: notification, tabTitle: tabTitle) - menu.insertItem(item, at: insertionIndex + offset) - notificationItems.append(item) + private func disableMenuItemShortcut(in menu: NSMenu, action: Selector) { + for item in menu.items { + if item.action == action { + item.keyEquivalent = "" + item.keyEquivalentModifierMask = [] + item.isEnabled = false + } + if let submenu = item.submenu { + disableMenuItemShortcut(in: submenu, action: action) + } } } - private func makeNotificationItem(notification: TerminalNotification, tabTitle: String?) -> NSMenuItem { - let item = NSMenuItem(title: "", action: #selector(openNotificationItemAction(_:)), keyEquivalent: "") - item.target = self - item.attributedTitle = MenuBarNotificationLineFormatter.attributedTitle(notification: notification, tabTitle: tabTitle) - item.toolTip = MenuBarNotificationLineFormatter.tooltip(notification: notification, tabTitle: tabTitle) - item.representedObject = NotificationMenuItemPayload(notification: notification) - return item + private func ensureApplicationIcon() { + // Manual light/dark override removed; the icon always tracks system + // appearance. Clear any stale pre-removal override so the dock-tile + // plugin (which reads the raw key cross-process) falls back too. + UserDefaults.standard.removeObject(forKey: "appIconMode") + AppIconAppearanceObserver.shared.startObserving() } - @objc private func openNotificationItemAction(_ sender: NSMenuItem) { - guard let payload = sender.representedObject as? NotificationMenuItemPayload else { return } - onOpenNotification(payload.notification) - } + private func scheduleLaunchServicesBundleRegistration( + bundleURL: URL = Bundle.main.bundleURL.standardizedFileURL, + scheduler: @escaping (@escaping @Sendable () -> Void) -> Void = AppDelegate.enqueueLaunchServicesRegistrationWork, + register: @escaping (CFURL) -> OSStatus = { url in + LSRegisterURL(url, true) + }, + breadcrumb: @escaping (_ message: String, _ data: [String: Any]) -> Void = { _, _ in } + ) { + let normalizedURL = bundleURL.standardizedFileURL + breadcrumb("launchservices.register.schedule", [ + "bundlePath": normalizedURL.path + ]) - @objc private func showNotificationsAction() { - onShowNotifications() - } + scheduler { + let startedAt = CFAbsoluteTimeGetCurrent() + let registerStatus = register(normalizedURL as CFURL) + let durationMs = Int(((CFAbsoluteTimeGetCurrent() - startedAt) * 1000).rounded()) - @objc private func jumpToUnreadAction() { - onJumpToLatestUnread() - } + breadcrumb("launchservices.register.complete", [ + "bundlePath": normalizedURL.path, + "status": Int(registerStatus), + "durationMs": durationMs + ]) - @objc private func markAllReadAction() { - notificationStore.markAllRead() + if registerStatus != noErr { + NSLog("LaunchServices registration failed (status: \(registerStatus)) for \(normalizedURL.path)") + } + } } - @objc private func clearAllAction() { - notificationStore.clearAll() +#if DEBUG + func scheduleLaunchServicesBundleRegistrationForTesting( + bundleURL: URL, + scheduler: @escaping (@escaping @Sendable () -> Void) -> Void, + register: @escaping (CFURL) -> OSStatus, + breadcrumb: @escaping (_ message: String, _ data: [String: Any]) -> Void = { _, _ in } + ) { + scheduleLaunchServicesBundleRegistration( + bundleURL: bundleURL, + scheduler: scheduler, + register: register, + breadcrumb: breadcrumb + ) } +#endif - @objc private func checkForUpdatesAction() { - onCheckForUpdates() - } + private func enforceSingleInstance() { + guard let bundleId = Bundle.main.bundleIdentifier else { return } + let currentPid = ProcessInfo.processInfo.processIdentifier - @objc private func preferencesAction() { - onOpenPreferences() + for app in NSRunningApplication.runningApplications(withBundleIdentifier: bundleId) { + guard app.processIdentifier != currentPid else { continue } + app.terminate() + if !app.isTerminated { + _ = app.forceTerminate() + } + } } - @objc private func quitAction() { - onQuitApp() - } -} + private func observeDuplicateLaunches() { + guard let bundleId = Bundle.main.bundleIdentifier else { return } + let embeddedCLIURL = Bundle.main.bundleURL + .appendingPathComponent("Contents/Resources/bin/programa", isDirectory: false) + .standardizedFileURL + .resolvingSymlinksInPath() + let currentPid = ProcessInfo.processInfo.processIdentifier -private final class NotificationMenuItemPayload: NSObject { - let notification: TerminalNotification + workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didLaunchApplicationNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard self != nil else { return } + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return } + guard app.bundleIdentifier == bundleId, app.processIdentifier != currentPid else { return } + if let executableURL = app.executableURL? + .standardizedFileURL + .resolvingSymlinksInPath(), + executableURL == embeddedCLIURL { + return + } - init(notification: TerminalNotification) { - self.notification = notification - super.init() + app.terminate() + if !app.isTerminated { + _ = app.forceTerminate() + } + NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + } } -} -struct NotificationMenuSnapshot { - let unreadCount: Int - let hasNotifications: Bool - let recentNotifications: [TerminalNotification] - - var hasUnreadNotifications: Bool { - unreadCount > 0 + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + handleNotificationResponse(response) + completionHandler() } - var stateHintTitle: String { - NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: unreadCount) + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + var options: UNNotificationPresentationOptions = [.banner, .list] + if notification.request.content.sound != nil { + options.insert(.sound) + } + completionHandler(options) } -} -enum NotificationMenuSnapshotBuilder { - static let defaultInlineNotificationLimit = 6 + private func handleNotificationResponse(_ response: UNNotificationResponse) { + guard let tabIdString = response.notification.request.content.userInfo["tabId"] as? String, + let tabId = UUID(uuidString: tabIdString) else { + return + } + let surfaceId: UUID? = { + guard let surfaceIdString = response.notification.request.content.userInfo["surfaceId"] as? String else { + return nil + } + return UUID(uuidString: surfaceIdString) + }() - static func make( - notifications: [TerminalNotification], - maxInlineNotificationItems: Int = defaultInlineNotificationLimit - ) -> NotificationMenuSnapshot { - let unreadCount = notifications.reduce(into: 0) { count, notification in - if !notification.isRead { - count += 1 + switch response.actionIdentifier { + case UNNotificationDefaultActionIdentifier, TerminalNotificationStore.actionShowIdentifier: + let notificationId: UUID? = { + if let id = UUID(uuidString: response.notification.request.identifier) { + return id + } + if let idString = response.notification.request.content.userInfo["notificationId"] as? String, + let id = UUID(uuidString: idString) { + return id + } + return nil + }() + DispatchQueue.main.async { + _ = self.openNotification(tabId: tabId, surfaceId: surfaceId, notificationId: notificationId) + } + case UNNotificationDismissActionIdentifier: + DispatchQueue.main.async { + if let notificationId = UUID(uuidString: response.notification.request.identifier) { + self.notificationStore?.markRead(id: notificationId) + } else if let notificationIdString = response.notification.request.content.userInfo["notificationId"] as? String, + let notificationId = UUID(uuidString: notificationIdString) { + self.notificationStore?.markRead(id: notificationId) + } } - } - - let inlineLimit = max(0, maxInlineNotificationItems) - return NotificationMenuSnapshot( - unreadCount: unreadCount, - hasNotifications: !notifications.isEmpty, - recentNotifications: Array(notifications.prefix(inlineLimit)) - ) - } - - static func stateHintTitle(unreadCount: Int) -> String { - switch unreadCount { - case 0: - return String(localized: "statusMenu.noUnread", defaultValue: "No unread notifications") - case 1: - return String(localized: "statusMenu.unreadCount.one", defaultValue: "1 unread notification") default: - return String(localized: "statusMenu.unreadCount.other", defaultValue: "\(unreadCount) unread notifications") + break } } -} -enum MenuBarBadgeLabelFormatter { - static func badgeText(for unreadCount: Int) -> String? { - guard unreadCount > 0 else { return nil } - if unreadCount > 9 { - return "9+" + 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 } + self.setActiveMainWindow(window) } - return String(unreadCount) } -} -enum MenuBarNotificationLineFormatter { - static let defaultMaxMenuTextWidth: CGFloat = 280 - static let defaultMaxMenuTextLines = 3 - - static func plainTitle(notification: TerminalNotification, tabTitle: String?) -> String { - let dot = notification.isRead ? " " : "● " - let timeText = notification.createdAt.formatted(date: .omitted, time: .shortened) - var lines: [String] = [] - lines.append("\(dot)\(notification.title) \(timeText)") + private func installBrowserAddressBarFocusObservers() { + guard browserAddressBarFocusObserver == nil, browserAddressBarBlurObserver == nil else { return } - let detail = notification.body.isEmpty ? notification.subtitle : notification.body - if !detail.isEmpty { - lines.append(detail) + browserAddressBarFocusObserver = NotificationCenter.default.addObserver( + 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.browserPanel(for: panelId)?.beginSuppressWebViewFocusForAddressBar() + self.browserAddressBarFocusedPanelId = panelId + self.stopBrowserOmnibarSelectionRepeat() +#if DEBUG + dlog("addressBar FOCUS panelId=\(panelId.uuidString.prefix(8))") +#endif } - if let tabTitle, !tabTitle.isEmpty { - lines.append(tabTitle) + browserAddressBarBlurObserver = NotificationCenter.default.addObserver( + forName: .browserDidBlurAddressBar, + 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() +#if DEBUG + dlog("addressBar BLUR panelId=\(panelId.uuidString.prefix(8))") +#endif + } } - - return lines.joined(separator: "\n") } - static func menuTitle( - notification: TerminalNotification, - tabTitle: String?, - maxWidth: CGFloat = defaultMaxMenuTextWidth, - maxLines: Int = defaultMaxMenuTextLines - ) -> String { - let base = plainTitle(notification: notification, tabTitle: tabTitle) - return wrappedAndTruncated(base, maxWidth: maxWidth, maxLines: maxLines) - } - - static func attributedTitle(notification: TerminalNotification, tabTitle: String?) -> NSAttributedString { - let paragraph = NSMutableParagraphStyle() - paragraph.lineBreakMode = .byWordWrapping - return NSAttributedString( - string: menuTitle(notification: notification, tabTitle: tabTitle), - attributes: [ - .font: NSFont.menuFont(ofSize: NSFont.systemFontSize), - .foregroundColor: NSColor.labelColor, - .paragraphStyle: paragraph, - ] - ) + private func browserPanel(for panelId: UUID) -> BrowserPanel? { + return tabManager?.selectedWorkspace?.browserPanel(for: panelId) } - static func tooltip(notification: TerminalNotification, tabTitle: String?) -> String { - plainTitle(notification: notification, tabTitle: tabTitle) + fileprivate func browserFindBarIsVisible(for webView: ProgramaWebView) -> Bool { + browserPanelOwning(webView)?.searchState != nil } - private static func wrappedAndTruncated(_ text: String, maxWidth: CGFloat, maxLines: Int) -> String { - let width = max(60, maxWidth) - let lines = max(1, maxLines) - let font = NSFont.menuFont(ofSize: NSFont.systemFontSize) - let wrapped = wrappedLines(for: text, maxWidth: width, font: font) - guard wrapped.count > lines else { return wrapped.joined(separator: "\n") } - - var clipped = Array(wrapped.prefix(lines)) - clipped[lines - 1] = truncateLine(clipped[lines - 1], maxWidth: width, font: font) - return clipped.joined(separator: "\n") + private func shouldLetFocusedBrowserOwnFindShortcut(_ event: NSEvent) -> Bool { + let shortcutWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow + let shortcutResponder = shortcutWindow?.firstResponder + let owningWebView = tabManager?.focusedBrowserPanel?.webView as? ProgramaWebView + guard let owningWebView else { return false } + return shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst( + event, + responder: shortcutResponder, + owningWebView: owningWebView + ) } - private static func wrappedLines(for text: String, maxWidth: CGFloat, font: NSFont) -> [String] { - let storage = NSTextStorage(string: text, attributes: [.font: font]) - let layout = NSLayoutManager() - let container = NSTextContainer(size: NSSize(width: maxWidth, height: .greatestFiniteMagnitude)) - container.lineFragmentPadding = 0 - container.lineBreakMode = .byWordWrapping - layout.addTextContainer(container) - storage.addLayoutManager(layout) - _ = layout.glyphRange(for: container) - - let fullText = text as NSString - var rows: [String] = [] - var glyphIndex = 0 - while glyphIndex < layout.numberOfGlyphs { - var glyphRange = NSRange() - layout.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: &glyphRange) - if glyphRange.length == 0 { break } + private func browserPanelOwning(_ webView: ProgramaWebView) -> BrowserPanel? { + var candidateManagers: [TabManager] = [] + var seenManagers = Set() - let charRange = layout.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) - let row = fullText.substring(with: charRange).trimmingCharacters(in: .newlines) - rows.append(row) - glyphIndex = NSMaxRange(glyphRange) + func appendCandidate(_ manager: TabManager?) { + guard let manager else { return } + let identifier = ObjectIdentifier(manager) + guard seenManagers.insert(identifier).inserted else { return } + candidateManagers.append(manager) } - if rows.isEmpty { - return [text] + if let window = webView.window, + let context = contextForMainWindow(window) { + appendCandidate(context.tabManager) } - return rows - } - - private static func truncateLine(_ line: String, maxWidth: CGFloat, font: NSFont) -> String { - let ellipsis = "…" - let full = line.trimmingCharacters(in: .whitespacesAndNewlines) - if full.isEmpty { return ellipsis } - - if measuredWidth(full + ellipsis, font: font) <= maxWidth { - return full + ellipsis + appendCandidate(tabManager) + for context in mainWindowContexts.values { + appendCandidate(context.tabManager) } - var chars = Array(full) - while !chars.isEmpty { - chars.removeLast() - let candidateBase = String(chars).trimmingCharacters(in: .whitespacesAndNewlines) - let candidate = (candidateBase.isEmpty ? "" : candidateBase) + ellipsis - if measuredWidth(candidate, font: font) <= maxWidth { - return candidate + for manager in candidateManagers { + if let panel = browserPanelOwning(webView, in: manager) { + return panel } } - return ellipsis - } - - private static func measuredWidth(_ text: String, font: NSFont) -> CGFloat { - (text as NSString).size(withAttributes: [.font: font]).width - } -} - -enum MenuBarBuildHintFormatter { - static func menuTitle( - appName: String = defaultAppName(), - isDebugBuild: Bool = _isDebugAssertConfiguration() - ) -> String? { - guard isDebugBuild else { return nil } - let normalized = appName.trimmingCharacters(in: .whitespacesAndNewlines) - let prefix = "cmux DEV" - guard normalized.hasPrefix(prefix) else { return "Build: DEV" } - - let suffix = String(normalized.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) - if suffix.isEmpty { - return "Build: DEV (untagged)" - } - return "Build Tag: \(suffix)" - } - - private static func defaultAppName() -> String { - let bundle = Bundle.main - if let displayName = bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String, - !displayName.isEmpty { - return displayName - } - if let name = bundle.object(forInfoDictionaryKey: "CFBundleName") as? String, !name.isEmpty { - return name - } - return ProcessInfo.processInfo.processName + return nil } -} - -enum MenuBarExtraSettings { - static let showInMenuBarKey = "showMenuBarExtra" - static let defaultShowInMenuBar = true - static func showsMenuBarExtra(defaults: UserDefaults = .standard) -> Bool { - if defaults.object(forKey: showInMenuBarKey) == nil { - return defaultShowInMenuBar + private func browserPanelOwning(_ webView: ProgramaWebView, in manager: TabManager) -> BrowserPanel? { + for workspace in manager.tabs { + if let panel = workspace.panels.values + .compactMap({ $0 as? BrowserPanel }) + .first(where: { $0.webView === webView }) { + return panel + } } - return defaults.bool(forKey: showInMenuBarKey) + return nil } -} - -struct MenuBarBadgeRenderConfig { - var badgeRect: NSRect - var singleDigitFontSize: CGFloat - var multiDigitFontSize: CGFloat - var singleDigitYOffset: CGFloat - var multiDigitYOffset: CGFloat - var singleDigitXAdjust: CGFloat - var multiDigitXAdjust: CGFloat - var textRectWidthAdjust: CGFloat -} -enum MenuBarIconDebugSettings { - static let previewEnabledKey = "menubarDebugPreviewEnabled" - static let previewCountKey = "menubarDebugPreviewCount" - static let badgeRectXKey = "menubarDebugBadgeRectX" - static let badgeRectYKey = "menubarDebugBadgeRectY" - static let badgeRectWidthKey = "menubarDebugBadgeRectWidth" - static let badgeRectHeightKey = "menubarDebugBadgeRectHeight" - static let singleDigitFontSizeKey = "menubarDebugSingleDigitFontSize" - static let multiDigitFontSizeKey = "menubarDebugMultiDigitFontSize" - static let singleDigitYOffsetKey = "menubarDebugSingleDigitYOffset" - static let multiDigitYOffsetKey = "menubarDebugMultiDigitYOffset" - static let singleDigitXAdjustKey = "menubarDebugSingleDigitXAdjust" - static let legacySingleDigitXAdjustKey = "menubarDebugTextRectXAdjust" - static let multiDigitXAdjustKey = "menubarDebugMultiDigitXAdjust" - static let textRectWidthAdjustKey = "menubarDebugTextRectWidthAdjust" - - static let defaultBadgeRect = NSRect(x: 5.38, y: 6.43, width: 10.75, height: 11.58) - static let defaultSingleDigitFontSize: CGFloat = 6.7 - static let defaultMultiDigitFontSize: CGFloat = 6.7 - static let defaultSingleDigitYOffset: CGFloat = 0.6 - static let defaultMultiDigitYOffset: CGFloat = 0.6 - static let defaultSingleDigitXAdjust: CGFloat = -1.1 - static let defaultMultiDigitXAdjust: CGFloat = 2.42 - static let defaultTextRectWidthAdjust: CGFloat = 1.8 - - static func displayedUnreadCount(actualUnreadCount: Int, defaults: UserDefaults = .standard) -> Int { - guard defaults.bool(forKey: previewEnabledKey) else { return actualUnreadCount } - let value = defaults.integer(forKey: previewCountKey) - return max(0, min(value, 99)) - } - - static func badgeRenderConfig(defaults: UserDefaults = .standard) -> MenuBarBadgeRenderConfig { - let x = value(defaults, key: badgeRectXKey, fallback: defaultBadgeRect.origin.x, range: 0...20) - let y = value(defaults, key: badgeRectYKey, fallback: defaultBadgeRect.origin.y, range: 0...20) - let width = value(defaults, key: badgeRectWidthKey, fallback: defaultBadgeRect.width, range: 4...14) - let height = value(defaults, key: badgeRectHeightKey, fallback: defaultBadgeRect.height, range: 4...14) - let singleFont = value(defaults, key: singleDigitFontSizeKey, fallback: defaultSingleDigitFontSize, range: 6...14) - let multiFont = value(defaults, key: multiDigitFontSizeKey, fallback: defaultMultiDigitFontSize, range: 6...14) - let singleY = value(defaults, key: singleDigitYOffsetKey, fallback: defaultSingleDigitYOffset, range: -3...4) - let multiY = value(defaults, key: multiDigitYOffsetKey, fallback: defaultMultiDigitYOffset, range: -3...4) - let singleX = value( - defaults, - key: singleDigitXAdjustKey, - legacyKey: legacySingleDigitXAdjustKey, - fallback: defaultSingleDigitXAdjust, - range: -4...4 - ) - let multiX = value(defaults, key: multiDigitXAdjustKey, fallback: defaultMultiDigitXAdjust, range: -4...4) - let widthAdjust = value(defaults, key: textRectWidthAdjustKey, fallback: defaultTextRectWidthAdjust, range: -3...5) - - return MenuBarBadgeRenderConfig( - badgeRect: NSRect(x: x, y: y, width: width, height: height), - singleDigitFontSize: singleFont, - multiDigitFontSize: multiFont, - singleDigitYOffset: singleY, - multiDigitYOffset: multiY, - singleDigitXAdjust: singleX, - multiDigitXAdjust: multiX, - textRectWidthAdjust: widthAdjust + private func setActiveMainWindow(_ window: NSWindow) { + guard let context = contextForMainTerminalWindow(window) else { return } +#if DEBUG + let beforeManagerToken = debugManagerToken(tabManager) +#endif + tabManager = context.tabManager + sidebarState = context.sidebarState + sidebarSelectionState = context.sidebarSelectionState + TerminalController.shared.setActiveTabManager(context.tabManager) +#if DEBUG + dlog( + "mainWindow.active window={\(debugWindowToken(window))} context={\(debugContextToken(context))} beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) \(debugShortcutRouteSnapshot())" ) +#endif } - static func copyPayload(defaults: UserDefaults = .standard) -> String { - let config = badgeRenderConfig(defaults: defaults) - let previewEnabled = defaults.bool(forKey: previewEnabledKey) - let previewCount = max(0, min(defaults.integer(forKey: previewCountKey), 99)) - return """ - menubarDebugPreviewEnabled=\(previewEnabled) - menubarDebugPreviewCount=\(previewCount) - menubarDebugBadgeRectX=\(String(format: "%.2f", config.badgeRect.origin.x)) - menubarDebugBadgeRectY=\(String(format: "%.2f", config.badgeRect.origin.y)) - menubarDebugBadgeRectWidth=\(String(format: "%.2f", config.badgeRect.width)) - menubarDebugBadgeRectHeight=\(String(format: "%.2f", config.badgeRect.height)) - menubarDebugSingleDigitFontSize=\(String(format: "%.2f", config.singleDigitFontSize)) - menubarDebugMultiDigitFontSize=\(String(format: "%.2f", config.multiDigitFontSize)) - menubarDebugSingleDigitYOffset=\(String(format: "%.2f", config.singleDigitYOffset)) - menubarDebugMultiDigitYOffset=\(String(format: "%.2f", config.multiDigitYOffset)) - menubarDebugSingleDigitXAdjust=\(String(format: "%.2f", config.singleDigitXAdjust)) - menubarDebugMultiDigitXAdjust=\(String(format: "%.2f", config.multiDigitXAdjust)) - menubarDebugTextRectWidthAdjust=\(String(format: "%.2f", config.textRectWidthAdjust)) - """ - } - - private static func value( - _ defaults: UserDefaults, - key: String, - legacyKey: String? = nil, - fallback: CGFloat, - range: ClosedRange - ) -> CGFloat { - if let parsed = parse(defaults.object(forKey: key), fallback: fallback, range: range) { - return parsed - } - if let legacyKey, let parsed = parse(defaults.object(forKey: legacyKey), fallback: fallback, range: range) { - return parsed - } - return fallback - } - - private static func parse( - _ object: Any?, - fallback: CGFloat, - range: ClosedRange - ) -> CGFloat? { - guard let number = object as? NSNumber else { - return nil - } - let candidate = CGFloat(number.doubleValue) - guard candidate.isFinite else { return fallback } - return max(range.lowerBound, min(candidate, range.upperBound)) - } -} - -enum MenuBarIconRenderer { - - static func makeImage(unreadCount: Int) -> NSImage { - let badgeText = MenuBarBadgeLabelFormatter.badgeText(for: unreadCount) - let config = MenuBarIconDebugSettings.badgeRenderConfig() - let size = NSSize(width: 18, height: 18) - let image = NSImage(size: size) - image.lockFocus() - defer { image.unlockFocus() } + private func unregisterMainWindow(_ window: NSWindow) { + // Reset cascade point so the next new window appears near the closing + // window's position, matching upstream Ghostty behavior. + let frame = window.frame + lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY) - let glyphRect = NSRect(x: 1.2, y: 1.5, width: 11.6, height: 15.0) - drawGlyph(in: glyphRect) + // Keep geometry available as a fallback even if the full session snapshot + // is removed when the last window closes. + persistWindowGeometry(from: window) + guard let removed = unregisterMainWindowContext(for: window) else { return } + teardownCommandPaletteState(for: removed.windowId) - if let text = badgeText { - drawBadge(text: text, in: config.badgeRect, config: config) + // Avoid stale notifications that can no longer be opened once the owning window is gone. + if let store = notificationStore { + for tab in removed.tabManager.tabs { + store.clearNotifications(forTabId: tab.id) + } } - image.isTemplate = true - return image - } + if tabManager === removed.tabManager { + // Repoint "active" pointers to any remaining main terminal window. + let nextContext: MainWindowContext? = { + if let keyWindow = NSApp.keyWindow, + let ctx = contextForMainTerminalWindow(keyWindow, reindex: false) { + return ctx + } + return mainWindowContexts.values.first + }() - private static func drawGlyph(in rect: NSRect) { - // Match the canonical cmux center-mark path from Icon Center Image Artwork.svg. - let srcMinX: CGFloat = 384.0 - let srcMinY: CGFloat = 255.0 - let srcWidth: CGFloat = 369.0 - let srcHeight: CGFloat = 513.0 + if let nextContext { + tabManager = nextContext.tabManager + sidebarState = nextContext.sidebarState + sidebarSelectionState = nextContext.sidebarSelectionState + TerminalController.shared.setActiveTabManager(nextContext.tabManager) + } else { + tabManager = nil + sidebarState = nil + sidebarSelectionState = nil + TerminalController.shared.setActiveTabManager(nil) + } + } - func map(_ x: CGFloat, _ y: CGFloat) -> NSPoint { - let nx = (x - srcMinX) / srcWidth - let ny = (y - srcMinY) / srcHeight - return NSPoint( - x: rect.minX + nx * rect.width, - y: rect.minY + (1.0 - ny) * rect.height + // During app termination we already persisted a full snapshot (with scrollback) + // in applicationShouldTerminate/applicationWillTerminate. Saving again here would + // overwrite it as windows tear down one-by-one, dropping closed windows and replay. + if Self.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: isTerminatingApp) { + _ = saveSessionSnapshot( + includeScrollback: false, + removeWhenEmpty: Self.shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister( + isTerminatingApp: isTerminatingApp + ) ) } - - let path = NSBezierPath() - path.move(to: map(384.0, 255.0)) - path.line(to: map(753.0, 511.5)) - path.line(to: map(384.0, 768.0)) - path.line(to: map(384.0, 654.0)) - path.line(to: map(582.692, 511.5)) - path.line(to: map(384.0, 369.0)) - path.close() - - NSColor.black.setFill() - path.fill() } - private static func drawBadge(text: String, in rect: NSRect, config: MenuBarBadgeRenderConfig) { - let paragraph = NSMutableParagraphStyle() - paragraph.alignment = .center - let fontSize: CGFloat = text.count > 1 ? config.multiDigitFontSize : config.singleDigitFontSize - let attrs: [NSAttributedString.Key: Any] = [ - .font: NSFont.systemFont(ofSize: fontSize, weight: .bold), - .foregroundColor: NSColor.systemBlue, - .paragraphStyle: paragraph, - ] - let yOffset: CGFloat = text.count > 1 ? config.multiDigitYOffset : config.singleDigitYOffset - let xAdjust: CGFloat = text.count > 1 ? config.multiDigitXAdjust : config.singleDigitXAdjust - let textRect = NSRect( - x: rect.origin.x + xAdjust, - y: rect.origin.y + yOffset, - width: rect.width + config.textRectWidthAdjust, - height: rect.height - ) - (text as NSString).draw(in: textRect, withAttributes: attrs) + private func isMainTerminalWindow(_ window: NSWindow) -> Bool { + if mainWindowContexts[ObjectIdentifier(window)] != nil { + return true + } + guard let raw = window.identifier?.rawValue else { return false } + return raw == "cmux.main" || raw.hasPrefix("cmux.main.") } -} - -#if DEBUG -private var programaFirstResponderGuardCurrentEventOverride: NSEvent? -private var programaFirstResponderGuardHitViewOverride: NSView? -#endif -private var programaFirstResponderGuardCurrentEventContext: NSEvent? -private var programaFirstResponderGuardHitViewContext: NSView? -private var programaFirstResponderGuardContextWindowNumber: Int? -private var programaBrowserReturnForwardingDepth = 0 -private var programaWindowFirstResponderBypassDepth = 0 -private var programaFieldEditorOwningWebViewAssociationKey: UInt8 = 0 - -@discardableResult -func cmuxWithWindowFirstResponderBypass(_ body: () -> T) -> T { - programaWindowFirstResponderBypassDepth += 1 - defer { - programaWindowFirstResponderBypassDepth = max(0, programaWindowFirstResponderBypassDepth - 1) + private func contextContainingTabId(_ tabId: UUID) -> MainWindowContext? { + for context in mainWindowContexts.values { + if context.tabManager.tabs.contains(where: { $0.id == tabId }) { + return context + } + } + return nil } - return body() -} -func programaIsWindowFirstResponderBypassActive() -> Bool { - programaWindowFirstResponderBypassDepth > 0 -} - -private final class ProgramaFieldEditorOwningWebViewBox: NSObject { - weak var webView: ProgramaWebView? - - init(webView: ProgramaWebView?) { - self.webView = webView + /// Returns the `TabManager` that owns `tabId`, if any. + func tabManagerFor(tabId: UUID) -> TabManager? { + contextContainingTabId(tabId)?.tabManager } -} -private extension NSApplication { - @objc func programa_applicationSendEvent(_ event: NSEvent) { -#if DEBUG - let typingTimingStart = event.type == .keyDown ? ProgramaTypingTiming.start() : nil - let phaseTotalStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 - if event.type == .keyDown { - ProgramaTypingTiming.logEventDelay(path: "app.sendEvent", event: event) - } - defer { - if event.type == .keyDown { - let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0 - ProgramaTypingTiming.logBreakdown( - path: "app.sendEvent.phase", - totalMs: totalMs, - event: event, - thresholdMs: 1.0, - parts: [("dispatchMs", totalMs)] - ) - ProgramaTypingTiming.logDuration( - path: "app.sendEvent", - startedAt: typingTimingStart, - event: event - ) - } - } -#endif - programa_applicationSendEvent(event) + private func workspaceForMainActor(tabId: UUID) -> Workspace? { + contextContainingTabId(tabId)?.tabManager.tabs.first(where: { $0.id == tabId }) } -} -private extension AppDelegate { - @objc func handleThemesReloadNotification(_ notification: Notification) { - DispatchQueue.main.async { - GhosttyApp.shared.reloadConfiguration(source: "distributed.cmux.themes") - } + /// Returns the `Workspace` that owns `tabId`, if any. + @MainActor + func workspaceFor(tabId: UUID) -> Workspace? { + workspaceForMainActor(tabId: tabId) } -} - -private extension NSWindow { - @objc func programa_makeFirstResponder(_ responder: NSResponder?) -> Bool { - if programaIsWindowFirstResponderBypassActive() { -#if DEBUG - dlog( - "focus.guard bypassFirstResponder responder=\(String(describing: responder.map { type(of: $0) })) " + - "window=\(ObjectIdentifier(self))" - ) -#endif - return false - } - let currentEvent = Self.programaCurrentEvent(for: self) - let responderWebView = responder.flatMap { - Self.programaOwningWebView(for: $0, in: self, event: currentEvent) - } - var pointerInitiatedWebFocus = false + func closeMainWindowContainingTabId(_ tabId: UUID) { + guard let context = contextContainingTabId(tabId) else { return } + let expectedIdentifier = "cmux.main.\(context.windowId.uuidString)" + let window: NSWindow? = context.window ?? NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) + window?.performClose(nil) + } - if AppDelegate.shared?.shouldBlockFirstResponderChangeWhileCommandPaletteVisible( - window: self, - responder: responder - ) == true { + @discardableResult + func openNotification(tabId: UUID, surfaceId: UUID?, notificationId: UUID?) -> Bool { #if DEBUG - dlog( - "focus.guard commandPaletteBlocked responder=\(String(describing: responder.map { type(of: $0) })) " + - "window=\(ObjectIdentifier(self))" - ) -#endif - return false + let isJumpUnreadUITest = ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" + if isJumpUnreadUITest { + writeJumpUnreadTestData([ + "jumpUnreadOpenCalled": "1", + "jumpUnreadOpenTabId": tabId.uuidString, + "jumpUnreadOpenSurfaceId": surfaceId?.uuidString ?? "", + ]) } - - if let responder, - let webView = responderWebView, - !webView.allowsFirstResponderAcquisitionEffective { - let pointerInitiatedFocus = Self.programaShouldAllowPointerInitiatedWebViewFocus( - window: self, - webView: webView, - event: currentEvent - ) - if pointerInitiatedFocus { - pointerInitiatedWebFocus = true -#if DEBUG - dlog( - "focus.guard allowPointerFirstResponder responder=\(String(describing: type(of: responder))) " + - "window=\(ObjectIdentifier(self)) " + - "web=\(ObjectIdentifier(webView)) " + - "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + - "pointerDepth=\(webView.debugPointerFocusAllowanceDepth) " + - "eventType=\(currentEvent.map { String(describing: $0.type) } ?? "nil")" - ) -#endif - } else { -#if DEBUG - dlog( - "focus.guard blockedFirstResponder responder=\(String(describing: type(of: responder))) " + - "window=\(ObjectIdentifier(self)) " + - "web=\(ObjectIdentifier(webView)) " + - "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + - "pointerDepth=\(webView.debugPointerFocusAllowanceDepth) " + - "eventType=\(currentEvent.map { String(describing: $0.type) } ?? "nil")" - ) #endif - return false - } - } + guard let context = contextContainingTabId(tabId) else { #if DEBUG - if let responder, - let webView = responderWebView { - dlog( - "focus.guard allowFirstResponder responder=\(String(describing: type(of: responder))) " + - "window=\(ObjectIdentifier(self)) " + - "web=\(ObjectIdentifier(webView)) " + - "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + - "pointerDepth=\(webView.debugPointerFocusAllowanceDepth)" + recordMultiWindowNotificationOpenFailureIfNeeded( + tabId: tabId, + surfaceId: surfaceId, + notificationId: notificationId, + reason: "missing_context" ) - } #endif - let result: Bool - if pointerInitiatedWebFocus, let webView = responderWebView { - // `NSWindow.makeFirstResponder` may run before `ProgramaWebView.mouseDown(with:)`. - // Preserve pointer intent during this synchronous responder change. - result = webView.withPointerFocusAllowance { - programa_makeFirstResponder(responder) - } - } else { - result = programa_makeFirstResponder(responder) - } - if result { - if let fieldEditor = responder as? NSTextView, fieldEditor.isFieldEditor { - Self.programaTrackFieldEditor(fieldEditor, owningWebView: responderWebView) - } else if let fieldEditor = self.firstResponder as? NSTextView, fieldEditor.isFieldEditor { - Self.programaTrackFieldEditor(fieldEditor, owningWebView: responderWebView) - } - } - return result - } - - @objc func programa_sendEvent(_ event: NSEvent) { #if DEBUG - let typingTimingStart = event.type == .keyDown ? ProgramaTypingTiming.start() : nil - let phaseTotalStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 - var contextSetupMs: Double = 0 - var focusRepairMs: Double = 0 - var folderGuardMs: Double = 0 - var originalDispatchMs: Double = 0 - let typingTimingExtra: String? = { - guard event.type == .keyDown else { return nil } - let responderWebView = self.firstResponder.flatMap { - Self.programaOwningWebView(for: $0, in: self, event: event) - } - let hitWebView = Self.programaHitViewForEventDispatch(in: self, event: event).flatMap { - Self.programaOwningWebView(for: $0) + if isJumpUnreadUITest { + writeJumpUnreadTestData(["jumpUnreadOpenContextFound": "0", "jumpUnreadOpenUsedFallback": "1"]) } - let firstResponderType = self.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - return "browser=\((responderWebView != nil || hitWebView != nil) ? 1 : 0) firstResponder=\(firstResponderType)" - }() - if event.type == .keyDown { - ProgramaTypingTiming.logEventDelay(path: "window.sendEvent", event: event) - } #endif - // recordTypingActivity must run in all builds so runSessionAutosaveTick - // can honor the typing quiet period in release. - if event.type == .keyDown { - AppDelegate.shared?.recordTypingActivity() - } + let ok = openNotificationFallback(tabId: tabId, surfaceId: surfaceId, notificationId: notificationId) #if DEBUG - defer { - if event.type == .keyDown { - let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0 - ProgramaTypingTiming.logBreakdown( - path: "window.sendEvent.phase", - totalMs: totalMs, - event: event, - thresholdMs: 1.0, - parts: [ - ("contextSetupMs", contextSetupMs), - ("focusRepairMs", focusRepairMs), - ("folderGuardMs", folderGuardMs), - ("originalDispatchMs", originalDispatchMs), - ], - extra: typingTimingExtra - ) - ProgramaTypingTiming.logDuration( - path: "window.sendEvent", - startedAt: typingTimingStart, - event: event, - extra: typingTimingExtra - ) + if isJumpUnreadUITest { + writeJumpUnreadTestData(["jumpUnreadOpenResult": ok ? "1" : "0"]) } - } - let contextSetupStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 #endif - let previousContextEvent = programaFirstResponderGuardCurrentEventContext - let previousContextHitView = programaFirstResponderGuardHitViewContext - let previousContextWindowNumber = programaFirstResponderGuardContextWindowNumber - programaFirstResponderGuardCurrentEventContext = event - programaFirstResponderGuardHitViewContext = Self.programaHitViewForEventDispatch(in: self, event: event) - programaFirstResponderGuardContextWindowNumber = self.windowNumber + return ok + } #if DEBUG - if event.type == .keyDown { - contextSetupMs = (ProcessInfo.processInfo.systemUptime - contextSetupStart) * 1000.0 + if isJumpUnreadUITest { + writeJumpUnreadTestData(["jumpUnreadOpenContextFound": "1", "jumpUnreadOpenUsedFallback": "0"]) } - let focusRepairStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 #endif - if event.type == .keyDown { - AppDelegate.shared?.repairFocusedTerminalKeyboardRoutingIfNeeded( - window: self, - event: event - ) - } + return openNotificationInContext(context, tabId: tabId, surfaceId: surfaceId, notificationId: notificationId) + } + + private func openNotificationInContext(_ context: MainWindowContext, tabId: UUID, surfaceId: UUID?, notificationId: UUID?) -> Bool { + let expectedIdentifier = "cmux.main.\(context.windowId.uuidString)" + let window: NSWindow? = context.window ?? NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) + guard let window else { #if DEBUG - if event.type == .keyDown { - focusRepairMs = (ProcessInfo.processInfo.systemUptime - focusRepairStart) * 1000.0 - } - let folderGuardStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 + recordMultiWindowNotificationOpenFailureIfNeeded( + tabId: tabId, + surfaceId: surfaceId, + notificationId: notificationId, + reason: "missing_window expectedIdentifier=\(expectedIdentifier)" + ) #endif - defer { - programaFirstResponderGuardCurrentEventContext = previousContextEvent - programaFirstResponderGuardHitViewContext = previousContextHitView - programaFirstResponderGuardContextWindowNumber = previousContextWindowNumber + return false } - guard shouldSuppressWindowMoveForFolderDrag(window: self, event: event), - let contentView = self.contentView else { + context.sidebarSelectionState.selection = .tabs + bringToFront(window) + guard context.tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) else { #if DEBUG - if event.type == .keyDown { - folderGuardMs = (ProcessInfo.processInfo.systemUptime - folderGuardStart) * 1000.0 - let originalDispatchStart = ProcessInfo.processInfo.systemUptime - programa_sendEvent(event) - originalDispatchMs = (ProcessInfo.processInfo.systemUptime - originalDispatchStart) * 1000.0 - return + recordMultiWindowNotificationOpenFailureIfNeeded( + tabId: tabId, + surfaceId: surfaceId, + notificationId: notificationId, + reason: "focus_failed" + ) + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData(["jumpUnreadOpenResult": "0"]) } #endif - programa_sendEvent(event) - return + return false } + #if DEBUG - if event.type == .keyDown { - folderGuardMs = (ProcessInfo.processInfo.systemUptime - folderGuardStart) * 1000.0 - } - let originalDispatchStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 + // UI test support: Jump-to-unread asserts that the correct workspace/panel is focused. + // Recording via first-responder can be flaky on the VM, so verify focus via the model. + recordJumpUnreadFocusFromModelIfNeeded( + tabManager: context.tabManager, + tabId: tabId, + expectedSurfaceId: surfaceId + ) #endif - let contentPoint = contentView.convert(event.locationInWindow, from: nil) - let hitView = contentView.hitTest(contentPoint) - let previousMovableState = isMovable - if previousMovableState { - isMovable = false + if let notificationId, let store = notificationStore { + markReadIfFocused( + notificationId: notificationId, + tabId: tabId, + surfaceId: surfaceId, + tabManager: context.tabManager, + notificationStore: store + ) } - #if DEBUG - let hitDesc = hitView.map { String(describing: type(of: $0)) } ?? "nil" - dlog("window.sendEvent.folderDown suppress=1 hit=\(hitDesc) wasMovable=\(previousMovableState)") - #endif - - programa_sendEvent(event) #if DEBUG - if event.type == .keyDown { - originalDispatchMs = (ProcessInfo.processInfo.systemUptime - originalDispatchStart) * 1000.0 + recordMultiWindowNotificationFocusIfNeeded( + windowId: context.windowId, + tabId: tabId, + surfaceId: surfaceId, + sidebarSelection: context.sidebarSelectionState.selection + ) + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData(["jumpUnreadOpenInContext": "1", "jumpUnreadOpenResult": "1"]) } #endif - - if previousMovableState { - isMovable = previousMovableState - } - - #if DEBUG - dlog("window.sendEvent.folderDown restore nowMovable=\(isMovable)") - #endif + return true } - @objc func programa_performKeyEquivalent(with event: NSEvent) -> Bool { -#if DEBUG - let typingTimingStart = ProgramaTypingTiming.start() - defer { - ProgramaTypingTiming.logDuration( - path: "window.performKeyEquivalent", - startedAt: typingTimingStart, - event: event - ) - } - let frType = self.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - dlog("performKeyEquiv: \(Self.keyDescription(event)) fr=\(frType)") -#endif - - // When the terminal surface is the first responder, prevent SwiftUI's - // hosting view from consuming key events via performKeyEquivalent. - // After a browser panel (WKWebView) has been in the responder chain, - // SwiftUI's internal focus system can get into a broken state where it - // intercepts key events in the content view hierarchy, returns true - // (claiming consumption), but never actually fires the action closure. - // - // For non-Command keys: bypass the view hierarchy entirely and send - // directly to the terminal so arrow keys, Ctrl+N/P, etc. reach keyDown. - // - // For Command keys: bypass the SwiftUI content view hierarchy and - // dispatch directly to the main menu. No SwiftUI view should be handling - // Command shortcuts when the terminal is focused — the local event monitor - // (handleCustomShortcut) already handles app-level shortcuts, and anything - // remaining should be menu items. - let firstResponderGhosttyView = cmuxOwningGhosttyView(for: self.firstResponder) - let firstResponderWebView = self.firstResponder.flatMap { - Self.programaOwningWebView(for: $0, in: self, event: event) - } - let firstResponderHasMarkedText = browserResponderHasMarkedText(self.firstResponder) - 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 - // process it. Cmd-based shortcuts should still work during composition since - // Cmd is never part of IME input sequences. - if ghosttyView.hasMarkedText(), !event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command) { - return programa_performKeyEquivalent(with: event) - } - - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if !flags.contains(.command) { - let result = ghosttyView.performKeyEquivalent(with: event) + private func openNotificationFallback(tabId: UUID, surfaceId: UUID?, notificationId: UUID?) -> Bool { + // If the owning window context hasn't been registered yet, fall back to the "active" window. + guard let tabManager else { #if DEBUG - dlog(" → ghostty direct: \(result)") -#endif - return result + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData(["jumpUnreadFallbackFail": "missing_tabManager"]) } - - // Preserve Ghostty's terminal font-size shortcuts (Cmd +/−/0) when - // the terminal is focused. Otherwise our browser menu shortcuts can - // consume the event even when no browser panel is focused. - if shouldRouteTerminalFontZoomShortcutToGhostty( - firstResponderIsGhostty: true, - flags: event.modifierFlags, - chars: event.charactersIgnoringModifiers ?? "", - keyCode: event.keyCode, - literalChars: event.characters - ) { - ghosttyView.keyDown(with: event) -#if DEBUG - dlog("zoom.shortcut stage=window.ghosttyKeyDownDirect event=\(Self.keyDescription(event)) handled=1") #endif - return true - } + return false } - - // Web forms rely on Return/Enter flowing through keyDown. If the original - // NSWindow.performKeyEquivalent consumes Enter first, submission never reaches - // WebKit. Route Return/Enter directly to the current first responder and - // mark handled to avoid the AppKit alert sound path. - if shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: event.keyCode, - firstResponderIsBrowser: firstResponderWebView != nil, - firstResponderHasMarkedText: firstResponderHasMarkedText, - flags: event.modifierFlags - ) { - // Forwarding keyDown can re-enter performKeyEquivalent in WebKit/AppKit internals. - // On re-entry, fall back to normal dispatch to avoid an infinite loop. - if programaBrowserReturnForwardingDepth > 0 { + guard tabManager.tabs.contains(where: { $0.id == tabId }) else { #if DEBUG - dlog(" → browser Return/Enter reentry; using normal dispatch") -#endif - return false + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData(["jumpUnreadFallbackFail": "tab_not_in_active_manager"]) } - programaBrowserReturnForwardingDepth += 1 - defer { programaBrowserReturnForwardingDepth = max(0, programaBrowserReturnForwardingDepth - 1) } -#if DEBUG - dlog(" → browser Return/Enter routed to firstResponder.keyDown") #endif - self.firstResponder?.keyDown(with: event) - return true + return false } - - if let firstResponderWebView, - shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst( - event, - responder: self.firstResponder, - owningWebView: firstResponderWebView - ) { - let result = firstResponderWebView.performKeyEquivalent(with: event) + guard let window = (NSApp.keyWindow ?? NSApp.windows.first(where: { isMainTerminalWindow($0) })) else { #if DEBUG - if result { - dlog(" → browser find command resolved before window menu path") - } else { - dlog(" → browser find command preflight left unclaimed; suppressing replay") + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData(["jumpUnreadFallbackFail": "missing_window"]) } #endif - // The focused web view has already received this Find-family shortcut once. - // Do not fall through into the original NSWindow.performKeyEquivalent path, - // or WebKit can observe the same key equivalent a second time before AppKit - // reaches keyDown/menu fallback. - return true - } - - if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true { -#if DEBUG - dlog(" → consumed by handleBrowserSurfaceKeyEquivalent") -#endif - return true + return false } - // When the terminal is focused, skip the full NSWindow.performKeyEquivalent - // (which walks the SwiftUI content view hierarchy) and dispatch Command-key - // events directly to the main menu. This avoids the broken SwiftUI focus path. - if firstResponderGhosttyView != nil, - shouldRouteCommandEquivalentDirectlyToMainMenu(event), - let mainMenu = NSApp.mainMenu { - let consumedByMenu = mainMenu.performKeyEquivalent(with: event) + sidebarSelectionState?.selection = .tabs + bringToFront(window) + guard tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) else { #if DEBUG - if browserZoomShortcutTraceCandidate( - flags: event.modifierFlags, - chars: event.charactersIgnoringModifiers ?? "", - keyCode: event.keyCode, - literalChars: event.characters - ) { - dlog( - "zoom.shortcut stage=window.mainMenuBypass event=\(Self.keyDescription(event)) " + - "consumed=\(consumedByMenu ? 1 : 0) fr=GhosttyNSView" - ) + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData([ + "jumpUnreadFallbackFail": "focus_failed", + "jumpUnreadOpenResult": "0", + ]) } #endif - if !consumedByMenu { - // Fall through to the original performKeyEquivalent path below. - } else { -#if DEBUG - dlog(" → consumed by mainMenu (bypassed SwiftUI)") -#endif - return true - } + return false } - let result = programa_performKeyEquivalent(with: event) #if DEBUG - if result { dlog(" → consumed by original performKeyEquivalent") } + recordJumpUnreadFocusFromModelIfNeeded( + tabManager: tabManager, + tabId: tabId, + expectedSurfaceId: surfaceId + ) #endif - return result - } - - static func keyDescription(_ event: NSEvent) -> String { - var parts: [String] = [] - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if flags.contains(.command) { parts.append("Cmd") } - if flags.contains(.shift) { parts.append("Shift") } - if flags.contains(.option) { parts.append("Opt") } - if flags.contains(.control) { parts.append("Ctrl") } - let chars = event.charactersIgnoringModifiers ?? "?" - parts.append("'\(chars)'(\(event.keyCode))") - return parts.joined(separator: "+") - } - private static func programaOwningWebView(for responder: NSResponder) -> ProgramaWebView? { - if let webView = responder as? ProgramaWebView { - return webView - } - - if let view = responder as? NSView, - let webView = programaOwningWebView(for: view) { - return webView + if let notificationId, let store = notificationStore { + markReadIfFocused( + notificationId: notificationId, + tabId: tabId, + surfaceId: surfaceId, + tabManager: tabManager, + notificationStore: store + ) } - - // NSTextView.delegate is unsafe-unretained in AppKit. Reading it here while - // a responder chain is tearing down can trap with "unowned reference". - var current = responder.nextResponder - while let next = current { - if let webView = next as? ProgramaWebView { - return webView - } - if let view = next as? NSView, - let webView = programaOwningWebView(for: view) { - return webView - } - current = next.nextResponder +#if DEBUG + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData(["jumpUnreadOpenInFallback": "1", "jumpUnreadOpenResult": "1"]) } - - return nil +#endif + return true } - private static func programaOwningWebView( - for responder: NSResponder, - in window: NSWindow, - event: NSEvent? - ) -> ProgramaWebView? { - // Browser find runs in the portal slot alongside the hosted WKWebView. - // Treat its native field editor chain as browser chrome, not as web content, - // so Cmd+F can move first responder into the find field while web focus is suppressed. - if BrowserWindowPortalRegistry.searchOverlayPanelId(for: responder, in: window) != nil { - return nil - } - - if let webView = programaOwningWebView(for: responder) { - return webView - } +#if DEBUG + private func recordJumpUnreadFocusFromModelIfNeeded( + tabManager: TabManager, + tabId: UUID, + expectedSurfaceId: UUID? + ) { + let env = ProcessInfo.processInfo.environment + guard env["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" else { return } + guard let expectedSurfaceId else { return } - guard let textView = responder as? NSTextView, textView.isFieldEditor else { - return nil - } + // Ensure the expectation is armed even if the view doesn't become first responder. + armJumpUnreadFocusRecord(tabId: tabId, surfaceId: expectedSurfaceId) - if let event, - let hitWebView = programaPointerHitWebView(in: window, event: event) { - programaTrackFieldEditor(textView, owningWebView: hitWebView) - return hitWebView + if tabManager.selectedTabId == tabId, + tabManager.focusedSurfaceId(for: tabId) == expectedSurfaceId { + recordJumpUnreadFocusIfExpected(tabId: tabId, surfaceId: expectedSurfaceId) + return } - return programaTrackedOwningWebView(for: textView) - } + var resolved = false + var observers: [NSObjectProtocol] = [] + var cancellables: [AnyCancellable] = [] - private static func programaOwningWebView(for view: NSView) -> ProgramaWebView? { - if let webView = view as? ProgramaWebView { - return webView + func cleanup() { + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers.removeAll() + cancellables.forEach { $0.cancel() } + cancellables.removeAll() } - var current: NSView? = view.superview - while let candidate = current { - if let webView = candidate as? ProgramaWebView { - return webView - } - if String(describing: type(of: candidate)).contains("WindowBrowserSlotView"), - let portalWebView = programaUniqueBrowserWebView(in: candidate) { - // Portal-hosted browser chrome (for example the Cmd+F overlay) is a - // sibling of the hosted WKWebView inside WindowBrowserSlotView, not a - // descendant of it. Allow native text-entry controls in that slot to - // acquire first responder directly, but keep generic sibling views - // associated with the hosted web view so blocked browser focus policy - // still protects inspector/overlay chrome from stray focus changes. - if view === portalWebView || view.isDescendant(of: portalWebView) { - return portalWebView - } - if programaAllowsPortalSlotTextEntryFocus(view) { - return nil - } - return portalWebView + @MainActor + func finishIfFocused() { + guard !resolved else { return } + guard tabManager.selectedTabId == tabId, + tabManager.focusedSurfaceId(for: tabId) == expectedSurfaceId else { + return } - current = candidate.superview + resolved = true + cleanup() + self.recordJumpUnreadFocusIfExpected(tabId: tabId, surfaceId: expectedSurfaceId) } - return nil - } - - private static func programaAllowsPortalSlotTextEntryFocus(_ view: NSView) -> Bool { - var current: NSView? = view - while let candidate = current { - if let textField = candidate as? NSTextField { - return textField.isEditable || textField.acceptsFirstResponder - } - if let textView = candidate as? NSTextView { - return textView.isEditable || textView.isSelectable || textView.isFieldEditor + observers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidFocusSurface, + object: nil, + queue: .main + ) { note in + guard let surfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, + surfaceId == expectedSurfaceId else { return } + Task { @MainActor in finishIfFocused() } + }) + cancellables.append(tabManager.$selectedTabId.sink { _ in + Task { @MainActor in finishIfFocused() } + }) + if let workspace = tabManager.tabs.first(where: { $0.id == tabId }) { + cancellables.append(workspace.$panels + .map { _ in () } + .sink { _ in + Task { @MainActor in finishIfFocused() } + }) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + Task { @MainActor in + guard !resolved else { return } + cleanup() } - current = candidate.superview } - return false + Task { @MainActor in finishIfFocused() } } +#endif - private static func programaUniqueBrowserWebView(in root: NSView) -> ProgramaWebView? { - var stack: [NSView] = [root] - var found: ProgramaWebView? - while let current = stack.popLast() { - if let webView = current as? ProgramaWebView { - if found == nil { - found = webView - } else if found !== webView { - return nil - } - } - stack.append(contentsOf: current.subviews) + func tabTitle(for tabId: UUID) -> String? { + if let context = contextContainingTabId(tabId) { + return context.tabManager.tabs.first(where: { $0.id == tabId })?.title } - return found + return tabManager?.tabs.first(where: { $0.id == tabId })?.title } - private static func programaCurrentEvent(for window: NSWindow) -> NSEvent? { -#if DEBUG - if let override = programaFirstResponderGuardCurrentEventOverride { - return override + private func bringToFront(_ window: NSWindow) { + if TerminalController.shouldSuppressSocketCommandActivation(), + !TerminalController.socketCommandAllowsInAppFocusMutations() { + return } -#endif - if programaFirstResponderGuardContextWindowNumber == window.windowNumber { - return programaFirstResponderGuardCurrentEventContext + setActiveMainWindow(window) + if window.isMiniaturized { + window.deminiaturize(nil) } - return NSApp.currentEvent + window.makeKeyAndOrderFront(nil) + // Improve reliability across Spaces / when other helper panels are key. + NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) } - private static func programaHitViewInThemeFrame(in window: NSWindow, event: NSEvent) -> NSView? { - guard let contentView = window.contentView, - let themeFrame = contentView.superview else { - return nil + private func markReadIfFocused( + notificationId: UUID, + tabId: UUID, + surfaceId: UUID?, + tabManager: TabManager, + notificationStore: TerminalNotificationStore + ) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + guard tabManager.selectedTabId == tabId else { return } + if let surfaceId { + guard tabManager.focusedSurfaceId(for: tabId) == surfaceId else { return } + } + notificationStore.markRead(id: notificationId) } - let pointInTheme = themeFrame.convert(event.locationInWindow, from: nil) - return themeFrame.hitTest(pointInTheme) } - private static func programaHitViewInContentView(in window: NSWindow, event: NSEvent) -> NSView? { - guard let contentView = window.contentView else { - return nil - } - let pointInContent = contentView.convert(event.locationInWindow, from: nil) - return contentView.hitTest(pointInContent) - } +#if DEBUG + private func recordMultiWindowNotificationOpenFailureIfNeeded( + tabId: UUID, + surfaceId: UUID?, + notificationId: UUID?, + reason: String + ) { + let env = ProcessInfo.processInfo.environment + guard let path = env["PROGRAMA_UI_TEST_MULTI_WINDOW_NOTIF_PATH"], !path.isEmpty else { return } - private static func programaTopHitViewForEvent(in window: NSWindow, event: NSEvent) -> NSView? { - if let hitInThemeFrame = programaHitViewInThemeFrame(in: window, event: event) { - return hitInThemeFrame + let contextSummaries: [String] = mainWindowContexts.values.map { ctx in + let tabIds = ctx.tabManager.tabs.map { $0.id.uuidString }.joined(separator: ",") + let hasWindow = (ctx.window != nil) ? "1" : "0" + return "windowId=\(ctx.windowId.uuidString) hasWindow=\(hasWindow) tabs=[\(tabIds)]" } - return programaHitViewInContentView(in: window, event: event) - } - private static func programaHitViewForEventDispatch(in window: NSWindow, event: NSEvent) -> NSView? { - if event.windowNumber != 0, event.windowNumber != window.windowNumber { - return nil - } - if let eventWindow = event.window, eventWindow !== window { - return nil - } - return programaTopHitViewForEvent(in: window, event: event) + writeMultiWindowNotificationTestData([ + "focusToken": UUID().uuidString, + "openFailureTabId": tabId.uuidString, + "openFailureSurfaceId": surfaceId?.uuidString ?? "", + "openFailureNotificationId": notificationId?.uuidString ?? "", + "openFailureReason": reason, + "openFailureContexts": contextSummaries.joined(separator: "; "), + ], at: path) } +#endif + + /// Configures a Programa main window's AppKit-level presentation (identifier, + /// titlebar, movability, transparency, glass effect, decorations) and registers + /// it for window-context tracking, then installs the file-drop overlay. + /// + /// Extracted verbatim from `ContentView`'s `WindowAccessor` trailing closure + /// (nuclear-review CV2b) so this AppKit window mutation lives with the + /// window-context layer instead of the SwiftUI view layer. `ContentView` still + /// owns tracking its own `@State` (`observedWindow`, `isFullScreen`, + /// `titlebarPadding`) — this method returns the computed titlebar padding so + /// the caller can decide whether to update that `@State`. + @discardableResult + func configureMainWindow( + _ window: NSWindow, + windowId: UUID, + windowIdentifier: String, + tabManager: TabManager, + sidebarState: SidebarState, + sidebarSelectionState: SidebarSelectionState, + sidebarBlendMode: String, + bgGlassEnabled: Bool, + bgGlassTintHex: String, + bgGlassTintOpacity: Double + ) -> CGFloat { + window.identifier = NSUserInterfaceItemIdentifier(windowIdentifier) + window.titlebarAppearsTransparent = true + // Keep window immovable; the sidebar's WindowDragHandleView handles + // drag-to-move via performDrag with temporary movable override. + // isMovableByWindowBackground=true breaks tab reordering, and + // isMovable=true blocks clicks on sidebar buttons in minimal mode. + window.isMovableByWindowBackground = false + window.isMovable = false + window.styleMask.insert(.fullSizeContentView) - private static func programaHitViewForCurrentEvent(in window: NSWindow, event: NSEvent) -> NSView? { + // Keep content below the titlebar so drags on Bonsplit's tab bar don't + // get interpreted as window drags. + let computedTitlebarHeight = window.frame.height - window.contentLayoutRect.height + let nextTitlebarPadding = max(28, min(72, computedTitlebarHeight)) #if DEBUG - if let override = programaFirstResponderGuardHitViewOverride { - return override + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_MODE"] == "1" { + UpdateLogStore.shared.append("ui test window accessor: id=\(windowIdentifier) visible=\(window.isVisible)") } #endif - if programaFirstResponderGuardContextWindowNumber == window.windowNumber, - let contextHitView = programaFirstResponderGuardHitViewContext { - return contextHitView - } - return programaTopHitViewForEvent(in: window, event: event) - } + // User settings decide whether window glass is active. The native Tahoe + // NSGlassEffectView path vs the older NSVisualEffectView fallback is chosen + // inside WindowGlassEffect.apply. + let currentThemeBackground = GhosttyBackgroundTheme.currentColor() + let shouldApplyWindowGlass = cmuxShouldApplyWindowGlass( + sidebarBlendMode: sidebarBlendMode, + bgGlassEnabled: bgGlassEnabled, + glassEffectAvailable: WindowGlassEffect.isAvailable + ) + let shouldForceTransparentHosting = + shouldApplyWindowGlass || currentThemeBackground.alphaComponent < 0.999 - private static func programaTrackFieldEditor(_ fieldEditor: NSTextView, owningWebView webView: ProgramaWebView?) { - if let webView { - objc_setAssociatedObject( - fieldEditor, - &programaFieldEditorOwningWebViewAssociationKey, - ProgramaFieldEditorOwningWebViewBox(webView: webView), - .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) + if shouldForceTransparentHosting { + window.isOpaque = false + // Keep the window clear whenever translucency is active. Relying only on + // terminal focus-driven updates can leave stale opaque window fills. + window.backgroundColor = NSColor.white.withAlphaComponent(0.001) + // Configure contentView hierarchy for transparency. + if let contentView = window.contentView { + ContentView.makeViewHierarchyTransparent(contentView) + } } else { - objc_setAssociatedObject( - fieldEditor, - &programaFieldEditorOwningWebViewAssociationKey, - nil, - .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) + // Browser-focused workspaces may not have an active terminal panel to refresh + // the NSWindow background. Keep opaque theme changes applied here as well. + window.backgroundColor = currentThemeBackground + window.isOpaque = currentThemeBackground.alphaComponent >= 0.999 } - } - private static func programaTrackedOwningWebView(for fieldEditor: NSTextView) -> ProgramaWebView? { - guard let box = objc_getAssociatedObject( - fieldEditor, - &programaFieldEditorOwningWebViewAssociationKey - ) as? ProgramaFieldEditorOwningWebViewBox else { - return nil - } - guard let webView = box.webView else { - programaTrackFieldEditor(fieldEditor, owningWebView: nil) - return nil + if shouldApplyWindowGlass { + // Apply liquid glass effect to the window with tint from settings + let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity) + WindowGlassEffect.apply(to: window, tintColor: tintColor) + } else { + WindowGlassEffect.remove(from: window) } - return webView - } + AppDelegate.shared?.attachUpdateAccessory(to: window) + AppDelegate.shared?.applyWindowDecorations(to: window) + AppDelegate.shared?.registerMainWindow( + window, + windowId: windowId, + tabManager: tabManager, + sidebarState: sidebarState, + sidebarSelectionState: sidebarSelectionState + ) + installFileDropOverlay(on: window, tabManager: tabManager) - private static func programaIsPointerDownEvent(_ event: NSEvent) -> Bool { - switch event.type { - case .leftMouseDown, .rightMouseDown, .otherMouseDown: - return true - default: - return false - } + return nextTitlebarPadding } - private static func programaPointerHitWebView(in window: NSWindow, event: NSEvent) -> ProgramaWebView? { - guard programaIsPointerDownEvent(event) else { return nil } - if event.windowNumber != 0, event.windowNumber != window.windowNumber { - return nil - } - if let eventWindow = event.window, eventWindow !== window { - return nil - } - if let portalWebView = BrowserWindowPortalRegistry.webViewAtWindowPoint( - event.locationInWindow, - in: window - ) as? ProgramaWebView { - return portalWebView - } - guard let hitView = programaHitViewForCurrentEvent(in: window, event: event) else { - return nil - } - return programaOwningWebView(for: hitView) - } +} - private static func programaShouldAllowPointerInitiatedWebViewFocus( - window: NSWindow, - webView: ProgramaWebView, - event: NSEvent? - ) -> Bool { - guard let event, - let hitWebView = programaPointerHitWebView(in: window, event: event) else { - return false +private extension AppDelegate { + @objc func handleThemesReloadNotification(_ notification: Notification) { + DispatchQueue.main.async { + GhosttyApp.shared.reloadConfiguration(source: "distributed.cmux.themes") } - return hitWebView === webView } - } + diff --git a/Sources/CLIInstaller.swift b/Sources/CLIInstaller.swift new file mode 100644 index 00000000000..e41902b633f --- /dev/null +++ b/Sources/CLIInstaller.swift @@ -0,0 +1,324 @@ +import AppKit +import SwiftUI +import Bonsplit +import CoreServices +import UserNotifications +import WebKit +import Combine +import ObjectiveC.runtime +import Darwin + +struct ProgramaCLIPathInstaller { + struct InstallOutcome { + let usedAdministratorPrivileges: Bool + let destinationURL: URL + let sourceURL: URL + } + + struct UninstallOutcome { + let usedAdministratorPrivileges: Bool + let destinationURL: URL + let removedExistingEntry: Bool + } + + enum InstallerError: LocalizedError { + case bundledCLIMissing(expectedPath: String) + case destinationParentNotDirectory(path: String) + case destinationIsDirectory(path: String) + case installVerificationFailed(path: String) + case uninstallVerificationFailed(path: String) + case privilegedCommandFailed(message: String) + + var errorDescription: String? { + switch self { + case .bundledCLIMissing(let expectedPath): + return "Bundled Programa CLI was not found at \(expectedPath)." + case .destinationParentNotDirectory(let path): + return "Expected \(path) to be a directory." + case .destinationIsDirectory(let path): + return "\(path) is a directory. Remove or rename it and try again." + case .installVerificationFailed(let path): + return "Installed symlink at \(path) did not point to the bundled programa CLI." + case .uninstallVerificationFailed(let path): + return "Failed to remove \(path)." + case .privilegedCommandFailed(let message): + return "Administrator action failed: \(message)" + } + } + } + + typealias PrivilegedInstallHandler = (_ sourceURL: URL, _ destinationURL: URL) throws -> Void + typealias PrivilegedUninstallHandler = (_ destinationURL: URL) throws -> Void + + let fileManager: FileManager + let destinationURL: URL + private let bundledCLIURLProvider: () -> URL? + private let expectedBundledCLIPath: String + private let privilegedInstaller: PrivilegedInstallHandler + private let privilegedUninstaller: PrivilegedUninstallHandler + + init( + fileManager: FileManager = .default, + destinationURL: URL = URL(fileURLWithPath: "/usr/local/bin/programa"), + bundledCLIURLProvider: @escaping () -> URL? = { + ProgramaCLIPathInstaller.defaultBundledCLIURL() + }, + expectedBundledCLIPath: String = ProgramaCLIPathInstaller.defaultBundledCLIExpectedPath(), + privilegedInstaller: PrivilegedInstallHandler? = nil, + privilegedUninstaller: PrivilegedUninstallHandler? = nil + ) { + self.fileManager = fileManager + self.destinationURL = destinationURL + self.bundledCLIURLProvider = bundledCLIURLProvider + self.expectedBundledCLIPath = expectedBundledCLIPath + self.privilegedInstaller = privilegedInstaller ?? Self.installWithAdministratorPrivileges(sourceURL:destinationURL:) + self.privilegedUninstaller = privilegedUninstaller ?? Self.uninstallWithAdministratorPrivileges(destinationURL:) + } + + var destinationPath: String { + destinationURL.path + } + + func install() throws -> InstallOutcome { + let sourceURL = try resolveBundledCLIURL() + do { + try installWithoutAdministratorPrivileges(sourceURL: sourceURL) + return InstallOutcome( + usedAdministratorPrivileges: false, + destinationURL: destinationURL, + sourceURL: sourceURL + ) + } catch { + guard Self.isPermissionDenied(error) else { throw error } + try ensureDestinationIsNotDirectory() + try privilegedInstaller(sourceURL, destinationURL) + try verifyInstalledSymlinkTarget(sourceURL: sourceURL) + return InstallOutcome( + usedAdministratorPrivileges: true, + destinationURL: destinationURL, + sourceURL: sourceURL + ) + } + } + + func uninstall() throws -> UninstallOutcome { + do { + let removedExistingEntry = try uninstallWithoutAdministratorPrivileges() + return UninstallOutcome( + usedAdministratorPrivileges: false, + destinationURL: destinationURL, + removedExistingEntry: removedExistingEntry + ) + } catch { + guard Self.isPermissionDenied(error) else { throw error } + try ensureDestinationIsNotDirectory() + let removedExistingEntry = destinationEntryExists() + try privilegedUninstaller(destinationURL) + if destinationEntryExists() { + throw InstallerError.uninstallVerificationFailed(path: destinationURL.path) + } + return UninstallOutcome( + usedAdministratorPrivileges: true, + destinationURL: destinationURL, + removedExistingEntry: removedExistingEntry + ) + } + } + + func isInstalled() -> Bool { + guard let sourceURL = bundledCLIURLProvider()?.standardizedFileURL else { return false } + guard let installedTargetURL = symlinkDestinationURL() else { return false } + return installedTargetURL == sourceURL + } + + private func resolveBundledCLIURL() throws -> URL { + guard let sourceURL = bundledCLIURLProvider()?.standardizedFileURL else { + throw InstallerError.bundledCLIMissing(expectedPath: expectedBundledCLIPath) + } + + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: sourceURL.path, isDirectory: &isDirectory), !isDirectory.boolValue else { + throw InstallerError.bundledCLIMissing(expectedPath: sourceURL.path) + } + return sourceURL + } + + private func installWithoutAdministratorPrivileges(sourceURL: URL) throws { + try ensureDestinationParentDirectoryExists() + try ensureDestinationIsNotDirectory() + if destinationEntryExists() { + try fileManager.removeItem(at: destinationURL) + } + try fileManager.createSymbolicLink(at: destinationURL, withDestinationURL: sourceURL) + try verifyInstalledSymlinkTarget(sourceURL: sourceURL) + } + + @discardableResult + private func uninstallWithoutAdministratorPrivileges() throws -> Bool { + try ensureDestinationIsNotDirectory() + let existed = destinationEntryExists() + if existed { + try fileManager.removeItem(at: destinationURL) + } + if destinationEntryExists() { + throw InstallerError.uninstallVerificationFailed(path: destinationURL.path) + } + return existed + } + + /// Check if the destination path has any filesystem entry (including dangling symlinks). + /// `FileManager.fileExists` follows symlinks, so a dangling symlink returns false. + private func destinationEntryExists() -> Bool { + (try? fileManager.attributesOfItem(atPath: destinationURL.path)) != nil + } + + private func verifyInstalledSymlinkTarget(sourceURL: URL) throws { + guard let installedTargetURL = symlinkDestinationURL(), + installedTargetURL == sourceURL.standardizedFileURL else { + throw InstallerError.installVerificationFailed(path: destinationURL.path) + } + } + + private func symlinkDestinationURL() -> URL? { + guard fileManager.fileExists(atPath: destinationURL.path) else { return nil } + guard let destinationPath = try? fileManager.destinationOfSymbolicLink(atPath: destinationURL.path) else { + return nil + } + return URL( + fileURLWithPath: destinationPath, + relativeTo: destinationURL.deletingLastPathComponent() + ).standardizedFileURL + } + + private func ensureDestinationParentDirectoryExists() throws { + let parentURL = destinationURL.deletingLastPathComponent() + var isDirectory: ObjCBool = false + if fileManager.fileExists(atPath: parentURL.path, isDirectory: &isDirectory) { + guard isDirectory.boolValue else { + throw InstallerError.destinationParentNotDirectory(path: parentURL.path) + } + return + } + try fileManager.createDirectory(at: parentURL, withIntermediateDirectories: true) + } + + private func ensureDestinationIsNotDirectory() throws { + guard let values = try resourceValuesIfFileExists( + at: destinationURL, + keys: [.isDirectoryKey, .isSymbolicLinkKey] + ) else { + return + } + + if values.isDirectory == true, values.isSymbolicLink != true { + throw InstallerError.destinationIsDirectory(path: destinationURL.path) + } + } + + private func resourceValuesIfFileExists( + at url: URL, + keys: Set + ) throws -> URLResourceValues? { + do { + return try url.resourceValues(forKeys: keys) + } catch { + let nsError = error as NSError + if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError { + return nil + } + if nsError.domain == NSPOSIXErrorDomain, + POSIXErrorCode(rawValue: Int32(nsError.code)) == .ENOENT { + return nil + } + throw error + } + } + + private static func defaultBundledCLIURL(bundle: Bundle = .main) -> URL? { + bundle.resourceURL?.appendingPathComponent("bin/programa", isDirectory: false) + } + + private static func defaultBundledCLIExpectedPath(bundle: Bundle = .main) -> String { + bundle.bundleURL + .appendingPathComponent("Contents/Resources/bin/programa", isDirectory: false) + .path + } + + private static func installWithAdministratorPrivileges(sourceURL: URL, destinationURL: URL) throws { + let destinationPath = destinationURL.path + let parentPath = destinationURL.deletingLastPathComponent().path + let command = "/bin/mkdir -p \(shellQuoted(parentPath)) && " + + "/bin/rm -f \(shellQuoted(destinationPath)) && " + + "/bin/ln -s \(shellQuoted(sourceURL.path)) \(shellQuoted(destinationPath))" + try runPrivilegedShellCommand(command) + } + + private static func uninstallWithAdministratorPrivileges(destinationURL: URL) throws { + let command = "/bin/rm -f \(shellQuoted(destinationURL.path))" + try runPrivilegedShellCommand(command) + } + + private static func runPrivilegedShellCommand(_ command: String) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = [ + "-e", "on run argv", + "-e", "do shell script (item 1 of argv) with administrator privileges", + "-e", "end run", + command + ] + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + let stderrText = String( + data: stderr.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + )?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let stdoutText = String( + data: stdout.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + )?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let details = stderrText.isEmpty ? stdoutText : stderrText + let message = details.isEmpty + ? "osascript exited with status \(process.terminationStatus)." + : details + throw InstallerError.privilegedCommandFailed(message: message) + } + } + + private static func shellQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + private static func isPermissionDenied(_ error: Error) -> Bool { + isPermissionDenied(error as NSError) + } + + private static func isPermissionDenied(_ error: NSError) -> Bool { + if error.domain == NSPOSIXErrorDomain, + let code = POSIXErrorCode(rawValue: Int32(error.code)), + code == .EACCES || code == .EPERM || code == .EROFS { + return true + } + + if error.domain == NSCocoaErrorDomain { + switch error.code { + case NSFileWriteNoPermissionError, NSFileReadNoPermissionError, NSFileWriteVolumeReadOnlyError: + return true + default: + break + } + } + + if let underlying = error.userInfo[NSUnderlyingErrorKey] as? NSError { + return isPermissionDenied(underlying) + } + + return false + } +} diff --git a/Sources/CommandPaletteController.swift b/Sources/CommandPaletteController.swift new file mode 100644 index 00000000000..c12ebba8d0f --- /dev/null +++ b/Sources/CommandPaletteController.swift @@ -0,0 +1,73 @@ +// Command-palette state ownership, extracted from ContentView.swift (nuclear-review #88). +// +// CommandPaletteController owns every @State property that used to live on +// ContentView and is exclusively used by the command palette (query, mode, +// search corpus/results, rename/workspace-description drafts, focus-restore +// targets, usage history, etc.). ContentView holds a single +// `@StateObject private var commandPaletteController` and exposes each +// property back to its existing (unqualified) call sites in its body via +// thin computed proxies — this keeps the ~4000 lines of palette orchestration +// code that reads/writes these properties unchanged while genuinely moving +// storage ownership onto the controller (no more @State duplicated per-view). +// +// The two @FocusState properties (isCommandPaletteSearchFocused, +// isCommandPaletteRenameFocused) stay on ContentView: @FocusState is a +// SwiftUI View-only property wrapper and cannot be hosted on an +// ObservableObject. +// +// Access-level widening: CommandPaletteMode, CommandPaletteRestoreFocusTarget, +// CommandPaletteTextSelectionBehavior, and CommandPaletteMultilineTextEditorRepresentable +// were `private` nested types inside ContentView; widened to internal (dropped +// the `private` modifier) so this controller (a different file) can reference +// them. No other behavior change. CommandPaletteCommand, CommandPaletteSearchResult, +// CommandPaletteListScope, CommandPalettePendingActivation, and +// CommandPaletteUsageEntry were already internal nested types; referenced here +// via `ContentView.` qualification since nested-type name lookup requires it +// from outside the enclosing type's lexical scope. + +import AppKit +import Combine +import SwiftUI + +final class CommandPaletteController: ObservableObject { + @Published var isCommandPalettePresented = false + @Published var commandPaletteQuery: String = "" + @Published var commandPaletteMode: ContentView.CommandPaletteMode = .commands + @Published var commandPaletteRenameDraft: String = "" + @Published var commandPaletteWorkspaceDescriptionDraft: String = "" + @Published var commandPaletteWorkspaceDescriptionHeight: CGFloat = ContentView.CommandPaletteMultilineTextEditorRepresentable.defaultMinimumHeight + @Published var commandPaletteSelectedResultIndex: Int = 0 + @Published var commandPaletteSelectionAnchorCommandID: String? + @Published var commandPaletteHoveredResultIndex: Int? + @Published var commandPaletteScrollTargetIndex: Int? + @Published var commandPaletteScrollTargetAnchor: UnitPoint? + @Published var commandPaletteRestoreFocusTarget: ContentView.CommandPaletteRestoreFocusTarget? + @Published var commandPaletteSearchCorpus: [CommandPaletteSearchCorpusEntry] = [] + @Published var commandPaletteSearchCorpusByID: [String: CommandPaletteSearchCorpusEntry] = [:] + @Published var commandPaletteSearchCommandsByID: [String: ContentView.CommandPaletteCommand] = [:] + @Published var cachedCommandPaletteResults: [ContentView.CommandPaletteSearchResult] = [] + @Published var commandPaletteVisibleResults: [ContentView.CommandPaletteSearchResult] = [] + @Published var commandPaletteVisibleResultsScope: ContentView.CommandPaletteListScope? + @Published var commandPaletteVisibleResultsFingerprint: Int? + @Published var cachedCommandPaletteScope: ContentView.CommandPaletteListScope? + @Published var cachedCommandPaletteFingerprint: Int? + @Published var commandPalettePendingDismissFocusTarget: ContentView.CommandPaletteRestoreFocusTarget? + @Published var commandPaletteRestoreTimeoutWorkItem: DispatchWorkItem? + @Published var commandPalettePendingTextSelectionBehavior: ContentView.CommandPaletteTextSelectionBehavior? + @Published var commandPaletteSearchTask: Task? + @Published var commandPaletteSearchRequestID: UInt64 = 0 + @Published var commandPaletteResolvedSearchRequestID: UInt64 = 0 + @Published var commandPaletteResolvedSearchScope: ContentView.CommandPaletteListScope? + @Published var commandPaletteResolvedSearchFingerprint: Int? + @Published var commandPaletteResolvedMatchingQuery = "" + @Published var commandPaletteTerminalOpenTargetAvailability: Set = [] + @Published var isCommandPaletteSearchPending = false + @Published var commandPalettePendingActivation: ContentView.CommandPalettePendingActivation? + @Published var commandPaletteResultsRevision: UInt64 = 0 + @Published var commandPaletteUsageHistoryByCommandId: [String: ContentView.CommandPaletteUsageEntry] = [:] + @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) + var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus + @AppStorage(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey) + var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces + @Published var commandPaletteShouldFocusWorkspaceDescriptionEditor = false +} diff --git a/Sources/CommandPaletteSearchEngine.swift b/Sources/CommandPaletteSearchEngine.swift new file mode 100644 index 00000000000..de3444a9fab --- /dev/null +++ b/Sources/CommandPaletteSearchEngine.swift @@ -0,0 +1,993 @@ +// Command palette fuzzy-search engine, extracted from ContentView.swift (nuclear-review #94.1). +// Pure move: search metadata, indexer, fuzzy matcher, corpus types, and the search engine. +// Zero coupling to ContentView state — behavior-identical relocation. + +import Foundation + +struct CommandPaletteSwitcherSearchMetadata: Equatable, Sendable { + let directories: [String] + let branches: [String] + let ports: [Int] + let description: String? + + init( + directories: [String] = [], + branches: [String] = [], + ports: [Int] = [], + description: String? = nil + ) { + self.directories = directories + self.branches = branches + self.ports = ports + self.description = description + } +} + +enum CommandPaletteSwitcherSearchIndexer { + enum MetadataDetail { + case workspace + case surface + } + + private static let metadataDelimiters = CharacterSet(charactersIn: "/\\.:_- ") + + static func keywords( + baseKeywords: [String], + metadata: CommandPaletteSwitcherSearchMetadata, + detail: MetadataDetail = .surface + ) -> [String] { + let metadataKeywords = metadataKeywordsForSearch(metadata, detail: detail) + return uniqueNormalizedPreservingOrder(baseKeywords + metadataKeywords) + } + + private static func metadataKeywordsForSearch( + _ metadata: CommandPaletteSwitcherSearchMetadata, + detail: MetadataDetail + ) -> [String] { + let directoryTokens = metadata.directories.flatMap { directoryTokensForSearch($0, detail: detail) } + let branchTokens = metadata.branches.flatMap { branchTokensForSearch($0, detail: detail) } + let portTokens = metadata.ports.flatMap(portTokensForSearch) + let descriptionTokens = descriptionTokensForSearch(metadata.description) + + var contextKeywords: [String] = [] + if !directoryTokens.isEmpty { + contextKeywords.append(contentsOf: ["directory", "dir", "cwd", "path"]) + } + if !branchTokens.isEmpty { + contextKeywords.append(contentsOf: ["branch", "git"]) + } + if !portTokens.isEmpty { + contextKeywords.append(contentsOf: ["port", "ports"]) + } + if !descriptionTokens.isEmpty { + contextKeywords.append(contentsOf: ["description", "descriptions", "notes", "note"]) + } + + return contextKeywords + directoryTokens + branchTokens + portTokens + descriptionTokens + } + + private static func directoryTokensForSearch( + _ rawDirectory: String, + detail: MetadataDetail + ) -> [String] { + let trimmed = rawDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + let standardized = (trimmed as NSString).standardizingPath + let canonical = standardized.isEmpty ? trimmed : standardized + let abbreviated = (canonical as NSString).abbreviatingWithTildeInPath + switch detail { + case .workspace: + return uniqueNormalizedPreservingOrder([trimmed, canonical, abbreviated]) + case .surface: + let basename = URL(fileURLWithPath: canonical, isDirectory: true).lastPathComponent + let components = canonical.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } + return uniqueNormalizedPreservingOrder( + [trimmed, canonical, abbreviated, basename] + components + ) + } + } + + private static func branchTokensForSearch( + _ rawBranch: String, + detail: MetadataDetail + ) -> [String] { + let trimmed = rawBranch.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + switch detail { + case .workspace: + return [trimmed] + case .surface: + let components = trimmed.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } + return uniqueNormalizedPreservingOrder([trimmed] + components) + } + } + + private static func portTokensForSearch(_ port: Int) -> [String] { + guard (1...65535).contains(port) else { return [] } + let portText = String(port) + return [portText, ":\(portText)"] + } + + private static func descriptionTokensForSearch(_ rawDescription: String?) -> [String] { + let trimmed = rawDescription?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return [] } + let normalizedWhitespace = trimmed.replacingOccurrences( + of: "\\s+", + with: " ", + options: .regularExpression + ) + let components = normalizedWhitespace.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } + return uniqueNormalizedPreservingOrder([trimmed, normalizedWhitespace] + components) + } + + private static func uniqueNormalizedPreservingOrder(_ values: [String]) -> [String] { + var result: [String] = [] + var seen: Set = [] + result.reserveCapacity(values.count) + + for value in values { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let normalizedKey = trimmed + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + guard seen.insert(normalizedKey).inserted else { continue } + result.append(trimmed) + } + return result + } +} + +enum CommandPaletteFuzzyMatcher { + private static let tokenBoundaryChars: Set = [" ", "-", "_", "/", ".", ":"] + + private enum SingleEditWordPrefixEditKind { + case candidateExtraCharacter + case tokenExtraCharacter + case substitutedCharacter + case transposedCharacters + + var basePenalty: Int { + switch self { + case .candidateExtraCharacter: + return 0 + case .tokenExtraCharacter: + return 10 + case .transposedCharacters: + return 24 + case .substitutedCharacter: + return 40 + } + } + } + + private struct SingleEditWordPrefixMatch { + let matchedIndices: Set + let segmentStart: Int + let segmentLength: Int + let prefixLength: Int + let editPosition: Int + let editKind: SingleEditWordPrefixEditKind + } + + struct PreparedQuery { + let normalizedText: String + let tokens: [String] + + var isEmpty: Bool { + tokens.isEmpty + } + } + + static func preparedQuery(_ query: String) -> PreparedQuery { + let normalizedQuery = normalizeForSearch(query) + return PreparedQuery( + normalizedText: normalizedQuery, + tokens: normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty } + ) + } + + static func normalizeForSearch(_ text: String) -> String { + text + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + } + + static func score(query: String, candidate: String) -> Int? { + score(query: query, candidates: [candidate]) + } + + static func score(query: String, candidates: [String]) -> Int? { + score( + preparedQuery: preparedQuery(query), + normalizedCandidates: candidates + .map(normalizeForSearch) + .filter { !$0.isEmpty } + ) + } + + static func score(preparedQuery: PreparedQuery, normalizedCandidates: [String]) -> Int? { + guard !preparedQuery.isEmpty else { return 0 } + guard !normalizedCandidates.isEmpty else { return nil } + + var totalScore = 0 + for token in preparedQuery.tokens { + var bestTokenScore: Int? + for candidate in normalizedCandidates { + guard let candidateScore = scoreToken(token, in: candidate) else { continue } + bestTokenScore = max(bestTokenScore ?? candidateScore, candidateScore) + } + guard let bestTokenScore else { return nil } + totalScore += bestTokenScore + } + return totalScore + } + + static func matchCharacterIndices(query: String, candidate: String) -> Set { + matchCharacterIndices(preparedQuery: preparedQuery(query), candidate: candidate) + } + + static func matchCharacterIndices(preparedQuery: PreparedQuery, candidate: String) -> Set { + guard !preparedQuery.isEmpty else { return [] } + + let loweredCandidate = normalizeForSearch(candidate) + guard !loweredCandidate.isEmpty else { return [] } + + let candidateChars = Array(loweredCandidate) + var matched: Set = [] + + for token in preparedQuery.tokens { + if token == loweredCandidate { + matched.formUnion(0.. Int? { + guard !token.isEmpty else { return 0 } + + let candidateChars = Array(candidate) + let tokenChars = Array(token) + guard tokenChars.count <= candidateChars.count else { return nil } + + if token == candidate { + return 8000 + } + if candidate.hasPrefix(token) { + return 6800 - max(0, candidate.count - token.count) + } + + var bestScore: Int? + if let wordExactScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: true) { + bestScore = max(bestScore ?? wordExactScore, wordExactScore) + } + if let wordPrefixScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: false) { + bestScore = max(bestScore ?? wordPrefixScore, wordPrefixScore) + } + if let singleEditPrefixScore = singleEditWordPrefixScore( + tokenChars: tokenChars, + candidateChars: candidateChars + ) { + bestScore = max(bestScore ?? singleEditPrefixScore, singleEditPrefixScore) + } + + if let range = candidate.range(of: token) { + let distance = candidate.distance(from: candidate.startIndex, to: range.lowerBound) + let lengthPenalty = max(0, candidate.count - token.count) + let boundaryBoost: Int = { + guard distance > 0 else { return 220 } + let prior = candidateChars[distance - 1] + return tokenBoundaryChars.contains(prior) ? 180 : 0 + }() + let containsScore = 4200 + boundaryBoost - (distance * 9) - lengthPenalty + bestScore = max(bestScore ?? containsScore, containsScore) + } + + if let initialismScore = initialismScore(tokenChars: tokenChars, candidateChars: candidateChars) { + bestScore = max(bestScore ?? initialismScore, initialismScore) + } + + if let stitchedScore = stitchedWordPrefixScore(tokenChars: tokenChars, candidateChars: candidateChars) { + bestScore = max(bestScore ?? stitchedScore, stitchedScore) + } + + if tokenChars.count <= 3, let subsequence = subsequenceScore(token: token, candidate: candidate) { + bestScore = max(bestScore ?? subsequence, subsequence) + } + + guard let bestScore else { return nil } + return max(1, bestScore) + } + + private static func bestWordScore( + tokenChars: [Character], + candidateChars: [Character], + requireExactWord: Bool + ) -> Int? { + guard !tokenChars.isEmpty else { return nil } + + var best: Int? + for segment in wordSegments(candidateChars) { + let wordLength = segment.end - segment.start + guard tokenChars.count <= wordLength else { continue } + + var matchesPrefix = true + for offset in 0.. Int? { + guard let match = singleEditWordPrefixMatch( + tokenChars: tokenChars, + candidateChars: candidateChars + ) else { + return nil + } + return singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) + } + + private static func singleEditWordPrefixScore( + match: SingleEditWordPrefixMatch, + candidateLength: Int + ) -> Int { + let lengthPenalty = max(0, match.segmentLength - match.prefixLength) * 6 + let distancePenalty = match.segmentStart * 8 + let trailingPenalty = max(0, candidateLength - match.segmentLength) + let editPositionPenalty = max(0, match.editPosition - match.segmentStart) * 10 + return 5000 + - match.editKind.basePenalty + - distancePenalty + - lengthPenalty + - trailingPenalty + - editPositionPenalty + } + + private static func initialismScore(tokenChars: [Character], candidateChars: [Character]) -> Int? { + guard !tokenChars.isEmpty else { return nil } + let segments = wordSegments(candidateChars) + guard tokenChars.count <= segments.count else { return nil } + + var matchedStarts: [Int] = [] + var searchWordIndex = 0 + + for tokenChar in tokenChars { + var found = false + while searchWordIndex < segments.count { + let segment = segments[searchWordIndex] + searchWordIndex += 1 + if candidateChars[segment.start] == tokenChar { + matchedStarts.append(segment.start) + found = true + break + } + } + if !found { return nil } + } + + let firstStart = matchedStarts.first ?? 0 + let skippedWords = max(0, segments.count - tokenChars.count) + return 3000 + (tokenChars.count * 160) - (firstStart * 5) - (skippedWords * 30) + } + + private static func tokenPrefixMatches( + tokenChars: [Character], + tokenStart: Int, + length: Int, + candidateChars: [Character], + candidateStart: Int + ) -> Bool { + guard length >= 0 else { return false } + guard tokenStart + length <= tokenChars.count else { return false } + guard candidateStart + length <= candidateChars.count else { return false } + guard length > 0 else { return true } + + for offset in 0.. Int? { + guard tokenChars.count >= 4 else { return nil } + let segments = wordSegments(candidateChars) + guard segments.count >= 2 else { return nil } + + struct StitchState: Hashable { + let tokenIndex: Int + let wordIndex: Int + let usedWords: Int + } + + var memo: [StitchState: Int?] = [:] + + func dfs(tokenIndex: Int, wordIndex: Int, usedWords: Int) -> Int? { + if tokenIndex == tokenChars.count { + return usedWords >= 2 ? 0 : nil + } + guard wordIndex < segments.count else { return nil } + + let state = StitchState(tokenIndex: tokenIndex, wordIndex: wordIndex, usedWords: usedWords) + if let cached = memo[state] { + return cached + } + + var best: Int? + let remainingChars = tokenChars.count - tokenIndex + for segmentIndex in wordIndex.. 0 else { continue } + + let skippedWords = max(0, segmentIndex - wordIndex) + let skipPenalty = skippedWords * 120 + for chunkLength in stride(from: maxChunk, through: 1, by: -1) { + guard tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: tokenIndex, + length: chunkLength, + candidateChars: candidateChars, + candidateStart: segment.start + ) else { + continue + } + guard let suffixScore = dfs( + tokenIndex: tokenIndex + chunkLength, + wordIndex: segmentIndex + 1, + usedWords: min(2, usedWords + 1) + ) else { + continue + } + + let chunkCoverage = chunkLength * 220 + let contiguityBonus = segmentIndex == wordIndex ? 80 : 0 + let segmentRemainderPenalty = max(0, segmentLength - chunkLength) * 9 + let distancePenalty = segment.start * 4 + let chunkScore = chunkCoverage + contiguityBonus - segmentRemainderPenalty - distancePenalty - skipPenalty + let totalScore = suffixScore + chunkScore + best = max(best ?? totalScore, totalScore) + } + } + + memo[state] = best + return best + } + + guard let stitchedScore = dfs(tokenIndex: 0, wordIndex: 0, usedWords: 0) else { return nil } + let lengthPenalty = max(0, candidateChars.count - tokenChars.count) + return 3500 + stitchedScore - lengthPenalty + } + + private static func stitchedWordPrefixMatchIndices(token: String, candidate: String) -> Set? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard tokenChars.count >= 4 else { return nil } + + let segments = wordSegments(candidateChars) + guard segments.count >= 2 else { return nil } + + var tokenIndex = 0 + var nextWordIndex = 0 + var usedWords = 0 + var matchedIndices: Set = [] + + while tokenIndex < tokenChars.count { + let remainingChars = tokenChars.count - tokenIndex + var foundMatch = false + + for segmentIndex in nextWordIndex.. 0 else { continue } + + for chunkLength in stride(from: maxChunk, through: 1, by: -1) { + guard tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: tokenIndex, + length: chunkLength, + candidateChars: candidateChars, + candidateStart: segment.start + ) else { + continue + } + + matchedIndices.formUnion(segment.start..<(segment.start + chunkLength)) + tokenIndex += chunkLength + nextWordIndex = segmentIndex + 1 + usedWords += 1 + foundMatch = true + break + } + + if foundMatch { break } + } + + if !foundMatch { return nil } + } + + guard usedWords >= 2 else { return nil } + return matchedIndices + } + + private static func singleEditWordPrefixMatch( + token: String, + candidate: String + ) -> SingleEditWordPrefixMatch? { + singleEditWordPrefixMatch( + tokenChars: Array(token), + candidateChars: Array(candidate) + ) + } + + private static func singleEditWordPrefixMatch( + tokenChars: [Character], + candidateChars: [Character] + ) -> SingleEditWordPrefixMatch? { + guard tokenChars.count >= 4 else { return nil } + + var bestMatch: SingleEditWordPrefixMatch? + var bestScore: Int? + + for segment in wordSegments(candidateChars) { + guard let match = singleEditWordPrefixMatch( + tokenChars: tokenChars, + candidateChars: candidateChars, + segment: segment + ) else { + continue + } + + let score = singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) + if let bestScore, score <= bestScore { + continue + } + bestScore = score + bestMatch = match + } + + return bestMatch + } + + private static func singleEditWordPrefixMatch( + tokenChars: [Character], + candidateChars: [Character], + segment: (start: Int, end: Int) + ) -> SingleEditWordPrefixMatch? { + guard tokenChars.count >= 4 else { return nil } + + let segmentLength = segment.end - segment.start + guard segmentLength + 1 >= tokenChars.count else { return nil } + + let exactPrefixLength = min(tokenChars.count, segmentLength) + var mismatchOffset = 0 + while mismatchOffset < exactPrefixLength, + candidateChars[segment.start + mismatchOffset] == tokenChars[mismatchOffset] + { + mismatchOffset += 1 + } + + if mismatchOffset == tokenChars.count { + let prefixLength = tokenChars.count + 1 + guard segmentLength >= prefixLength else { return nil } + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: prefixLength, + editPosition: segment.start + tokenChars.count, + editKind: .candidateExtraCharacter + ) + } + + if mismatchOffset == segmentLength { + let prefixLength = tokenChars.count - 1 + guard prefixLength > 0 else { return nil } + guard tokenChars.count == segmentLength + 1 else { return nil } + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + prefixLength)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: prefixLength, + editPosition: segment.start + prefixLength, + editKind: .tokenExtraCharacter + ) + } + + let mismatchCandidateIndex = segment.start + mismatchOffset + + if segmentLength >= tokenChars.count + 1, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset, + length: tokenChars.count - mismatchOffset, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 1 + ) + { + var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count + 1)) + matchedIndices.remove(mismatchCandidateIndex) + return SingleEditWordPrefixMatch( + matchedIndices: matchedIndices, + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count + 1, + editPosition: mismatchCandidateIndex, + editKind: .candidateExtraCharacter + ) + } + + if tokenChars.count >= 2, + segmentLength >= tokenChars.count - 1, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 1, + length: tokenChars.count - mismatchOffset - 1, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + ) + { + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count - 1)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count - 1, + editPosition: mismatchCandidateIndex, + editKind: .tokenExtraCharacter + ) + } + + if segmentLength >= tokenChars.count, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 1, + length: tokenChars.count - mismatchOffset - 1, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 1 + ) + { + var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count)) + matchedIndices.remove(mismatchCandidateIndex) + return SingleEditWordPrefixMatch( + matchedIndices: matchedIndices, + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count, + editPosition: mismatchCandidateIndex, + editKind: .substitutedCharacter + ) + } + + if segmentLength >= tokenChars.count, + mismatchOffset + 1 < tokenChars.count, + mismatchCandidateIndex + 1 < segment.end, + tokenChars[mismatchOffset] == candidateChars[mismatchCandidateIndex + 1], + tokenChars[mismatchOffset + 1] == candidateChars[mismatchCandidateIndex], + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 2, + length: tokenChars.count - mismatchOffset - 2, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 2 + ) + { + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count, + editPosition: mismatchCandidateIndex, + editKind: .transposedCharacters + ) + } + + return nil + } + + private static func wordSegments(_ candidateChars: [Character]) -> [(start: Int, end: Int)] { + var segments: [(start: Int, end: Int)] = [] + var index = 0 + + while index < candidateChars.count { + while index < candidateChars.count, tokenBoundaryChars.contains(candidateChars[index]) { + index += 1 + } + guard index < candidateChars.count else { break } + let start = index + while index < candidateChars.count, !tokenBoundaryChars.contains(candidateChars[index]) { + index += 1 + } + segments.append((start: start, end: index)) + } + + return segments + } + + private static func subsequenceScore(token: String, candidate: String) -> Int? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard tokenChars.count <= candidateChars.count else { return nil } + + var searchIndex = 0 + var previousMatch = -1 + var consecutiveRun = 0 + var score = 0 + + for tokenChar in tokenChars { + var foundIndex: Int? + while searchIndex < candidateChars.count { + if candidateChars[searchIndex] == tokenChar { + foundIndex = searchIndex + break + } + searchIndex += 1 + } + guard let matchedIndex = foundIndex else { return nil } + + score += 90 + if matchedIndex == 0 || tokenBoundaryChars.contains(candidateChars[matchedIndex - 1]) { + score += 140 + } + if matchedIndex == previousMatch + 1 { + consecutiveRun += 1 + score += min(200, consecutiveRun * 45) + } else { + consecutiveRun = 0 + score -= min(120, max(0, matchedIndex - previousMatch - 1) * 4) + } + + previousMatch = matchedIndex + searchIndex = matchedIndex + 1 + } + + score -= max(0, candidateChars.count - tokenChars.count) + return max(1, score) + } + + private static func subsequenceMatchIndices(token: String, candidate: String) -> Set? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard tokenChars.count <= candidateChars.count else { return nil } + + var indices: Set = [] + var searchIndex = 0 + + for tokenChar in tokenChars { + var foundIndex: Int? + while searchIndex < candidateChars.count { + if candidateChars[searchIndex] == tokenChar { + foundIndex = searchIndex + break + } + searchIndex += 1 + } + guard let matchIndex = foundIndex else { return nil } + indices.insert(matchIndex) + searchIndex = matchIndex + 1 + } + + return indices + } + + private static func initialismMatchIndices(token: String, candidate: String) -> Set? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard !tokenChars.isEmpty else { return nil } + + let segments = wordSegments(candidateChars) + guard tokenChars.count <= segments.count else { return nil } + + var matched: Set = [] + var searchWordIndex = 0 + + for tokenChar in tokenChars { + var found = false + while searchWordIndex < segments.count { + let segment = segments[searchWordIndex] + searchWordIndex += 1 + if candidateChars[segment.start] == tokenChar { + matched.insert(segment.start) + found = true + break + } + } + if !found { return nil } + } + + return matched + } +} + +struct CommandPaletteSearchCorpusEntry: Sendable where Payload: Sendable { + let payload: Payload + let rank: Int + let title: String + let normalizedTitle: String + let normalizedSearchableTexts: [String] + + init(payload: Payload, rank: Int, title: String, searchableTexts: [String]) { + self.payload = payload + self.rank = rank + self.title = title + self.normalizedTitle = CommandPaletteFuzzyMatcher.normalizeForSearch(title) + self.normalizedSearchableTexts = searchableTexts + .map(CommandPaletteFuzzyMatcher.normalizeForSearch) + .filter { !$0.isEmpty } + } +} + +struct CommandPaletteSearchCorpusResult: Sendable where Payload: Sendable { + let payload: Payload + let rank: Int + let title: String + let score: Int + let titleMatchIndices: Set +} + +enum CommandPaletteSearchEngine { + private static let titleMatchBonus = 2000 + + static func search( + entries: [CommandPaletteSearchCorpusEntry], + query: String, + historyBoost: (Payload, Bool) -> Int + ) -> [CommandPaletteSearchCorpusResult] { + search( + entries: entries, + query: query, + historyBoost: historyBoost, + shouldCancel: nil + ) + } + + static func search( + entries: [CommandPaletteSearchCorpusEntry], + query: String, + historyBoost: (Payload, Bool) -> Int, + shouldCancel: @escaping () -> Bool + ) -> [CommandPaletteSearchCorpusResult] { + search( + entries: entries, + query: query, + historyBoost: historyBoost, + shouldCancel: Optional(shouldCancel) + ) + } + + private static func search( + entries: [CommandPaletteSearchCorpusEntry], + query: String, + historyBoost: (Payload, Bool) -> Int, + shouldCancel: (() -> Bool)? + ) -> [CommandPaletteSearchCorpusResult] { + let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) + let queryIsEmpty = preparedQuery.isEmpty + var results: [CommandPaletteSearchCorpusResult] = [] + results.reserveCapacity(entries.count) + + func shouldCancelSearch(at index: Int) -> Bool { + guard let shouldCancel else { return false } + return index % 16 == 0 && shouldCancel() + } + + if queryIsEmpty { + for (index, entry) in entries.enumerated() { + if shouldCancelSearch(at: index) { return [] } + results.append( + CommandPaletteSearchCorpusResult( + payload: entry.payload, + rank: entry.rank, + title: entry.title, + score: historyBoost(entry.payload, true), + titleMatchIndices: [] + ) + ) + } + } else { + for (index, entry) in entries.enumerated() { + if shouldCancelSearch(at: index) { return [] } + guard let fuzzyScore = weightedScore( + preparedQuery: preparedQuery, + entry: entry + ) else { + continue + } + results.append( + CommandPaletteSearchCorpusResult( + payload: entry.payload, + rank: entry.rank, + title: entry.title, + score: fuzzyScore + historyBoost(entry.payload, false), + titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices( + preparedQuery: preparedQuery, + candidate: entry.title + ) + ) + ) + } + } + + if shouldCancel?() == true { return [] } + + return results.sorted { lhs, rhs in + if lhs.score != rhs.score { return lhs.score > rhs.score } + if lhs.rank != rhs.rank { return lhs.rank < rhs.rank } + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending + } + } + + private static func weightedScore( + preparedQuery: CommandPaletteFuzzyMatcher.PreparedQuery, + entry: CommandPaletteSearchCorpusEntry + ) -> Int? { + guard let fuzzyScore = CommandPaletteFuzzyMatcher.score( + preparedQuery: preparedQuery, + normalizedCandidates: entry.normalizedSearchableTexts + ) else { + return nil + } + guard !entry.normalizedTitle.isEmpty, + let titleScore = CommandPaletteFuzzyMatcher.score( + preparedQuery: preparedQuery, + normalizedCandidates: [entry.normalizedTitle] + ) else { + return fuzzyScore + } + return max(fuzzyScore, titleScore + titleMatchBonus) + } +} + diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index e17214dc0e9..cfc56e5321f 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7,7 +7,7 @@ import ObjectiveC import UniformTypeIdentifiers import WebKit -private extension Color { +extension Color { init?(hex: String) { let hex = hex.trimmingCharacters(in: .init(charactersIn: "#")) guard hex.count == 6, let value = UInt64(hex, radix: 16) else { return nil } @@ -19,7 +19,7 @@ private extension Color { } } -private func coloredCircleImage(color: NSColor) -> NSImage { +func coloredCircleImage(color: NSColor) -> NSImage { let size = NSSize(width: 14, height: 14) let image = NSImage(size: size, flipped: false) { rect in color.setFill() @@ -370,589 +370,6 @@ enum SidebarResizeInteraction { } } -// MARK: - File Drop Overlay - -enum DragOverlayRoutingPolicy { - static let bonsplitTabTransferType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") - static let sidebarTabReorderType = NSPasteboard.PasteboardType(SidebarTabDragPayload.typeIdentifier) - - static func hasBonsplitTabTransfer(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { - guard let pasteboardTypes else { return false } - return pasteboardTypes.contains(bonsplitTabTransferType) - } - - static func hasSidebarTabReorder(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { - guard let pasteboardTypes else { return false } - return pasteboardTypes.contains(sidebarTabReorderType) - } - - static func hasFileURL(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { - guard let pasteboardTypes else { return false } - return pasteboardTypes.contains(.fileURL) - } - - static func shouldCaptureFileDropDestination( - pasteboardTypes: [NSPasteboard.PasteboardType]?, - hasLocalDraggingSource: Bool - ) -> 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. - _ = 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 } - if hasSidebarTabReorder(pasteboardTypes) { return false } - return true - } - - static func shouldCaptureFileDropDestination( - pasteboardTypes: [NSPasteboard.PasteboardType]? - ) -> Bool { - shouldCaptureFileDropDestination( - pasteboardTypes: pasteboardTypes, - hasLocalDraggingSource: false - ) - } - - static func shouldCaptureFileDropOverlay( - pasteboardTypes: [NSPasteboard.PasteboardType]?, - eventType: NSEvent.EventType? - ) -> Bool { - guard shouldCaptureFileDropDestination(pasteboardTypes: pasteboardTypes) else { return false } - guard isDragMouseEvent(eventType) else { return false } - return true - } - - static func shouldCaptureSidebarExternalOverlay( - hasSidebarDragState: Bool, - pasteboardTypes: [NSPasteboard.PasteboardType]? - ) -> Bool { - guard hasSidebarDragState else { return false } - return hasSidebarTabReorder(pasteboardTypes) - } - - static func shouldCaptureSidebarExternalOverlay( - draggedTabId: UUID?, - pasteboardTypes: [NSPasteboard.PasteboardType]? - ) -> Bool { - shouldCaptureSidebarExternalOverlay( - hasSidebarDragState: draggedTabId != nil, - pasteboardTypes: pasteboardTypes - ) - } - - static func shouldPassThroughPortalHitTesting( - pasteboardTypes: [NSPasteboard.PasteboardType]?, - eventType: NSEvent.EventType? - ) -> Bool { - guard isPortalDragEvent(eventType) else { return false } - return hasBonsplitTabTransfer(pasteboardTypes) || hasSidebarTabReorder(pasteboardTypes) - } - - private static func isDragMouseEvent(_ eventType: NSEvent.EventType?) -> Bool { - eventType == .leftMouseDragged - || eventType == .rightMouseDragged - || eventType == .otherMouseDragged - } - - private static func isPortalDragEvent(_ eventType: NSEvent.EventType?) -> Bool { - // Restrict portal pass-through to explicit drag-motion events so stale - // NSPasteboard(name: .drag) types cannot hijack normal pointer input. - guard let eventType else { return false } - switch eventType { - case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: - return true - default: - return false - } - } -} - -/// 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 -/// 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. -/// -/// Mouse events are forwarded to the views below via a hide-send-unhide pattern so clicks, -/// scrolls, and other interactions pass through normally. -final class FileDropOverlayView: NSView { - /// Fallback handler when no terminal is found under the drop point. - var onDrop: (([URL]) -> Bool)? - private var isForwardingMouseEvent = false - private weak var forwardedMouseDragTarget: NSView? - private var forwardedMouseDragButton: ForwardedMouseDragButton? - /// The WKWebView currently receiving forwarded drag events, so we can - /// synthesize draggingExited/draggingEntered as the cursor moves. - private weak var activeDragWebView: WKWebView? - /// The WKWebView that accepted prepareForDragOperation so conclude can be - /// delivered to the same browser target after the drop completes. - private weak var preparedDragWebView: WKWebView? - private var lastHitTestLogSignature: String? - private var lastDragRouteLogSignatureByPhase: [String: String] = [:] - - override var acceptsFirstResponder: Bool { false } - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - registerForDraggedTypes([.fileURL]) - } - - required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") } - - private enum ForwardedMouseDragButton: Equatable { - case left - case right - case other(Int) - } - - private func dragButton(for event: NSEvent) -> ForwardedMouseDragButton? { - switch event.type { - case .leftMouseDown, .leftMouseUp, .leftMouseDragged: - return .left - case .rightMouseDown, .rightMouseUp, .rightMouseDragged: - return .right - case .otherMouseDown, .otherMouseUp, .otherMouseDragged: - return .other(Int(event.buttonNumber)) - default: - return nil - } - } - - private func shouldTrackForwardedMouseDragStart(for eventType: NSEvent.EventType) -> Bool { - switch eventType { - case .leftMouseDown, .rightMouseDown, .otherMouseDown: - return true - default: - return false - } - } - - private func shouldTrackForwardedMouseDragEnd(for eventType: NSEvent.EventType) -> Bool { - switch eventType { - case .leftMouseUp, .rightMouseUp, .otherMouseUp: - return true - default: - return false - } - } - - // MARK: Hit-testing — participation is routed by DragOverlayRoutingPolicy so - // file-drop, bonsplit tab drags, and sidebar tab reorder drags cannot conflict. - - override func hitTest(_ point: NSPoint) -> NSView? { - let pb = NSPasteboard(name: .drag) - let eventType = NSApp.currentEvent?.type - let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropOverlay( - pasteboardTypes: pb.types, - eventType: eventType - ) -#if DEBUG - logHitTestDecision( - pasteboardTypes: pb.types, - eventType: eventType, - shouldCapture: shouldCapture - ) -#endif - guard shouldCapture else { return nil } - - return super.hitTest(point) - } - - // MARK: Mouse forwarding — safety net for the rare case where stale drag pasteboard - // data causes hitTest to return self when no drag is actually active. - // We hit-test contentView directly and dispatch to the target rather than using - // window.sendEvent(), which caches the mouse target and causes infinite recursion. - - private func forwardEvent(_ event: NSEvent) { - guard !isForwardingMouseEvent else { return } - guard let window, let contentView = window.contentView else { return } - let eventButton = dragButton(for: event) - - isForwardingMouseEvent = true - isHidden = true - defer { - isHidden = false - isForwardingMouseEvent = false - } - - let target: NSView? - if let eventButton, - forwardedMouseDragButton == eventButton, - let activeTarget = forwardedMouseDragTarget, - activeTarget.window != nil { - // Preserve normal AppKit mouse-delivery semantics: once a drag starts, - // keep routing dragged/up events to the original mouseDown target. - target = activeTarget - } else { - let point = contentView.convert(event.locationInWindow, from: nil) - target = contentView.hitTest(point) - } - - guard let target, target !== self else { - if shouldTrackForwardedMouseDragEnd(for: event.type), - let eventButton, - forwardedMouseDragButton == eventButton { - forwardedMouseDragTarget = nil - forwardedMouseDragButton = nil - } - return - } - - if shouldTrackForwardedMouseDragStart(for: event.type), let eventButton { - forwardedMouseDragTarget = target - forwardedMouseDragButton = eventButton - } - - switch event.type { - case .leftMouseDown: target.mouseDown(with: event) - case .leftMouseUp: target.mouseUp(with: event) - case .leftMouseDragged: target.mouseDragged(with: event) - case .rightMouseDown: target.rightMouseDown(with: event) - case .rightMouseUp: target.rightMouseUp(with: event) - case .rightMouseDragged: target.rightMouseDragged(with: event) - case .otherMouseDown: target.otherMouseDown(with: event) - case .otherMouseUp: target.otherMouseUp(with: event) - case .otherMouseDragged: target.otherMouseDragged(with: event) - case .scrollWheel: target.scrollWheel(with: event) - default: break - } - - if shouldTrackForwardedMouseDragEnd(for: event.type), - let eventButton, - forwardedMouseDragButton == eventButton { - forwardedMouseDragTarget = nil - forwardedMouseDragButton = nil - } - } - - override func mouseDown(with event: NSEvent) { forwardEvent(event) } - override func mouseUp(with event: NSEvent) { forwardEvent(event) } - override func mouseDragged(with event: NSEvent) { forwardEvent(event) } - override func rightMouseDown(with event: NSEvent) { forwardEvent(event) } - override func rightMouseUp(with event: NSEvent) { forwardEvent(event) } - override func rightMouseDragged(with event: NSEvent) { forwardEvent(event) } - override func otherMouseDown(with event: NSEvent) { forwardEvent(event) } - override func otherMouseUp(with event: NSEvent) { forwardEvent(event) } - override func otherMouseDragged(with event: NSEvent) { forwardEvent(event) } - override func scrollWheel(with event: NSEvent) { forwardEvent(event) } - - // MARK: NSDraggingDestination – accept file drops over terminal and browser views. - // - // AppKit sends draggingEntered once when the drag enters this overlay, then - // draggingUpdated as the cursor moves within it. We track which WKWebView (if - // any) is under the cursor and synthesize enter/exit calls so the browser's - // HTML5 drag events (dragenter, dragleave, drop) fire correctly. - - override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { - return updateDragTarget(sender, phase: "entered") - } - - override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { - return updateDragTarget(sender, phase: "updated") - } - - override func draggingExited(_ sender: (any NSDraggingInfo)?) { - preparedDragWebView = nil - if let prev = activeDragWebView { - prev.draggingExited(sender) - activeDragWebView = nil - } - } - - override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { - let hasLocalDraggingSource = sender.draggingSource != nil - let types = sender.draggingPasteboard.types - let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( - pasteboardTypes: types, - hasLocalDraggingSource: hasLocalDraggingSource - ) - let webView = shouldCapture ? (activeDragWebView ?? webViewUnderPoint(sender.draggingLocation)) : nil - let terminal = terminalUnderPoint(sender.draggingLocation) - let hasTerminalTarget = terminal != nil -#if DEBUG - logDragRouteDecision( - phase: "prepare", - pasteboardTypes: types, - shouldCapture: shouldCapture, - hasLocalDraggingSource: hasLocalDraggingSource, - hasTerminalTarget: hasTerminalTarget - ) -#endif - guard shouldCapture else { - preparedDragWebView = nil - return false - } - if let webView { - preparedDragWebView = webView - return webView.prepareForDragOperation(sender) - } - preparedDragWebView = nil - return hasTerminalTarget - } - - override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { - let hasLocalDraggingSource = sender.draggingSource != nil - let types = sender.draggingPasteboard.types - let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( - pasteboardTypes: types, - hasLocalDraggingSource: hasLocalDraggingSource - ) - let webView = shouldCapture - ? (preparedDragWebView ?? activeDragWebView ?? webViewUnderPoint(sender.draggingLocation)) - : nil - let terminal = terminalUnderPoint(sender.draggingLocation) - let hasTerminalTarget = terminal != nil -#if DEBUG - logDragRouteDecision( - phase: "perform", - pasteboardTypes: types, - shouldCapture: shouldCapture, - hasLocalDraggingSource: hasLocalDraggingSource, - hasTerminalTarget: hasTerminalTarget - ) -#endif - guard shouldCapture else { - preparedDragWebView = nil - activeDragWebView = nil - return false - } - if let webView { - preparedDragWebView = webView - return webView.performDragOperation(sender) - } - preparedDragWebView = nil - activeDragWebView = nil - guard let terminal else { return false } - return terminal.performDragOperation(sender) - } - - override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) { - defer { - preparedDragWebView = nil - activeDragWebView = nil - } - guard let sender else { return } - let webView = preparedDragWebView ?? activeDragWebView ?? webViewUnderPoint(sender.draggingLocation) - webView?.concludeDragOperation(sender) - } - - private func updateDragTarget(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation { - let loc = sender.draggingLocation - let hasLocalDraggingSource = sender.draggingSource != nil - let types = sender.draggingPasteboard.types - let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( - pasteboardTypes: types, - hasLocalDraggingSource: hasLocalDraggingSource - ) - let webView = shouldCapture ? webViewUnderPoint(loc) : nil - - if let prev = activeDragWebView, prev !== webView { - prev.draggingExited(sender) - activeDragWebView = nil - } - - if let webView { - if activeDragWebView !== webView { - activeDragWebView = webView - return webView.draggingEntered(sender) - } - return webView.draggingUpdated(sender) - } - - let hasTerminalTarget = terminalUnderPoint(loc) != nil -#if DEBUG - logDragRouteDecision( - phase: phase, - pasteboardTypes: types, - shouldCapture: shouldCapture, - hasLocalDraggingSource: hasLocalDraggingSource, - hasTerminalTarget: hasTerminalTarget - ) -#endif - guard shouldCapture, hasTerminalTarget else { return [] } - return .copy - } - - private func debugPasteboardTypes(_ types: [NSPasteboard.PasteboardType]?) -> String { - guard let types, !types.isEmpty else { return "-" } - return types.map(\.rawValue).joined(separator: ",") - } - - /// Hit-tests the window to find a WKWebView (browser panel) under the cursor. - func webViewUnderPoint(_ windowPoint: NSPoint) -> WKWebView? { - if let window, - let portalWebView = BrowserWindowPortalRegistry.webViewAtWindowPoint(windowPoint, in: window) { - return portalWebView - } - - guard let window, let contentView = window.contentView else { return nil } - isHidden = true - defer { isHidden = false } - let point = contentView.convert(windowPoint, from: nil) - let hitView = contentView.hitTest(point) - - var current: NSView? = hitView - while let view = current { - if let webView = view as? WKWebView { return webView } - current = view.superview - } - return nil - } - - private func debugTopHitViewForCurrentEvent() -> String { - guard let window, - let currentEvent = NSApp.currentEvent, - let contentView = window.contentView, - let themeFrame = contentView.superview else { return "-" } - - let pointInTheme = themeFrame.convert(currentEvent.locationInWindow, from: nil) - // Don't toggle isHidden here — it triggers setNeedsDisplay which can - // exceed AppKit's display-pass limit during cursor-update display cycles. - guard let hit = themeFrame.hitTest(pointInTheme) else { return "nil" } - var chain: [String] = [] - var current: NSView? = hit - var depth = 0 - while let view = current, depth < 6 { - chain.append(debugHitViewDescriptor(view)) - current = view.superview - depth += 1 - } - return chain.joined(separator: "->") - } - - private func debugHitViewDescriptor(_ view: NSView) -> String { - let className = String(describing: type(of: view)) - let ptr = String(describing: Unmanaged.passUnretained(view).toOpaque()) - let dragTypes = debugRegisteredDragTypes(view) - return "\(className)@\(ptr){dragTypes=\(dragTypes)}" - } - - private func debugRegisteredDragTypes(_ view: NSView) -> String { - let types = view.registeredDraggedTypes - guard !types.isEmpty else { return "-" } - - let interestingTypes = types.filter { type in - let raw = type.rawValue - return raw == NSPasteboard.PasteboardType.fileURL.rawValue - || raw == DragOverlayRoutingPolicy.bonsplitTabTransferType.rawValue - || raw == DragOverlayRoutingPolicy.sidebarTabReorderType.rawValue - || raw.contains("public.text") - || raw.contains("public.url") - || raw.contains("public.data") - } - let selected = interestingTypes.isEmpty ? Array(types.prefix(3)) : interestingTypes - let rendered = selected.map(\.rawValue).joined(separator: ",") - if selected.count < types.count { - return "\(rendered),+\(types.count - selected.count)" - } - return rendered - } - - private func hasRelevantDragTypes(_ types: [NSPasteboard.PasteboardType]?) -> Bool { - guard let types else { return false } - return types.contains(.fileURL) - || types.contains(DragOverlayRoutingPolicy.bonsplitTabTransferType) - || types.contains(DragOverlayRoutingPolicy.sidebarTabReorderType) - } - - private func debugEventName(_ eventType: NSEvent.EventType?) -> String { - guard let eventType else { return "none" } - switch eventType { - case .cursorUpdate: return "cursorUpdate" - case .appKitDefined: return "appKitDefined" - case .systemDefined: return "systemDefined" - case .applicationDefined: return "applicationDefined" - case .periodic: return "periodic" - case .mouseMoved: return "mouseMoved" - case .mouseEntered: return "mouseEntered" - case .mouseExited: return "mouseExited" - case .flagsChanged: return "flagsChanged" - case .leftMouseDown: return "leftMouseDown" - case .leftMouseUp: return "leftMouseUp" - case .leftMouseDragged: return "leftMouseDragged" - case .rightMouseDown: return "rightMouseDown" - case .rightMouseUp: return "rightMouseUp" - case .rightMouseDragged: return "rightMouseDragged" - case .otherMouseDown: return "otherMouseDown" - case .otherMouseUp: return "otherMouseUp" - case .otherMouseDragged: return "otherMouseDragged" - case .scrollWheel: return "scrollWheel" - default: return "other(\(eventType.rawValue))" - } - } - -#if DEBUG - private func logHitTestDecision( - pasteboardTypes: [NSPasteboard.PasteboardType]?, - eventType: NSEvent.EventType?, - shouldCapture: Bool - ) { - let isDragEvent = eventType == .leftMouseDragged - || eventType == .rightMouseDragged - || eventType == .otherMouseDragged - guard shouldCapture || isDragEvent || hasRelevantDragTypes(pasteboardTypes) else { return } - - let signature = "\(shouldCapture ? 1 : 0)|\(debugEventName(eventType))|\(debugPasteboardTypes(pasteboardTypes))" - guard lastHitTestLogSignature != signature else { return } - lastHitTestLogSignature = signature - dlog( - "overlay.fileDrop.hitTest capture=\(shouldCapture ? 1 : 0) " + - "event=\(debugEventName(eventType)) " + - "topHit=\(debugTopHitViewForCurrentEvent()) " + - "types=\(debugPasteboardTypes(pasteboardTypes))" - ) - } - - private func logDragRouteDecision( - phase: String, - pasteboardTypes: [NSPasteboard.PasteboardType]?, - shouldCapture: Bool, - hasLocalDraggingSource: Bool, - hasTerminalTarget: Bool - ) { - guard shouldCapture || hasRelevantDragTypes(pasteboardTypes) else { return } - let signature = [ - shouldCapture ? "1" : "0", - hasLocalDraggingSource ? "1" : "0", - hasTerminalTarget ? "1" : "0", - debugPasteboardTypes(pasteboardTypes) - ].joined(separator: "|") - guard lastDragRouteLogSignatureByPhase[phase] != signature else { return } - lastDragRouteLogSignatureByPhase[phase] = signature - dlog( - "overlay.fileDrop.\(phase) capture=\(shouldCapture ? 1 : 0) " + - "localSource=\(hasLocalDraggingSource ? 1 : 0) " + - "hasTerminal=\(hasTerminalTarget ? 1 : 0) " + - "types=\(debugPasteboardTypes(pasteboardTypes))" - ) - } -#endif - /// Hit-tests the window to find the GhosttyNSView under the cursor. - func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? { - if let window, - let portalTerminal = TerminalWindowPortalRegistry.terminalViewAtWindowPoint(windowPoint, in: window) { - return portalTerminal - } - - guard let window, let contentView = window.contentView else { return nil } - isHidden = true - defer { isHidden = false } - let point = contentView.convert(windowPoint, from: nil) - let hitView = contentView.hitTest(point) - - var current: NSView? = hitView - while let view = current { - if let terminal = view as? GhosttyNSView { return terminal } - current = view.superview - } - return nil - } -} - var fileDropOverlayKey: UInt8 = 0 private var commandPaletteWindowOverlayKey: UInt8 = 0 private var tmuxWorkspacePaneWindowOverlayKey: UInt8 = 0 @@ -1018,7 +435,7 @@ private func debugCommandPaletteKeyEventSummary(_ event: NSEvent) -> String { "chars=\(chars) charsIgnoring=\(charsIgnoring)" } -private func debugCommandPaletteTextPreview(_ text: String, limit: Int = 120) -> String { +func debugCommandPaletteTextPreview(_ text: String, limit: Int = 120) -> String { let escaped = text .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\n", with: "\\n") @@ -1823,50 +1240,193 @@ struct ContentView: View { @State private var isResizerBandActive = false @State private var isSidebarResizerCursorActive = false @State private var sidebarResizerCursorStabilizer: DispatchSourceTimer? - @State private var isCommandPalettePresented = false - @State private var commandPaletteQuery: String = "" - @State private var commandPaletteMode: CommandPaletteMode = .commands - @State private var commandPaletteRenameDraft: String = "" - @State private var commandPaletteWorkspaceDescriptionDraft: String = "" - @State private var commandPaletteWorkspaceDescriptionHeight: CGFloat = CommandPaletteMultilineTextEditorRepresentable.defaultMinimumHeight - @State private var commandPaletteSelectedResultIndex: Int = 0 - @State private var commandPaletteSelectionAnchorCommandID: String? - @State private var commandPaletteHoveredResultIndex: Int? - @State private var commandPaletteScrollTargetIndex: Int? - @State private var commandPaletteScrollTargetAnchor: UnitPoint? - @State private var commandPaletteRestoreFocusTarget: CommandPaletteRestoreFocusTarget? - @State private var commandPaletteSearchCorpus: [CommandPaletteSearchCorpusEntry] = [] - @State private var commandPaletteSearchCorpusByID: [String: CommandPaletteSearchCorpusEntry] = [:] - @State private var commandPaletteSearchCommandsByID: [String: CommandPaletteCommand] = [:] - @State private var cachedCommandPaletteResults: [CommandPaletteSearchResult] = [] - @State private var commandPaletteVisibleResults: [CommandPaletteSearchResult] = [] - @State private var commandPaletteVisibleResultsScope: CommandPaletteListScope? - @State private var commandPaletteVisibleResultsFingerprint: Int? - @State private var cachedCommandPaletteScope: CommandPaletteListScope? - @State private var cachedCommandPaletteFingerprint: Int? - @State private var commandPalettePendingDismissFocusTarget: CommandPaletteRestoreFocusTarget? - @State private var commandPaletteRestoreTimeoutWorkItem: DispatchWorkItem? - @State private var commandPalettePendingTextSelectionBehavior: CommandPaletteTextSelectionBehavior? - @State private var commandPaletteSearchTask: Task? - @State private var commandPaletteSearchRequestID: UInt64 = 0 - @State private var commandPaletteResolvedSearchRequestID: UInt64 = 0 - @State private var commandPaletteResolvedSearchScope: CommandPaletteListScope? - @State private var commandPaletteResolvedSearchFingerprint: Int? - @State private var commandPaletteResolvedMatchingQuery = "" - @State private var commandPaletteTerminalOpenTargetAvailability: Set = [] - @State private var isCommandPaletteSearchPending = false - @State private var commandPalettePendingActivation: CommandPalettePendingActivation? - @State private var commandPaletteResultsRevision: UInt64 = 0 - @State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:] - @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) - private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus - @AppStorage(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey) - private var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces - @State private var commandPaletteShouldFocusWorkspaceDescriptionEditor = false + @StateObject private var commandPaletteController = CommandPaletteController() + private var isCommandPalettePresented: Bool { + get { commandPaletteController.isCommandPalettePresented } + nonmutating set { commandPaletteController.isCommandPalettePresented = newValue } + } + private var commandPaletteQuery: String { + get { commandPaletteController.commandPaletteQuery } + nonmutating set { commandPaletteController.commandPaletteQuery = newValue } + } + private var commandPaletteMode: CommandPaletteMode { + get { commandPaletteController.commandPaletteMode } + nonmutating set { commandPaletteController.commandPaletteMode = newValue } + } + private var commandPaletteRenameDraft: String { + get { commandPaletteController.commandPaletteRenameDraft } + nonmutating set { commandPaletteController.commandPaletteRenameDraft = newValue } + } + private var commandPaletteWorkspaceDescriptionDraft: String { + get { commandPaletteController.commandPaletteWorkspaceDescriptionDraft } + nonmutating set { commandPaletteController.commandPaletteWorkspaceDescriptionDraft = newValue } + } + private var commandPaletteWorkspaceDescriptionHeight: CGFloat { + get { commandPaletteController.commandPaletteWorkspaceDescriptionHeight } + nonmutating set { commandPaletteController.commandPaletteWorkspaceDescriptionHeight = newValue } + } + private var commandPaletteSelectedResultIndex: Int { + get { commandPaletteController.commandPaletteSelectedResultIndex } + nonmutating set { commandPaletteController.commandPaletteSelectedResultIndex = newValue } + } + private var commandPaletteSelectionAnchorCommandID: String? { + get { commandPaletteController.commandPaletteSelectionAnchorCommandID } + nonmutating set { commandPaletteController.commandPaletteSelectionAnchorCommandID = newValue } + } + private var commandPaletteHoveredResultIndex: Int? { + get { commandPaletteController.commandPaletteHoveredResultIndex } + nonmutating set { commandPaletteController.commandPaletteHoveredResultIndex = newValue } + } + private var commandPaletteScrollTargetIndex: Int? { + get { commandPaletteController.commandPaletteScrollTargetIndex } + nonmutating set { commandPaletteController.commandPaletteScrollTargetIndex = newValue } + } + private var commandPaletteScrollTargetAnchor: UnitPoint? { + get { commandPaletteController.commandPaletteScrollTargetAnchor } + nonmutating set { commandPaletteController.commandPaletteScrollTargetAnchor = newValue } + } + private var commandPaletteRestoreFocusTarget: CommandPaletteRestoreFocusTarget? { + get { commandPaletteController.commandPaletteRestoreFocusTarget } + nonmutating set { commandPaletteController.commandPaletteRestoreFocusTarget = newValue } + } + private var commandPaletteSearchCorpus: [CommandPaletteSearchCorpusEntry] { + get { commandPaletteController.commandPaletteSearchCorpus } + nonmutating set { commandPaletteController.commandPaletteSearchCorpus = newValue } + } + private var commandPaletteSearchCorpusByID: [String: CommandPaletteSearchCorpusEntry] { + get { commandPaletteController.commandPaletteSearchCorpusByID } + nonmutating set { commandPaletteController.commandPaletteSearchCorpusByID = newValue } + } + private var commandPaletteSearchCommandsByID: [String: CommandPaletteCommand] { + get { commandPaletteController.commandPaletteSearchCommandsByID } + nonmutating set { commandPaletteController.commandPaletteSearchCommandsByID = newValue } + } + private var cachedCommandPaletteResults: [CommandPaletteSearchResult] { + get { commandPaletteController.cachedCommandPaletteResults } + nonmutating set { commandPaletteController.cachedCommandPaletteResults = newValue } + } + private var commandPaletteVisibleResults: [CommandPaletteSearchResult] { + get { commandPaletteController.commandPaletteVisibleResults } + nonmutating set { commandPaletteController.commandPaletteVisibleResults = newValue } + } + private var commandPaletteVisibleResultsScope: CommandPaletteListScope? { + get { commandPaletteController.commandPaletteVisibleResultsScope } + nonmutating set { commandPaletteController.commandPaletteVisibleResultsScope = newValue } + } + private var commandPaletteVisibleResultsFingerprint: Int? { + get { commandPaletteController.commandPaletteVisibleResultsFingerprint } + nonmutating set { commandPaletteController.commandPaletteVisibleResultsFingerprint = newValue } + } + private var cachedCommandPaletteScope: CommandPaletteListScope? { + get { commandPaletteController.cachedCommandPaletteScope } + nonmutating set { commandPaletteController.cachedCommandPaletteScope = newValue } + } + private var cachedCommandPaletteFingerprint: Int? { + get { commandPaletteController.cachedCommandPaletteFingerprint } + nonmutating set { commandPaletteController.cachedCommandPaletteFingerprint = newValue } + } + private var commandPalettePendingDismissFocusTarget: CommandPaletteRestoreFocusTarget? { + get { commandPaletteController.commandPalettePendingDismissFocusTarget } + nonmutating set { commandPaletteController.commandPalettePendingDismissFocusTarget = newValue } + } + private var commandPaletteRestoreTimeoutWorkItem: DispatchWorkItem? { + get { commandPaletteController.commandPaletteRestoreTimeoutWorkItem } + nonmutating set { commandPaletteController.commandPaletteRestoreTimeoutWorkItem = newValue } + } + private var commandPalettePendingTextSelectionBehavior: CommandPaletteTextSelectionBehavior? { + get { commandPaletteController.commandPalettePendingTextSelectionBehavior } + nonmutating set { commandPaletteController.commandPalettePendingTextSelectionBehavior = newValue } + } + private var commandPaletteSearchTask: Task? { + get { commandPaletteController.commandPaletteSearchTask } + nonmutating set { commandPaletteController.commandPaletteSearchTask = newValue } + } + private var commandPaletteSearchRequestID: UInt64 { + get { commandPaletteController.commandPaletteSearchRequestID } + nonmutating set { commandPaletteController.commandPaletteSearchRequestID = newValue } + } + private var commandPaletteResolvedSearchRequestID: UInt64 { + get { commandPaletteController.commandPaletteResolvedSearchRequestID } + nonmutating set { commandPaletteController.commandPaletteResolvedSearchRequestID = newValue } + } + private var commandPaletteResolvedSearchScope: CommandPaletteListScope? { + get { commandPaletteController.commandPaletteResolvedSearchScope } + nonmutating set { commandPaletteController.commandPaletteResolvedSearchScope = newValue } + } + private var commandPaletteResolvedSearchFingerprint: Int? { + get { commandPaletteController.commandPaletteResolvedSearchFingerprint } + nonmutating set { commandPaletteController.commandPaletteResolvedSearchFingerprint = newValue } + } + private var commandPaletteResolvedMatchingQuery: String { + get { commandPaletteController.commandPaletteResolvedMatchingQuery } + nonmutating set { commandPaletteController.commandPaletteResolvedMatchingQuery = newValue } + } + private var commandPaletteTerminalOpenTargetAvailability: Set { + get { commandPaletteController.commandPaletteTerminalOpenTargetAvailability } + nonmutating set { commandPaletteController.commandPaletteTerminalOpenTargetAvailability = newValue } + } + private var isCommandPaletteSearchPending: Bool { + get { commandPaletteController.isCommandPaletteSearchPending } + nonmutating set { commandPaletteController.isCommandPaletteSearchPending = newValue } + } + private var commandPalettePendingActivation: CommandPalettePendingActivation? { + get { commandPaletteController.commandPalettePendingActivation } + nonmutating set { commandPaletteController.commandPalettePendingActivation = newValue } + } + private var commandPaletteResultsRevision: UInt64 { + get { commandPaletteController.commandPaletteResultsRevision } + nonmutating set { commandPaletteController.commandPaletteResultsRevision = newValue } + } + private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] { + get { commandPaletteController.commandPaletteUsageHistoryByCommandId } + nonmutating set { commandPaletteController.commandPaletteUsageHistoryByCommandId = newValue } + } + private var commandPaletteRenameSelectAllOnFocus: Bool { + get { commandPaletteController.commandPaletteRenameSelectAllOnFocus } + nonmutating set { commandPaletteController.commandPaletteRenameSelectAllOnFocus = newValue } + } + private var commandPaletteSearchAllSurfaces: Bool { + get { commandPaletteController.commandPaletteSearchAllSurfaces } + nonmutating set { commandPaletteController.commandPaletteSearchAllSurfaces = newValue } + } + private var commandPaletteShouldFocusWorkspaceDescriptionEditor: Bool { + get { commandPaletteController.commandPaletteShouldFocusWorkspaceDescriptionEditor } + nonmutating set { commandPaletteController.commandPaletteShouldFocusWorkspaceDescriptionEditor = newValue } + } + private var commandPaletteQueryBinding: Binding { + Binding( + get: { commandPaletteController.commandPaletteQuery }, + set: { commandPaletteController.commandPaletteQuery = $0 } + ) + } + private var commandPaletteRenameDraftBinding: Binding { + Binding( + get: { commandPaletteController.commandPaletteRenameDraft }, + set: { commandPaletteController.commandPaletteRenameDraft = $0 } + ) + } + private var commandPaletteWorkspaceDescriptionDraftBinding: Binding { + Binding( + get: { commandPaletteController.commandPaletteWorkspaceDescriptionDraft }, + set: { commandPaletteController.commandPaletteWorkspaceDescriptionDraft = $0 } + ) + } + private var commandPaletteWorkspaceDescriptionHeightBinding: Binding { + Binding( + get: { commandPaletteController.commandPaletteWorkspaceDescriptionHeight }, + set: { commandPaletteController.commandPaletteWorkspaceDescriptionHeight = $0 } + ) + } + private var commandPaletteShouldFocusWorkspaceDescriptionEditorBinding: Binding { + Binding( + get: { commandPaletteController.commandPaletteShouldFocusWorkspaceDescriptionEditor }, + set: { commandPaletteController.commandPaletteShouldFocusWorkspaceDescriptionEditor = $0 } + ) + } @FocusState private var isCommandPaletteSearchFocused: Bool @FocusState private var isCommandPaletteRenameFocused: Bool - private enum CommandPaletteMode { + enum CommandPaletteMode { case commands case renameInput(CommandPaletteRenameTarget) case renameConfirm(CommandPaletteRenameTarget, proposedName: String) @@ -1888,7 +1448,7 @@ struct ContentView: View { case command(commandID: String) } - private struct CommandPaletteRenameTarget: Equatable { + struct CommandPaletteRenameTarget: Equatable { enum Kind: Equatable { case workspace(workspaceId: UUID) case tab(workspaceId: UUID, panelId: UUID) @@ -1925,7 +1485,7 @@ struct ContentView: View { } } - private struct CommandPaletteWorkspaceDescriptionTarget: Equatable { + struct CommandPaletteWorkspaceDescriptionTarget: Equatable { let workspaceId: UUID let currentDescription: String @@ -1944,7 +1504,7 @@ struct ContentView: View { } } - private struct CommandPaletteRestoreFocusTarget { + struct CommandPaletteRestoreFocusTarget { let workspaceId: UUID let panelId: UUID let intent: PanelFocusIntent @@ -1955,7 +1515,7 @@ struct ContentView: View { case rename } - private enum CommandPaletteTextSelectionBehavior { + enum CommandPaletteTextSelectionBehavior { case caretAtEnd case selectAll } @@ -3895,7 +3455,7 @@ struct ContentView: View { HStack(spacing: 8) { CommandPaletteSearchFieldRepresentable( placeholder: commandPaletteSearchPlaceholder, - text: $commandPaletteQuery, + text: commandPaletteQueryBinding, isFocused: Binding( get: { isCommandPaletteSearchFocused }, set: { isCommandPaletteSearchFocused = $0 } @@ -4115,7 +3675,7 @@ struct ContentView: View { onDeleteBackward: handleCommandPaletteRenameDeleteBackward(modifiers:) ), placeholder: target.placeholder, - text: $commandPaletteRenameDraft, + text: commandPaletteRenameDraftBinding, onSubmit: { _ in continueRenameFlow(target: target) }, onEscape: { dismissCommandPalette() }, onInteraction: handleCommandPaletteRenameInputInteraction @@ -4199,12 +3759,12 @@ struct ContentView: View { localized: "command.editWorkspaceDescription.title", defaultValue: "Edit Workspace Description…" ), - focus: $commandPaletteShouldFocusWorkspaceDescriptionEditor, - measuredHeight: $commandPaletteWorkspaceDescriptionHeight, + focus: commandPaletteShouldFocusWorkspaceDescriptionEditorBinding, + measuredHeight: commandPaletteWorkspaceDescriptionHeightBinding, maxHeight: maxEditorHeight ), placeholder: target.placeholder, - text: $commandPaletteWorkspaceDescriptionDraft, + text: commandPaletteWorkspaceDescriptionDraftBinding, onSubmit: { proposedDescription in applyWorkspaceDescriptionFlow(target: target, proposedDescription: proposedDescription) }, @@ -4488,7 +4048,7 @@ struct ContentView: View { override func hitTest(_ point: NSPoint) -> NSView? { nil } } - private final class CommandPaletteMultilineTextView: NSTextView { + final class CommandPaletteMultilineTextView: NSTextView { var onHandleKeyEvent: ((NSEvent, NSTextView?) -> Bool)? var onDidBecomeFirstResponder: (() -> Void)? @@ -4616,7 +4176,7 @@ struct ContentView: View { } } - private final class CommandPaletteMultilineTextEditorView: NSView { + final class CommandPaletteMultilineTextEditorView: NSView { private static let font = NSFont.systemFont(ofSize: 13) private static let textInset = NSSize(width: 0, height: 2) static let defaultMinimumHeight: CGFloat = { @@ -4821,7 +4381,7 @@ struct ContentView: View { } } - private struct CommandPaletteMultilineTextEditorRepresentable: NSViewRepresentable { + struct CommandPaletteMultilineTextEditorRepresentable: NSViewRepresentable { static let defaultMinimumHeight = CommandPaletteMultilineTextEditorView.defaultMinimumHeight let placeholder: String @@ -7731,1007 +7291,20 @@ struct ContentView: View { #endif } -struct CommandPaletteSwitcherSearchMetadata: Equatable, Sendable { - let directories: [String] - let branches: [String] - let ports: [Int] - let description: String? - - init( - directories: [String] = [], - branches: [String] = [], - ports: [Int] = [], - description: String? = nil - ) { - self.directories = directories - self.branches = branches - self.ports = ports - self.description = description - } -} - -enum CommandPaletteSwitcherSearchIndexer { - enum MetadataDetail { - case workspace - case surface - } - - private static let metadataDelimiters = CharacterSet(charactersIn: "/\\.:_- ") - - static func keywords( - baseKeywords: [String], - metadata: CommandPaletteSwitcherSearchMetadata, - detail: MetadataDetail = .surface - ) -> [String] { - let metadataKeywords = metadataKeywordsForSearch(metadata, detail: detail) - return uniqueNormalizedPreservingOrder(baseKeywords + metadataKeywords) - } - - private static func metadataKeywordsForSearch( - _ metadata: CommandPaletteSwitcherSearchMetadata, - detail: MetadataDetail - ) -> [String] { - let directoryTokens = metadata.directories.flatMap { directoryTokensForSearch($0, detail: detail) } - let branchTokens = metadata.branches.flatMap { branchTokensForSearch($0, detail: detail) } - let portTokens = metadata.ports.flatMap(portTokensForSearch) - let descriptionTokens = descriptionTokensForSearch(metadata.description) - - var contextKeywords: [String] = [] - if !directoryTokens.isEmpty { - contextKeywords.append(contentsOf: ["directory", "dir", "cwd", "path"]) - } - if !branchTokens.isEmpty { - contextKeywords.append(contentsOf: ["branch", "git"]) - } - if !portTokens.isEmpty { - contextKeywords.append(contentsOf: ["port", "ports"]) - } - if !descriptionTokens.isEmpty { - contextKeywords.append(contentsOf: ["description", "descriptions", "notes", "note"]) - } - - return contextKeywords + directoryTokens + branchTokens + portTokens + descriptionTokens - } - - private static func directoryTokensForSearch( - _ rawDirectory: String, - detail: MetadataDetail - ) -> [String] { - let trimmed = rawDirectory.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return [] } - - let standardized = (trimmed as NSString).standardizingPath - let canonical = standardized.isEmpty ? trimmed : standardized - let abbreviated = (canonical as NSString).abbreviatingWithTildeInPath - switch detail { - case .workspace: - return uniqueNormalizedPreservingOrder([trimmed, canonical, abbreviated]) - case .surface: - let basename = URL(fileURLWithPath: canonical, isDirectory: true).lastPathComponent - let components = canonical.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } - return uniqueNormalizedPreservingOrder( - [trimmed, canonical, abbreviated, basename] + components - ) - } - } - - private static func branchTokensForSearch( - _ rawBranch: String, - detail: MetadataDetail - ) -> [String] { - let trimmed = rawBranch.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return [] } - switch detail { - case .workspace: - return [trimmed] - case .surface: - let components = trimmed.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } - return uniqueNormalizedPreservingOrder([trimmed] + components) - } - } - - private static func portTokensForSearch(_ port: Int) -> [String] { - guard (1...65535).contains(port) else { return [] } - let portText = String(port) - return [portText, ":\(portText)"] - } - - private static func descriptionTokensForSearch(_ rawDescription: String?) -> [String] { - let trimmed = rawDescription?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !trimmed.isEmpty else { return [] } - let normalizedWhitespace = trimmed.replacingOccurrences( - of: "\\s+", - with: " ", - options: .regularExpression - ) - let components = normalizedWhitespace.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } - return uniqueNormalizedPreservingOrder([trimmed, normalizedWhitespace] + components) - } - - private static func uniqueNormalizedPreservingOrder(_ values: [String]) -> [String] { - var result: [String] = [] - var seen: Set = [] - result.reserveCapacity(values.count) +private struct SidebarResizerAccessibilityModifier: ViewModifier { + let accessibilityIdentifier: String? - for value in values { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - let normalizedKey = trimmed - .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) - .lowercased() - guard seen.insert(normalizedKey).inserted else { continue } - result.append(trimmed) + @ViewBuilder + func body(content: Content) -> some View { + if let accessibilityIdentifier { + content.accessibilityIdentifier(accessibilityIdentifier) + } else { + content } - return result } } -enum CommandPaletteFuzzyMatcher { - private static let tokenBoundaryChars: Set = [" ", "-", "_", "/", ".", ":"] - - private enum SingleEditWordPrefixEditKind { - case candidateExtraCharacter - case tokenExtraCharacter - case substitutedCharacter - case transposedCharacters - - var basePenalty: Int { - switch self { - case .candidateExtraCharacter: - return 0 - case .tokenExtraCharacter: - return 10 - case .transposedCharacters: - return 24 - case .substitutedCharacter: - return 40 - } - } - } - - private struct SingleEditWordPrefixMatch { - let matchedIndices: Set - let segmentStart: Int - let segmentLength: Int - let prefixLength: Int - let editPosition: Int - let editKind: SingleEditWordPrefixEditKind - } - - struct PreparedQuery { - let normalizedText: String - let tokens: [String] - - var isEmpty: Bool { - tokens.isEmpty - } - } - - static func preparedQuery(_ query: String) -> PreparedQuery { - let normalizedQuery = normalizeForSearch(query) - return PreparedQuery( - normalizedText: normalizedQuery, - tokens: normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty } - ) - } - - static func normalizeForSearch(_ text: String) -> String { - text - .trimmingCharacters(in: .whitespacesAndNewlines) - .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) - .lowercased() - } - - static func score(query: String, candidate: String) -> Int? { - score(query: query, candidates: [candidate]) - } - - static func score(query: String, candidates: [String]) -> Int? { - score( - preparedQuery: preparedQuery(query), - normalizedCandidates: candidates - .map(normalizeForSearch) - .filter { !$0.isEmpty } - ) - } - - static func score(preparedQuery: PreparedQuery, normalizedCandidates: [String]) -> Int? { - guard !preparedQuery.isEmpty else { return 0 } - guard !normalizedCandidates.isEmpty else { return nil } - - var totalScore = 0 - for token in preparedQuery.tokens { - var bestTokenScore: Int? - for candidate in normalizedCandidates { - guard let candidateScore = scoreToken(token, in: candidate) else { continue } - bestTokenScore = max(bestTokenScore ?? candidateScore, candidateScore) - } - guard let bestTokenScore else { return nil } - totalScore += bestTokenScore - } - return totalScore - } - - static func matchCharacterIndices(query: String, candidate: String) -> Set { - matchCharacterIndices(preparedQuery: preparedQuery(query), candidate: candidate) - } - - static func matchCharacterIndices(preparedQuery: PreparedQuery, candidate: String) -> Set { - guard !preparedQuery.isEmpty else { return [] } - - let loweredCandidate = normalizeForSearch(candidate) - guard !loweredCandidate.isEmpty else { return [] } - - let candidateChars = Array(loweredCandidate) - var matched: Set = [] - - for token in preparedQuery.tokens { - if token == loweredCandidate { - matched.formUnion(0.. Int? { - guard !token.isEmpty else { return 0 } - - let candidateChars = Array(candidate) - let tokenChars = Array(token) - guard tokenChars.count <= candidateChars.count else { return nil } - - if token == candidate { - return 8000 - } - if candidate.hasPrefix(token) { - return 6800 - max(0, candidate.count - token.count) - } - - var bestScore: Int? - if let wordExactScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: true) { - bestScore = max(bestScore ?? wordExactScore, wordExactScore) - } - if let wordPrefixScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: false) { - bestScore = max(bestScore ?? wordPrefixScore, wordPrefixScore) - } - if let singleEditPrefixScore = singleEditWordPrefixScore( - tokenChars: tokenChars, - candidateChars: candidateChars - ) { - bestScore = max(bestScore ?? singleEditPrefixScore, singleEditPrefixScore) - } - - if let range = candidate.range(of: token) { - let distance = candidate.distance(from: candidate.startIndex, to: range.lowerBound) - let lengthPenalty = max(0, candidate.count - token.count) - let boundaryBoost: Int = { - guard distance > 0 else { return 220 } - let prior = candidateChars[distance - 1] - return tokenBoundaryChars.contains(prior) ? 180 : 0 - }() - let containsScore = 4200 + boundaryBoost - (distance * 9) - lengthPenalty - bestScore = max(bestScore ?? containsScore, containsScore) - } - - if let initialismScore = initialismScore(tokenChars: tokenChars, candidateChars: candidateChars) { - bestScore = max(bestScore ?? initialismScore, initialismScore) - } - - if let stitchedScore = stitchedWordPrefixScore(tokenChars: tokenChars, candidateChars: candidateChars) { - bestScore = max(bestScore ?? stitchedScore, stitchedScore) - } - - if tokenChars.count <= 3, let subsequence = subsequenceScore(token: token, candidate: candidate) { - bestScore = max(bestScore ?? subsequence, subsequence) - } - - guard let bestScore else { return nil } - return max(1, bestScore) - } - - private static func bestWordScore( - tokenChars: [Character], - candidateChars: [Character], - requireExactWord: Bool - ) -> Int? { - guard !tokenChars.isEmpty else { return nil } - - var best: Int? - for segment in wordSegments(candidateChars) { - let wordLength = segment.end - segment.start - guard tokenChars.count <= wordLength else { continue } - - var matchesPrefix = true - for offset in 0.. Int? { - guard let match = singleEditWordPrefixMatch( - tokenChars: tokenChars, - candidateChars: candidateChars - ) else { - return nil - } - return singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) - } - - private static func singleEditWordPrefixScore( - match: SingleEditWordPrefixMatch, - candidateLength: Int - ) -> Int { - let lengthPenalty = max(0, match.segmentLength - match.prefixLength) * 6 - let distancePenalty = match.segmentStart * 8 - let trailingPenalty = max(0, candidateLength - match.segmentLength) - let editPositionPenalty = max(0, match.editPosition - match.segmentStart) * 10 - return 5000 - - match.editKind.basePenalty - - distancePenalty - - lengthPenalty - - trailingPenalty - - editPositionPenalty - } - - private static func initialismScore(tokenChars: [Character], candidateChars: [Character]) -> Int? { - guard !tokenChars.isEmpty else { return nil } - let segments = wordSegments(candidateChars) - guard tokenChars.count <= segments.count else { return nil } - - var matchedStarts: [Int] = [] - var searchWordIndex = 0 - - for tokenChar in tokenChars { - var found = false - while searchWordIndex < segments.count { - let segment = segments[searchWordIndex] - searchWordIndex += 1 - if candidateChars[segment.start] == tokenChar { - matchedStarts.append(segment.start) - found = true - break - } - } - if !found { return nil } - } - - let firstStart = matchedStarts.first ?? 0 - let skippedWords = max(0, segments.count - tokenChars.count) - return 3000 + (tokenChars.count * 160) - (firstStart * 5) - (skippedWords * 30) - } - - private static func tokenPrefixMatches( - tokenChars: [Character], - tokenStart: Int, - length: Int, - candidateChars: [Character], - candidateStart: Int - ) -> Bool { - guard length >= 0 else { return false } - guard tokenStart + length <= tokenChars.count else { return false } - guard candidateStart + length <= candidateChars.count else { return false } - guard length > 0 else { return true } - - for offset in 0.. Int? { - guard tokenChars.count >= 4 else { return nil } - let segments = wordSegments(candidateChars) - guard segments.count >= 2 else { return nil } - - struct StitchState: Hashable { - let tokenIndex: Int - let wordIndex: Int - let usedWords: Int - } - - var memo: [StitchState: Int?] = [:] - - func dfs(tokenIndex: Int, wordIndex: Int, usedWords: Int) -> Int? { - if tokenIndex == tokenChars.count { - return usedWords >= 2 ? 0 : nil - } - guard wordIndex < segments.count else { return nil } - - let state = StitchState(tokenIndex: tokenIndex, wordIndex: wordIndex, usedWords: usedWords) - if let cached = memo[state] { - return cached - } - - var best: Int? - let remainingChars = tokenChars.count - tokenIndex - for segmentIndex in wordIndex.. 0 else { continue } - - let skippedWords = max(0, segmentIndex - wordIndex) - let skipPenalty = skippedWords * 120 - for chunkLength in stride(from: maxChunk, through: 1, by: -1) { - guard tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: tokenIndex, - length: chunkLength, - candidateChars: candidateChars, - candidateStart: segment.start - ) else { - continue - } - guard let suffixScore = dfs( - tokenIndex: tokenIndex + chunkLength, - wordIndex: segmentIndex + 1, - usedWords: min(2, usedWords + 1) - ) else { - continue - } - - let chunkCoverage = chunkLength * 220 - let contiguityBonus = segmentIndex == wordIndex ? 80 : 0 - let segmentRemainderPenalty = max(0, segmentLength - chunkLength) * 9 - let distancePenalty = segment.start * 4 - let chunkScore = chunkCoverage + contiguityBonus - segmentRemainderPenalty - distancePenalty - skipPenalty - let totalScore = suffixScore + chunkScore - best = max(best ?? totalScore, totalScore) - } - } - - memo[state] = best - return best - } - - guard let stitchedScore = dfs(tokenIndex: 0, wordIndex: 0, usedWords: 0) else { return nil } - let lengthPenalty = max(0, candidateChars.count - tokenChars.count) - return 3500 + stitchedScore - lengthPenalty - } - - private static func stitchedWordPrefixMatchIndices(token: String, candidate: String) -> Set? { - let tokenChars = Array(token) - let candidateChars = Array(candidate) - guard tokenChars.count >= 4 else { return nil } - - let segments = wordSegments(candidateChars) - guard segments.count >= 2 else { return nil } - - var tokenIndex = 0 - var nextWordIndex = 0 - var usedWords = 0 - var matchedIndices: Set = [] - - while tokenIndex < tokenChars.count { - let remainingChars = tokenChars.count - tokenIndex - var foundMatch = false - - for segmentIndex in nextWordIndex.. 0 else { continue } - - for chunkLength in stride(from: maxChunk, through: 1, by: -1) { - guard tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: tokenIndex, - length: chunkLength, - candidateChars: candidateChars, - candidateStart: segment.start - ) else { - continue - } - - matchedIndices.formUnion(segment.start..<(segment.start + chunkLength)) - tokenIndex += chunkLength - nextWordIndex = segmentIndex + 1 - usedWords += 1 - foundMatch = true - break - } - - if foundMatch { break } - } - - if !foundMatch { return nil } - } - - guard usedWords >= 2 else { return nil } - return matchedIndices - } - - private static func singleEditWordPrefixMatch( - token: String, - candidate: String - ) -> SingleEditWordPrefixMatch? { - singleEditWordPrefixMatch( - tokenChars: Array(token), - candidateChars: Array(candidate) - ) - } - - private static func singleEditWordPrefixMatch( - tokenChars: [Character], - candidateChars: [Character] - ) -> SingleEditWordPrefixMatch? { - guard tokenChars.count >= 4 else { return nil } - - var bestMatch: SingleEditWordPrefixMatch? - var bestScore: Int? - - for segment in wordSegments(candidateChars) { - guard let match = singleEditWordPrefixMatch( - tokenChars: tokenChars, - candidateChars: candidateChars, - segment: segment - ) else { - continue - } - - let score = singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) - if let bestScore, score <= bestScore { - continue - } - bestScore = score - bestMatch = match - } - - return bestMatch - } - - private static func singleEditWordPrefixMatch( - tokenChars: [Character], - candidateChars: [Character], - segment: (start: Int, end: Int) - ) -> SingleEditWordPrefixMatch? { - guard tokenChars.count >= 4 else { return nil } - - let segmentLength = segment.end - segment.start - guard segmentLength + 1 >= tokenChars.count else { return nil } - - let exactPrefixLength = min(tokenChars.count, segmentLength) - var mismatchOffset = 0 - while mismatchOffset < exactPrefixLength, - candidateChars[segment.start + mismatchOffset] == tokenChars[mismatchOffset] - { - mismatchOffset += 1 - } - - if mismatchOffset == tokenChars.count { - let prefixLength = tokenChars.count + 1 - guard segmentLength >= prefixLength else { return nil } - return SingleEditWordPrefixMatch( - matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: prefixLength, - editPosition: segment.start + tokenChars.count, - editKind: .candidateExtraCharacter - ) - } - - if mismatchOffset == segmentLength { - let prefixLength = tokenChars.count - 1 - guard prefixLength > 0 else { return nil } - guard tokenChars.count == segmentLength + 1 else { return nil } - return SingleEditWordPrefixMatch( - matchedIndices: Set(segment.start..<(segment.start + prefixLength)), - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: prefixLength, - editPosition: segment.start + prefixLength, - editKind: .tokenExtraCharacter - ) - } - - let mismatchCandidateIndex = segment.start + mismatchOffset - - if segmentLength >= tokenChars.count + 1, - tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: mismatchOffset, - length: tokenChars.count - mismatchOffset, - candidateChars: candidateChars, - candidateStart: mismatchCandidateIndex + 1 - ) - { - var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count + 1)) - matchedIndices.remove(mismatchCandidateIndex) - return SingleEditWordPrefixMatch( - matchedIndices: matchedIndices, - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: tokenChars.count + 1, - editPosition: mismatchCandidateIndex, - editKind: .candidateExtraCharacter - ) - } - - if tokenChars.count >= 2, - segmentLength >= tokenChars.count - 1, - tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: mismatchOffset + 1, - length: tokenChars.count - mismatchOffset - 1, - candidateChars: candidateChars, - candidateStart: mismatchCandidateIndex - ) - { - return SingleEditWordPrefixMatch( - matchedIndices: Set(segment.start..<(segment.start + tokenChars.count - 1)), - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: tokenChars.count - 1, - editPosition: mismatchCandidateIndex, - editKind: .tokenExtraCharacter - ) - } - - if segmentLength >= tokenChars.count, - tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: mismatchOffset + 1, - length: tokenChars.count - mismatchOffset - 1, - candidateChars: candidateChars, - candidateStart: mismatchCandidateIndex + 1 - ) - { - var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count)) - matchedIndices.remove(mismatchCandidateIndex) - return SingleEditWordPrefixMatch( - matchedIndices: matchedIndices, - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: tokenChars.count, - editPosition: mismatchCandidateIndex, - editKind: .substitutedCharacter - ) - } - - if segmentLength >= tokenChars.count, - mismatchOffset + 1 < tokenChars.count, - mismatchCandidateIndex + 1 < segment.end, - tokenChars[mismatchOffset] == candidateChars[mismatchCandidateIndex + 1], - tokenChars[mismatchOffset + 1] == candidateChars[mismatchCandidateIndex], - tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: mismatchOffset + 2, - length: tokenChars.count - mismatchOffset - 2, - candidateChars: candidateChars, - candidateStart: mismatchCandidateIndex + 2 - ) - { - return SingleEditWordPrefixMatch( - matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: tokenChars.count, - editPosition: mismatchCandidateIndex, - editKind: .transposedCharacters - ) - } - - return nil - } - - private static func wordSegments(_ candidateChars: [Character]) -> [(start: Int, end: Int)] { - var segments: [(start: Int, end: Int)] = [] - var index = 0 - - while index < candidateChars.count { - while index < candidateChars.count, tokenBoundaryChars.contains(candidateChars[index]) { - index += 1 - } - guard index < candidateChars.count else { break } - let start = index - while index < candidateChars.count, !tokenBoundaryChars.contains(candidateChars[index]) { - index += 1 - } - segments.append((start: start, end: index)) - } - - return segments - } - - private static func subsequenceScore(token: String, candidate: String) -> Int? { - let tokenChars = Array(token) - let candidateChars = Array(candidate) - guard tokenChars.count <= candidateChars.count else { return nil } - - var searchIndex = 0 - var previousMatch = -1 - var consecutiveRun = 0 - var score = 0 - - for tokenChar in tokenChars { - var foundIndex: Int? - while searchIndex < candidateChars.count { - if candidateChars[searchIndex] == tokenChar { - foundIndex = searchIndex - break - } - searchIndex += 1 - } - guard let matchedIndex = foundIndex else { return nil } - - score += 90 - if matchedIndex == 0 || tokenBoundaryChars.contains(candidateChars[matchedIndex - 1]) { - score += 140 - } - if matchedIndex == previousMatch + 1 { - consecutiveRun += 1 - score += min(200, consecutiveRun * 45) - } else { - consecutiveRun = 0 - score -= min(120, max(0, matchedIndex - previousMatch - 1) * 4) - } - - previousMatch = matchedIndex - searchIndex = matchedIndex + 1 - } - - score -= max(0, candidateChars.count - tokenChars.count) - return max(1, score) - } - - private static func subsequenceMatchIndices(token: String, candidate: String) -> Set? { - let tokenChars = Array(token) - let candidateChars = Array(candidate) - guard tokenChars.count <= candidateChars.count else { return nil } - - var indices: Set = [] - var searchIndex = 0 - - for tokenChar in tokenChars { - var foundIndex: Int? - while searchIndex < candidateChars.count { - if candidateChars[searchIndex] == tokenChar { - foundIndex = searchIndex - break - } - searchIndex += 1 - } - guard let matchIndex = foundIndex else { return nil } - indices.insert(matchIndex) - searchIndex = matchIndex + 1 - } - - return indices - } - - private static func initialismMatchIndices(token: String, candidate: String) -> Set? { - let tokenChars = Array(token) - let candidateChars = Array(candidate) - guard !tokenChars.isEmpty else { return nil } - - let segments = wordSegments(candidateChars) - guard tokenChars.count <= segments.count else { return nil } - - var matched: Set = [] - var searchWordIndex = 0 - - for tokenChar in tokenChars { - var found = false - while searchWordIndex < segments.count { - let segment = segments[searchWordIndex] - searchWordIndex += 1 - if candidateChars[segment.start] == tokenChar { - matched.insert(segment.start) - found = true - break - } - } - if !found { return nil } - } - - return matched - } -} - -struct CommandPaletteSearchCorpusEntry: Sendable where Payload: Sendable { - let payload: Payload - let rank: Int - let title: String - let normalizedTitle: String - let normalizedSearchableTexts: [String] - - init(payload: Payload, rank: Int, title: String, searchableTexts: [String]) { - self.payload = payload - self.rank = rank - self.title = title - self.normalizedTitle = CommandPaletteFuzzyMatcher.normalizeForSearch(title) - self.normalizedSearchableTexts = searchableTexts - .map(CommandPaletteFuzzyMatcher.normalizeForSearch) - .filter { !$0.isEmpty } - } -} - -struct CommandPaletteSearchCorpusResult: Sendable where Payload: Sendable { - let payload: Payload - let rank: Int - let title: String - let score: Int - let titleMatchIndices: Set -} - -enum CommandPaletteSearchEngine { - private static let titleMatchBonus = 2000 - - static func search( - entries: [CommandPaletteSearchCorpusEntry], - query: String, - historyBoost: (Payload, Bool) -> Int - ) -> [CommandPaletteSearchCorpusResult] { - search( - entries: entries, - query: query, - historyBoost: historyBoost, - shouldCancel: nil - ) - } - - static func search( - entries: [CommandPaletteSearchCorpusEntry], - query: String, - historyBoost: (Payload, Bool) -> Int, - shouldCancel: @escaping () -> Bool - ) -> [CommandPaletteSearchCorpusResult] { - search( - entries: entries, - query: query, - historyBoost: historyBoost, - shouldCancel: Optional(shouldCancel) - ) - } - - private static func search( - entries: [CommandPaletteSearchCorpusEntry], - query: String, - historyBoost: (Payload, Bool) -> Int, - shouldCancel: (() -> Bool)? - ) -> [CommandPaletteSearchCorpusResult] { - let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) - let queryIsEmpty = preparedQuery.isEmpty - var results: [CommandPaletteSearchCorpusResult] = [] - results.reserveCapacity(entries.count) - - func shouldCancelSearch(at index: Int) -> Bool { - guard let shouldCancel else { return false } - return index % 16 == 0 && shouldCancel() - } - - if queryIsEmpty { - for (index, entry) in entries.enumerated() { - if shouldCancelSearch(at: index) { return [] } - results.append( - CommandPaletteSearchCorpusResult( - payload: entry.payload, - rank: entry.rank, - title: entry.title, - score: historyBoost(entry.payload, true), - titleMatchIndices: [] - ) - ) - } - } else { - for (index, entry) in entries.enumerated() { - if shouldCancelSearch(at: index) { return [] } - guard let fuzzyScore = weightedScore( - preparedQuery: preparedQuery, - entry: entry - ) else { - continue - } - results.append( - CommandPaletteSearchCorpusResult( - payload: entry.payload, - rank: entry.rank, - title: entry.title, - score: fuzzyScore + historyBoost(entry.payload, false), - titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices( - preparedQuery: preparedQuery, - candidate: entry.title - ) - ) - ) - } - } - - if shouldCancel?() == true { return [] } - - return results.sorted { lhs, rhs in - if lhs.score != rhs.score { return lhs.score > rhs.score } - if lhs.rank != rhs.rank { return lhs.rank < rhs.rank } - return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending - } - } - - private static func weightedScore( - preparedQuery: CommandPaletteFuzzyMatcher.PreparedQuery, - entry: CommandPaletteSearchCorpusEntry - ) -> Int? { - guard let fuzzyScore = CommandPaletteFuzzyMatcher.score( - preparedQuery: preparedQuery, - normalizedCandidates: entry.normalizedSearchableTexts - ) else { - return nil - } - guard !entry.normalizedTitle.isEmpty, - let titleScore = CommandPaletteFuzzyMatcher.score( - preparedQuery: preparedQuery, - normalizedCandidates: [entry.normalizedTitle] - ) else { - return fuzzyScore - } - return max(fuzzyScore, titleScore + titleMatchBonus) - } -} - -private struct SidebarResizerAccessibilityModifier: ViewModifier { - let accessibilityIdentifier: String? - - @ViewBuilder - func body(content: Content) -> some View { - if let accessibilityIdentifier { - content.accessibilityIdentifier(accessibilityIdentifier) - } else { - content - } - } -} - -private struct SidebarTabItemSettingsSnapshot: Equatable { +struct SidebarTabItemSettingsSnapshot: Equatable { let sidebarShortcutHintXOffset: Double let sidebarShortcutHintYOffset: Double let alwaysShowShortcutHints: Bool @@ -9168,254 +7741,16 @@ enum DevBuildBannerDebugSettings { } } -enum SidebarDragLifecycleNotification { - static let stateDidChange = Notification.Name("programa.sidebarDragStateDidChange") - static let requestClear = Notification.Name("programa.sidebarDragRequestClear") - static let tabIdKey = "tabId" - static let reasonKey = "reason" - - static func postStateDidChange(tabId: UUID?, reason: String) { - var userInfo: [AnyHashable: Any] = [reasonKey: reason] - if let tabId { - userInfo[tabIdKey] = tabId - } - NotificationCenter.default.post( - name: stateDidChange, - object: nil, - userInfo: userInfo - ) - } +private final class SidebarShortcutHintModifierMonitor: ObservableObject { + @Published private(set) var isModifierPressed = false - static func postClearRequest(reason: String) { - NotificationCenter.default.post( - name: requestClear, - object: nil, - userInfo: [reasonKey: reason] - ) - } - - static func tabId(from notification: Notification) -> UUID? { - notification.userInfo?[tabIdKey] as? UUID - } - - static func reason(from notification: Notification) -> String { - notification.userInfo?[reasonKey] as? String ?? "unknown" - } -} - -enum SidebarOutsideDropResetPolicy { - static func shouldResetDrag(draggedTabId: UUID?, hasSidebarDragPayload: Bool) -> Bool { - draggedTabId != nil && hasSidebarDragPayload - } -} - -enum SidebarDragFailsafePolicy { - static let clearDelay: TimeInterval = 0.15 - - static func shouldRequestClear(isDragActive: Bool, isLeftMouseButtonDown: Bool) -> Bool { - isDragActive && !isLeftMouseButtonDown - } - - static func shouldRequestClearWhenMonitoringStarts(isLeftMouseButtonDown: Bool) -> Bool { - shouldRequestClear( - isDragActive: true, - isLeftMouseButtonDown: isLeftMouseButtonDown - ) - } - - static func shouldRequestClear(forMouseEventType eventType: NSEvent.EventType) -> Bool { - eventType == .leftMouseUp - } -} - -@MainActor -private final class SidebarDragFailsafeMonitor: ObservableObject { - private static let escapeKeyCode: UInt16 = 53 - private var pendingClearWorkItem: DispatchWorkItem? - private var appResignObserver: NSObjectProtocol? - private var keyDownMonitor: Any? - private var localMouseMonitor: Any? - private var globalMouseMonitor: Any? - private var onRequestClear: ((String) -> Void)? - - func start(onRequestClear: @escaping (String) -> Void) { - self.onRequestClear = onRequestClear - if SidebarDragFailsafePolicy.shouldRequestClearWhenMonitoringStarts( - isLeftMouseButtonDown: CGEventSource.buttonState( - .combinedSessionState, - button: .left - ) - ) { - requestClearSoon(reason: "mouse_up_failsafe") - } - if appResignObserver == nil { - appResignObserver = NotificationCenter.default.addObserver( - forName: NSApplication.didResignActiveNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.requestClearSoon(reason: "app_resign_active") - } - } - } - if keyDownMonitor == nil { - keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - if event.keyCode == Self.escapeKeyCode { - self?.requestClearSoon(reason: "escape_cancel") - } - return event - } - } - if localMouseMonitor == nil { - localMouseMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in - if SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) { - self?.requestClearSoon(reason: "mouse_up_failsafe") - } - return event - } - } - if globalMouseMonitor == nil { - globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in - guard SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) else { return } - Task { @MainActor [weak self] in - self?.requestClearSoon(reason: "mouse_up_failsafe") - } - } - } - } - - func stop() { - pendingClearWorkItem?.cancel() - pendingClearWorkItem = nil - if let appResignObserver { - NotificationCenter.default.removeObserver(appResignObserver) - self.appResignObserver = nil - } - if let keyDownMonitor { - NSEvent.removeMonitor(keyDownMonitor) - self.keyDownMonitor = nil - } - if let localMouseMonitor { - NSEvent.removeMonitor(localMouseMonitor) - self.localMouseMonitor = nil - } - if let globalMouseMonitor { - NSEvent.removeMonitor(globalMouseMonitor) - self.globalMouseMonitor = nil - } - onRequestClear = nil - } - - private func requestClearSoon(reason: String) { - guard pendingClearWorkItem == nil else { return } -#if DEBUG - dlog("sidebar.dragFailsafe.schedule reason=\(reason)") -#endif - let workItem = DispatchWorkItem { [weak self] in -#if DEBUG - dlog("sidebar.dragFailsafe.fire reason=\(reason)") -#endif - self?.pendingClearWorkItem = nil - self?.onRequestClear?(reason) - } - pendingClearWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + SidebarDragFailsafePolicy.clearDelay, execute: workItem) - } -} - -private struct SidebarExternalDropOverlay: View { - let draggedTabId: UUID? - - var body: some View { - let dragPasteboardTypes = NSPasteboard(name: .drag).types - let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureSidebarExternalOverlay( - draggedTabId: draggedTabId, - pasteboardTypes: dragPasteboardTypes - ) - Group { - if shouldCapture { - Color.clear - .contentShape(Rectangle()) - .allowsHitTesting(true) - .onDrop( - of: SidebarTabDragPayload.dropContentTypes, - delegate: SidebarExternalDropDelegate(draggedTabId: draggedTabId) - ) - } else { - Color.clear - .contentShape(Rectangle()) - .allowsHitTesting(false) - } - } - } -} - -private struct SidebarExternalDropDelegate: DropDelegate { - let draggedTabId: UUID? - - func validateDrop(info: DropInfo) -> Bool { - let hasSidebarPayload = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) - let shouldReset = SidebarOutsideDropResetPolicy.shouldResetDrag( - draggedTabId: draggedTabId, - hasSidebarDragPayload: hasSidebarPayload - ) -#if DEBUG - dlog( - "sidebar.dropOutside.validate tab=\(debugShortSidebarTabId(draggedTabId)) " + - "hasType=\(hasSidebarPayload) allowed=\(shouldReset)" - ) -#endif - return shouldReset - } - - func dropEntered(info: DropInfo) { -#if DEBUG - dlog("sidebar.dropOutside.entered tab=\(debugShortSidebarTabId(draggedTabId))") -#endif - } - - func dropExited(info: DropInfo) { -#if DEBUG - dlog("sidebar.dropOutside.exited tab=\(debugShortSidebarTabId(draggedTabId))") -#endif - } - - func dropUpdated(info: DropInfo) -> DropProposal? { - guard validateDrop(info: info) else { return nil } -#if DEBUG - dlog("sidebar.dropOutside.updated tab=\(debugShortSidebarTabId(draggedTabId)) op=move") -#endif - // Explicit move proposal avoids AppKit showing a copy (+) cursor. - return DropProposal(operation: .move) - } - - func performDrop(info: DropInfo) -> Bool { - guard validateDrop(info: info) else { return false } -#if DEBUG - dlog("sidebar.dropOutside.perform tab=\(debugShortSidebarTabId(draggedTabId))") -#endif - SidebarDragLifecycleNotification.postClearRequest(reason: "outside_sidebar_drop") - return true - } - - private func debugShortSidebarTabId(_ id: UUID?) -> String { - guard let id else { return "nil" } - return String(id.uuidString.prefix(5)) - } -} - -@MainActor -private final class SidebarShortcutHintModifierMonitor: ObservableObject { - @Published private(set) var isModifierPressed = false - - 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? + 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 } @@ -9565,4041 +7900,3 @@ private final class SidebarShortcutHintModifierMonitor: ObservableObject { } } -private struct SidebarFooter: View { - @ObservedObject var updateViewModel: UpdateViewModel - let onSendFeedback: () -> Void - - var body: some View { -#if DEBUG - SidebarDevFooter(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) -#else - SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) - .padding(.leading, 6) - .padding(.trailing, 10) - .padding(.bottom, 6) -#endif - } -} - -private struct SidebarFooterButtons: View { - @ObservedObject var updateViewModel: UpdateViewModel - let onSendFeedback: () -> Void - - var body: some View { - HStack(spacing: 4) { - SidebarHelpMenuButton(onSendFeedback: onSendFeedback) - UpdatePill(model: updateViewModel) - } - .frame(maxWidth: .infinity, alignment: .leading) - } -} - -private enum SidebarHelpMenuAction { - case importBrowserData - case keyboardShortcuts - case docs - case changelog - case github - case githubIssues - case discord - case checkForUpdates - case sendFeedback - case welcome -} - -private struct SidebarHelpMenuButton: View { - private let docsURL = URL(string: "https://github.com/darkroomengineering/programa/tree/main/docs") - private let changelogURL = URL(string: "https://github.com/darkroomengineering/programa/blob/main/CHANGELOG.md") - private let githubURL = URL(string: "https://github.com/darkroomengineering/programa") - private let githubIssuesURL = URL(string: "https://github.com/darkroomengineering/programa/issues") - private let discordURL = URL(string: "https://discord.gg/xsgFEVrWCZ") - private let helpTitle = String(localized: "sidebar.help.button", defaultValue: "Help") - private let buttonSize: CGFloat = 22 - private let iconSize: CGFloat = 11 - @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared - - let onSendFeedback: () -> Void - - @State private var isPopoverPresented = false - - private var sendFeedbackShortcutHint: String { - let _ = keyboardShortcutSettingsObserver.revision - return KeyboardShortcutSettings.shortcut(for: .sendFeedback).displayString - } - - var body: some View { - Button { - isPopoverPresented.toggle() - } label: { - Image(systemName: "questionmark.circle") - .symbolRenderingMode(.monochrome) - .font(.system(size: iconSize, weight: .medium)) - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) - .frame(width: buttonSize, height: buttonSize, alignment: .center) - } - .buttonStyle(SidebarFooterIconButtonStyle()) - .frame(width: buttonSize, height: buttonSize, alignment: .center) - .background(ArrowlessPopoverAnchor( - isPresented: $isPopoverPresented, - preferredEdge: .maxY, - detachedGap: 4 - ) { - helpPopover - }) - .accessibilityElement(children: .ignore) - .safeHelp(helpTitle) - .accessibilityLabel(helpTitle) - .accessibilityIdentifier("SidebarHelpMenuButton") - } - - private var helpPopover: some View { - VStack(alignment: .leading, spacing: 2) { - helpOptionButton( - title: String(localized: "sidebar.help.welcome", defaultValue: "Welcome to Programa!"), - action: .welcome, - accessibilityIdentifier: "SidebarHelpMenuOptionWelcome", - isExternalLink: false - ) - helpOptionButton( - title: String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback"), - action: .sendFeedback, - accessibilityIdentifier: "SidebarHelpMenuOptionSendFeedback", - isExternalLink: false, - shortcutHint: sendFeedbackShortcutHint, - trailingSystemImage: "bubble.left.and.text.bubble.right" - ) - helpOptionButton( - title: String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts"), - action: .keyboardShortcuts, - accessibilityIdentifier: "SidebarHelpMenuOptionKeyboardShortcuts", - isExternalLink: false - ) - helpOptionButton( - title: String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…"), - action: .importBrowserData, - accessibilityIdentifier: "SidebarHelpMenuOptionImportBrowserData", - isExternalLink: false - ) - if docsURL != nil { - helpOptionButton( - title: String(localized: "about.docs", defaultValue: "Docs"), - action: .docs, - accessibilityIdentifier: "SidebarHelpMenuOptionDocs", - isExternalLink: true - ) - } - if changelogURL != nil { - helpOptionButton( - title: String(localized: "sidebar.help.changelog", defaultValue: "Changelog"), - action: .changelog, - accessibilityIdentifier: "SidebarHelpMenuOptionChangelog", - isExternalLink: true - ) - } - if githubURL != nil { - helpOptionButton( - title: String(localized: "about.github", defaultValue: "GitHub"), - action: .github, - accessibilityIdentifier: "SidebarHelpMenuOptionGitHub", - isExternalLink: true - ) - } - if githubIssuesURL != nil { - helpOptionButton( - title: String(localized: "sidebar.help.githubIssues", defaultValue: "GitHub Issues"), - action: .githubIssues, - accessibilityIdentifier: "SidebarHelpMenuOptionGitHubIssues", - isExternalLink: true - ) - } - if discordURL != nil { - helpOptionButton( - title: String(localized: "sidebar.help.discord", defaultValue: "Discord"), - action: .discord, - accessibilityIdentifier: "SidebarHelpMenuOptionDiscord", - isExternalLink: true - ) - } - helpOptionButton( - title: String(localized: "command.checkForUpdates.title", defaultValue: "Check for Updates"), - action: .checkForUpdates, - accessibilityIdentifier: "SidebarHelpMenuOptionCheckForUpdates", - isExternalLink: false - ) - } - .padding(8) - .frame(minWidth: 200) - } - - private func helpOptionButton( - title: String, - action: SidebarHelpMenuAction, - accessibilityIdentifier: String, - isExternalLink: Bool, - shortcutHint: String? = nil, - trailingSystemImage: String? = nil - ) -> some View { - Button { - isPopoverPresented = false - perform(action) - } label: { - HStack(spacing: 8) { - Text(title) - .font(.system(size: 12)) - Spacer(minLength: 0) - if let shortcutHint { - helpOptionShortcutHint(text: shortcutHint) - } - if let trailingSystemImage { - helpOptionTrailingIcon(systemName: trailingSystemImage) - } - if isExternalLink { - helpOptionTrailingIcon(systemName: "arrow.up.right", size: 8) - } - } - .padding(.horizontal, 8) - .frame(height: 24) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityIdentifier(accessibilityIdentifier) - } - - private func helpOptionShortcutHint(text: String) -> some View { - Text(text) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - .font(.system(size: 10, weight: .regular, design: .rounded)) - .monospacedDigit() - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) - } - - private func helpOptionTrailingIcon(systemName: String, size: CGFloat = 13) -> some View { - Image(systemName: systemName) - .resizable() - .scaledToFit() - .frame(width: size, height: size) - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) - } - - private func perform(_ action: SidebarHelpMenuAction) { - switch action { - case .importBrowserData: - isPopoverPresented = false - DispatchQueue.main.async { - BrowserDataImportCoordinator.shared.presentImportDialog() - } - case .keyboardShortcuts: - isPopoverPresented = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { - Task { @MainActor in - if let appDelegate = AppDelegate.shared { - appDelegate.openPreferencesWindow( - debugSource: "sidebarHelpMenu.keyboardShortcuts", - navigationTarget: .keyboardShortcuts - ) - } else { - AppDelegate.presentPreferencesWindow(navigationTarget: .keyboardShortcuts) - } - } - } - case .docs: - guard let docsURL else { return } - NSWorkspace.shared.open(docsURL) - case .changelog: - guard let changelogURL else { return } - NSWorkspace.shared.open(changelogURL) - case .github: - guard let githubURL else { return } - NSWorkspace.shared.open(githubURL) - case .githubIssues: - guard let githubIssuesURL else { return } - NSWorkspace.shared.open(githubIssuesURL) - case .discord: - guard let discordURL else { return } - NSWorkspace.shared.open(discordURL) - case .checkForUpdates: - Task { @MainActor in - AppDelegate.shared?.checkForUpdates(nil) - } - case .sendFeedback: - isPopoverPresented = false - onSendFeedback() - case .welcome: - isPopoverPresented = false - Task { @MainActor in - if let appDelegate = AppDelegate.shared { - appDelegate.openWelcomeWorkspace() - } - } - } - } - -} - -private struct ArrowlessPopoverAnchor: NSViewRepresentable { - @Binding var isPresented: Bool - let preferredEdge: NSRectEdge - let detachedGap: CGFloat - @ViewBuilder let content: () -> PopoverContent - - func makeNSView(context: Context) -> NSView { - let view = NSView() - context.coordinator.anchorView = view - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - context.coordinator.anchorView = nsView - context.coordinator.updateRootView(AnyView(content())) - - if isPresented { - context.coordinator.present( - preferredEdge: preferredEdge, - detachedGap: detachedGap - ) - } else { - context.coordinator.dismiss() - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(isPresented: $isPresented) - } - - final class Coordinator: NSObject, NSPopoverDelegate { - @Binding var isPresented: Bool - - weak var anchorView: NSView? - private let hostingController = NSHostingController(rootView: AnyView(EmptyView())) - private var popover: NSPopover? - - init(isPresented: Binding) { - _isPresented = isPresented - } - - func updateRootView(_ rootView: AnyView) { - hostingController.rootView = AnyView(rootView.fixedSize()) - hostingController.view.invalidateIntrinsicContentSize() - hostingController.view.layoutSubtreeIfNeeded() - } - - func present(preferredEdge: NSRectEdge, detachedGap: CGFloat) { - guard let anchorView else { - isPresented = false - dismiss() - return - } - - let popover = popover ?? makePopover() - if popover.isShown { - return - } - - hostingController.view.invalidateIntrinsicContentSize() - hostingController.view.layoutSubtreeIfNeeded() - let fittingSize = hostingController.view.fittingSize - if fittingSize.width > 0, fittingSize.height > 0 { - popover.contentSize = NSSize( - width: ceil(fittingSize.width), - height: ceil(fittingSize.height) - ) - } - - popover.show( - relativeTo: positioningRect( - for: anchorView.bounds, - preferredEdge: preferredEdge, - detachedGap: detachedGap - ), - of: anchorView, - preferredEdge: preferredEdge - ) - } - - func dismiss() { - popover?.performClose(nil) - popover = nil - } - - func popoverDidClose(_ notification: Notification) { - popover = nil - if isPresented { - isPresented = false - } - } - - private func makePopover() -> NSPopover { - let popover = NSPopover() - popover.behavior = .semitransient - popover.animates = true - popover.setValue(true, forKeyPath: "shouldHideAnchor") - popover.contentViewController = hostingController - popover.delegate = self - self.popover = popover - return popover - } - - private func positioningRect( - for bounds: CGRect, - preferredEdge: NSRectEdge, - detachedGap: CGFloat - ) -> CGRect { - let hiddenArrowInset: CGFloat = 13 - let compensation = max(hiddenArrowInset - detachedGap, 0) - - switch preferredEdge { - case .maxY: - return NSRect( - x: bounds.minX, - y: bounds.maxY - compensation, - width: bounds.width, - height: compensation - ) - case .minY: - return NSRect( - x: bounds.minX, - y: bounds.minY, - width: bounds.width, - height: compensation - ) - case .maxX: - return NSRect( - x: bounds.maxX - compensation, - y: bounds.minY, - width: compensation, - height: bounds.height - ) - case .minX: - return NSRect( - x: bounds.minX, - y: bounds.minY, - width: compensation, - height: bounds.height - ) - @unknown default: - return bounds - } - } - } -} - -private struct SidebarFooterIconButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - SidebarFooterIconButtonStyleBody(configuration: configuration) - } -} - -private struct SidebarFooterIconButtonStyleBody: View { - let configuration: SidebarFooterIconButtonStyle.Configuration - - @Environment(\.isEnabled) private var isEnabled - @State private var isHovered = false - - private var backgroundOpacity: Double { - guard isEnabled else { return 0.0 } - if configuration.isPressed { return 0.16 } - if isHovered { return 0.08 } - return 0.0 - } - - var body: some View { - configuration.label - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color.primary.opacity(backgroundOpacity)) - ) - .onHover { hovering in - isHovered = hovering - } - .animation(.easeOut(duration: 0.12), value: isHovered) - .animation(.easeOut(duration: 0.08), value: configuration.isPressed) - } -} - -#if DEBUG -private struct SidebarDevFooter: View { - @ObservedObject var updateViewModel: UpdateViewModel - let onSendFeedback: () -> Void - @AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey) - private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) - if showSidebarDevBuildBanner { - Text(String(localized: "debug.devBuildBanner.title", defaultValue: "THIS IS A DEV BUILD")) - .font(.system(size: 11, weight: .semibold)) - .foregroundColor(.red) - } - } - .padding(.leading, 6) - .padding(.trailing, 10) - .padding(.bottom, 6) - } -} -#endif - -private struct SidebarTopScrim: View { - let height: CGFloat - - var body: some View { - SidebarTopBlurEffect() - .frame(height: height) - .mask( - LinearGradient( - colors: [ - Color.black.opacity(0.95), - Color.black.opacity(0.75), - Color.black.opacity(0.35), - Color.clear - ], - startPoint: .top, - endPoint: .bottom - ) - ) - } -} - -private struct SidebarTopBlurEffect: NSViewRepresentable { - func makeNSView(context: Context) -> NSVisualEffectView { - let view = NSVisualEffectView() - view.blendingMode = .withinWindow - view.material = .underWindowBackground - view.state = .active - view.isEmphasized = false - return view - } - - func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} -} - -private struct SidebarScrollViewResolver: NSViewRepresentable { - let onResolve: (NSScrollView?) -> Void - - func makeNSView(context: Context) -> SidebarScrollViewResolverView { - let view = SidebarScrollViewResolverView() - view.onResolve = onResolve - return view - } - - func updateNSView(_ nsView: SidebarScrollViewResolverView, context: Context) { - nsView.onResolve = onResolve - nsView.resolveScrollView() - } -} - -private final class SidebarScrollViewResolverView: NSView { - var onResolve: ((NSScrollView?) -> Void)? - - override func viewDidMoveToSuperview() { - super.viewDidMoveToSuperview() - resolveScrollView() - } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - resolveScrollView() - } - - func resolveScrollView() { - DispatchQueue.main.async { [weak self] in - guard let self else { return } - onResolve?(self.enclosingScrollView) - } - } -} - -private struct SidebarEmptyArea: View { - @EnvironmentObject var tabManager: TabManager - let rowSpacing: CGFloat - @Binding var selection: SidebarSelection - @Binding var selectedTabIds: Set - @Binding var lastSidebarSelectionIndex: Int? - let dragAutoScrollController: SidebarDragAutoScrollController - @Binding var draggedTabId: UUID? - @Binding var dropIndicator: SidebarDropIndicator? - - var body: some View { - Color.clear - .contentShape(Rectangle()) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onTapGesture(count: 2) { - tabManager.addWorkspace(placementOverride: .end) - if let selectedId = tabManager.selectedTabId { - selectedTabIds = [selectedId] - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } - } - selection = .tabs - } - .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate( - targetTabId: nil, - tabManager: tabManager, - draggedTabId: $draggedTabId, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - targetRowHeight: nil, - dragAutoScrollController: dragAutoScrollController, - dropIndicator: $dropIndicator - )) - .overlay(alignment: .top) { - if shouldShowTopDropIndicator { - Rectangle() - .fill(programaAccentColor()) - .frame(height: 2) - .padding(.horizontal, 8) - .offset(y: -(rowSpacing / 2)) - } - } - } - - private var shouldShowTopDropIndicator: Bool { - guard draggedTabId != nil, let indicator = dropIndicator else { return false } - if indicator.tabId == nil { - return true - } - guard indicator.edge == .bottom, let lastTabId = tabManager.tabs.last?.id else { return false } - return indicator.tabId == lastTabId - } -} - -enum SidebarPathFormatter { - static let homeDirectoryPath: String = FileManager.default.homeDirectoryForCurrentUser.path - - static func shortenedPath( - _ path: String, - homeDirectoryPath: String = Self.homeDirectoryPath - ) -> String { - let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return path } - if trimmed == homeDirectoryPath { - return "~" - } - if trimmed.hasPrefix(homeDirectoryPath + "/") { - return "~" + trimmed.dropFirst(homeDirectoryPath.count) - } - return trimmed - } -} - -enum SidebarWorkspaceShortcutHintMetrics { - private static let measurementFont = NSFont.systemFont(ofSize: 10, weight: .semibold) - private static let minimumSlotWidth: CGFloat = 28 - private static let horizontalPadding: CGFloat = 12 - private static let lock = NSLock() - private static var cachedHintWidths: [String: CGFloat] = [:] - #if DEBUG - private static var measurementCount = 0 - #endif - - static func slotWidth(label: String?, debugXOffset: Double) -> CGFloat { - guard let label else { return minimumSlotWidth } - let positiveDebugInset = max(0, CGFloat(ShortcutHintDebugSettings.clamped(debugXOffset))) + 2 - return max(minimumSlotWidth, hintWidth(for: label) + positiveDebugInset) - } - - static func hintWidth(for label: String) -> CGFloat { - lock.lock() - if let cached = cachedHintWidths[label] { - lock.unlock() - return cached - } - lock.unlock() - - let textWidth = (label as NSString).size(withAttributes: [.font: measurementFont]).width - let measuredWidth = ceil(textWidth) + horizontalPadding - - lock.lock() - cachedHintWidths[label] = measuredWidth - #if DEBUG - measurementCount += 1 - #endif - lock.unlock() - return measuredWidth - } - - #if DEBUG - static func resetCacheForTesting() { - lock.lock() - cachedHintWidths.removeAll() - measurementCount = 0 - lock.unlock() - } - - static func measurementCountForTesting() -> Int { - lock.lock() - let count = measurementCount - lock.unlock() - return count - } - #endif -} - -enum SidebarTrailingAccessoryWidthPolicy { - static let closeButtonWidth: CGFloat = 16 - - static func width( - canCloseWorkspace: Bool, - showsWorkspaceShortcutHint: Bool, - workspaceShortcutLabel: String?, - debugXOffset: Double - ) -> CGFloat { - if showsWorkspaceShortcutHint, let workspaceShortcutLabel { - return SidebarWorkspaceShortcutHintMetrics.slotWidth( - label: workspaceShortcutLabel, - debugXOffset: debugXOffset - ) - } - - return canCloseWorkspace ? closeButtonWidth : 0 - } -} - -// PERF: TabItemView is Equatable so SwiftUI skips body re-evaluation when -// the parent rebuilds with unchanged values. Without this, every TabManager -// or NotificationStore publish causes ALL tab items to re-evaluate (~18% of -// main thread during typing). If you add new properties, update == below. -// Reactive workspace state inside the row must not rely on parent diffs alone: -// `.equatable()` can otherwise leave sidebar badges/details stale until an -// unrelated parent change sneaks through. Keep the workspace reference plain -// and bridge only sidebar-visible workspace changes into local state. -// Do NOT add @EnvironmentObject or new @Binding without updating ==. -// Do NOT remove .equatable() from the ForEach call site in VerticalTabsSidebar. -private struct TabItemView: View, Equatable { - private static let workspaceObservationCoalesceInterval: RunLoop.SchedulerTimeType.Stride = .milliseconds(40) - - // Closures, Bindings, and object references are excluded from == - // because they're recreated every parent eval but don't affect rendering. - nonisolated static func == (lhs: TabItemView, rhs: TabItemView) -> Bool { - lhs.tab === rhs.tab && - lhs.index == rhs.index && - lhs.isActive == rhs.isActive && - lhs.workspaceShortcutDigit == rhs.workspaceShortcutDigit && - lhs.workspaceShortcutModifierSymbol == rhs.workspaceShortcutModifierSymbol && - lhs.canCloseWorkspace == rhs.canCloseWorkspace && - lhs.accessibilityWorkspaceCount == rhs.accessibilityWorkspaceCount && - lhs.unreadCount == rhs.unreadCount && - lhs.latestNotificationText == rhs.latestNotificationText && - lhs.rowSpacing == rhs.rowSpacing && - lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints && - lhs.contextMenuWorkspaceIds == rhs.contextMenuWorkspaceIds && - lhs.remoteContextMenuWorkspaceIds == rhs.remoteContextMenuWorkspaceIds && - lhs.allRemoteContextMenuTargetsConnecting == rhs.allRemoteContextMenuTargetsConnecting && - lhs.allRemoteContextMenuTargetsDisconnected == rhs.allRemoteContextMenuTargetsDisconnected && - lhs.settings == rhs.settings - } - - // Use plain references instead of @EnvironmentObject to avoid subscribing - // to ALL changes on these objects. Body reads use precomputed parameters; - // action handlers use the plain references without triggering re-evaluation. - let tabManager: TabManager - let notificationStore: TerminalNotificationStore - @Environment(\.colorScheme) private var colorScheme - let tab: Tab - let index: Int - let isActive: Bool - let workspaceShortcutDigit: Int? - let workspaceShortcutModifierSymbol: String - let canCloseWorkspace: Bool - let accessibilityWorkspaceCount: Int - let unreadCount: Int - let latestNotificationText: String? - let rowSpacing: CGFloat - let setSelectionToTabs: () -> Void - @Binding var selectedTabIds: Set - @Binding var lastSidebarSelectionIndex: Int? - let showsModifierShortcutHints: Bool - let dragAutoScrollController: SidebarDragAutoScrollController - @Binding var draggedTabId: UUID? - @Binding var dropIndicator: SidebarDropIndicator? - let contextMenuWorkspaceIds: [UUID] - let remoteContextMenuWorkspaceIds: [UUID] - let allRemoteContextMenuTargetsConnecting: Bool - let allRemoteContextMenuTargetsDisconnected: Bool - let settings: SidebarTabItemSettingsSnapshot - @State private var workspaceObservationGeneration: UInt64 = 0 - @State private var isHovering = false - @State private var rowHeight: CGFloat = 1 - // Cached results of the expensive bonsplit tree walk + branch/dir/PR snapshot. - // Updated only by the debounced publisher, onAppear, and settings changes — - // NOT by the immediate publisher (title keystrokes). This prevents the tree - // walk from running on every keystroke in single-panel workspaces. - @State private var cachedOrderedPanelIds: [UUID]? = nil - @State private var cachedBranchDirectoryLines: [VerticalBranchDirectoryLine] = [] - @State private var cachedPullRequestRows: [PullRequestDisplay] = [] - - var isMultiSelected: Bool { - selectedTabIds.contains(tab.id) - } - - private var isBeingDragged: Bool { - draggedTabId == tab.id - } - - private var sidebarShortcutHintXOffset: Double { - settings.sidebarShortcutHintXOffset - } - - private var sidebarShortcutHintYOffset: Double { - settings.sidebarShortcutHintYOffset - } - - private var alwaysShowShortcutHints: Bool { - settings.alwaysShowShortcutHints - } - - private var sidebarShowGitBranch: Bool { - settings.showsGitBranch - } - - private var sidebarBranchVerticalLayout: Bool { - settings.usesVerticalBranchLayout - } - - private var sidebarShowGitBranchIcon: Bool { - settings.showsGitBranchIcon - } - - private var sidebarShowSSH: Bool { - settings.showsSSH - } - - private var activeTabIndicatorStyle: SidebarActiveTabIndicatorStyle { - settings.activeTabIndicatorStyle - } - - private var sidebarSelectionColorHex: String? { - settings.selectionColorHex - } - - private var sidebarNotificationBadgeColorHex: String? { - settings.notificationBadgeColorHex - } - - private var openSidebarPullRequestLinksInProgramaBrowser: Bool { - settings.openPullRequestLinksInProgramaBrowser - } - - private var openSidebarPortLinksInProgramaBrowser: Bool { - settings.openPortLinksInProgramaBrowser - } - - private var titleFontWeight: Font.Weight { - .semibold - } - - private var showsLeadingRail: Bool { - explicitRailColor != nil - } - - private var activeBorderLineWidth: CGFloat { - switch activeTabIndicatorStyle { - case .leftRail: - return 0 - case .solidFill: - return isActive ? 1.5 : 0 - } - } - - private var activeBorderColor: Color { - guard isActive else { return .clear } - switch activeTabIndicatorStyle { - case .leftRail: - return .clear - case .solidFill: - return Color.primary.opacity(0.5) - } - } - - private var usesInvertedActiveForeground: Bool { - isActive - } - - private var activePrimaryTextColor: Color { - usesInvertedActiveForeground - ? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 1.0)) - : .primary - } - - private func activeSecondaryColor(_ opacity: Double = 0.75) -> Color { - usesInvertedActiveForeground - ? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat(opacity))) - : .secondary - } - - private var activeUnreadBadgeFillColor: Color { - if let hex = sidebarNotificationBadgeColorHex, let nsColor = NSColor(hex: hex) { - return Color(nsColor: nsColor) - } - return usesInvertedActiveForeground ? Color.white.opacity(0.25) : programaAccentColor() - } - - private var activeProgressTrackColor: Color { - usesInvertedActiveForeground ? Color.white.opacity(0.15) : Color.secondary.opacity(0.2) - } - - private var activeProgressFillColor: Color { - usesInvertedActiveForeground ? Color.white.opacity(0.8) : programaAccentColor() - } - - private var shortcutHintEmphasis: Double { - usesInvertedActiveForeground ? 1.0 : 0.9 - } - - private var showCloseButton: Bool { - isHovering && canCloseWorkspace && !(showsModifierShortcutHints || alwaysShowShortcutHints) - } - - private var workspaceShortcutLabel: String? { - guard let workspaceShortcutDigit else { return nil } - return "\(workspaceShortcutModifierSymbol)\(workspaceShortcutDigit)" - } - - private var showsWorkspaceShortcutHint: Bool { - (showsModifierShortcutHints || alwaysShowShortcutHints) && workspaceShortcutLabel != nil - } - - private var trailingAccessoryWidth: CGFloat { - SidebarTrailingAccessoryWidthPolicy.width( - canCloseWorkspace: canCloseWorkspace, - showsWorkspaceShortcutHint: showsWorkspaceShortcutHint, - workspaceShortcutLabel: workspaceShortcutLabel, - debugXOffset: sidebarShortcutHintXOffset - ) - } - - private var remoteWorkspaceSidebarText: String? { - guard tab.hasActiveRemoteTerminalSessions else { return nil } - let trimmedTarget = tab.remoteDisplayTarget?.trimmingCharacters(in: .whitespacesAndNewlines) - if let trimmedTarget, !trimmedTarget.isEmpty { - return trimmedTarget - } - return String(localized: "sidebar.remote.subtitleFallback", defaultValue: "SSH workspace") - } - - private var copyableSidebarSSHError: String? { - let fallbackTarget = tab.remoteDisplayTarget ?? String( - localized: "sidebar.remote.help.targetFallback", - defaultValue: "remote host" - ) - let trimmedDetail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) - if tab.remoteConnectionState == .error, let trimmedDetail, !trimmedDetail.isEmpty { - let entry = SidebarRemoteErrorCopyEntry( - workspaceTitle: tab.title, - target: fallbackTarget, - detail: trimmedDetail - ) - return SidebarRemoteErrorCopySupport.clipboardText(for: [entry]) - } - if let statusValue = tab.statusEntries["remote.error"]?.value - .trimmingCharacters(in: .whitespacesAndNewlines), - !statusValue.isEmpty { - let entry = SidebarRemoteErrorCopyEntry( - workspaceTitle: tab.title, - target: fallbackTarget, - detail: statusValue - ) - return SidebarRemoteErrorCopySupport.clipboardText(for: [entry]) - } - return nil - } - - private var remoteConnectionStatusText: String { - switch tab.remoteConnectionState { - case .connected: - return String(localized: "remote.status.connected", defaultValue: "Connected") - case .connecting: - return String(localized: "remote.status.connecting", defaultValue: "Connecting") - case .error: - return String(localized: "remote.status.error", defaultValue: "Error") - case .disconnected: - return String(localized: "remote.status.disconnected", defaultValue: "Disconnected") - } - } - - @ViewBuilder - private var remoteWorkspaceSection: some View { - if sidebarShowSSH, let remoteWorkspaceSidebarText { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(remoteWorkspaceSidebarText) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.8)) - .lineLimit(1) - .truncationMode(.middle) - - Spacer(minLength: 0) - - Text(remoteConnectionStatusText) - .font(.system(size: 9, weight: .medium)) - .foregroundColor(activeSecondaryColor(0.58)) - .lineLimit(1) - } - } - .padding(.top, latestNotificationText == nil ? 1 : 2) - .safeHelp(remoteStateHelpText) - } - } - - private func copyTextToPasteboard(_ text: String) { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(text, forType: .string) - } - - private var visibleAuxiliaryDetails: SidebarWorkspaceAuxiliaryDetailVisibility { - settings.visibleAuxiliaryDetails - } - - var body: some View { - 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 = 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.") - let moveUpActionText = String(localized: "sidebar.workspace.moveUpAction", defaultValue: "Move Up") - let moveDownActionText = String(localized: "sidebar.workspace.moveDownAction", defaultValue: "Move Down") - let latestNotificationSubtitle = latestNotificationText - let effectiveSubtitle = latestNotificationSubtitle - let detailVisibility = visibleAuxiliaryDetails - // Read from cache — updated only by the debounced publisher, onAppear, - // and settings changes. Title-keystroke re-renders skip this tree walk. - let orderedPanelIds: [UUID]? = cachedOrderedPanelIds - let branchDirectoryLines: [VerticalBranchDirectoryLine] = cachedBranchDirectoryLines - let pullRequestRows: [PullRequestDisplay] = cachedPullRequestRows - 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 branchLinesContainBranch = sidebarShowGitBranch && branchDirectoryLines.contains { $0.branch != nil } - - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - if unreadCount > 0 { - ZStack { - Circle() - .fill(activeUnreadBadgeFillColor) - Text("\(unreadCount)") - .font(.system(size: 9, weight: .semibold)) - .foregroundColor(.white) - } - .frame(width: 16, height: 16) - } - - if tab.isPinned { - Image(systemName: "pin.fill") - .font(.system(size: 9, weight: .semibold)) - .foregroundColor(activeSecondaryColor(0.8)) - .safeHelp(protectedWorkspaceTooltip) - } - - Text(tab.title) - .font(.system(size: 12.5, weight: titleFontWeight)) - .foregroundColor(activePrimaryTextColor) - .lineLimit(1) - .truncationMode(.tail) - .layoutPriority(1) - - Spacer(minLength: 0) - - ZStack(alignment: .trailing) { - Button(action: { - #if DEBUG - dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=button") - #endif - tabManager.closeWorkspaceWithConfirmation(tab) - }) { - Image(systemName: "xmark") - .font(.system(size: 9, weight: .medium)) - .foregroundColor(activeSecondaryColor(0.7)) - } - .buttonStyle(.plain) - .safeHelp(closeButtonTooltip) - .frame(width: SidebarTrailingAccessoryWidthPolicy.closeButtonWidth, height: 16, alignment: .center) - .opacity(showCloseButton && !showsWorkspaceShortcutHint ? 1 : 0) - .allowsHitTesting(showCloseButton && !showsWorkspaceShortcutHint) - - if showsWorkspaceShortcutHint, let workspaceShortcutLabel { - Text(workspaceShortcutLabel) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - .font(.system(size: 10, weight: .semibold, design: .rounded)) - .monospacedDigit() - .foregroundColor(activePrimaryTextColor) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(ShortcutHintPillBackground(emphasis: shortcutHintEmphasis)) - .offset( - x: ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset), - y: ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset) - ) - .transition(.opacity) - } - } - .animation(.easeInOut(duration: 0.14), value: showsModifierShortcutHints || alwaysShowShortcutHints) - .frame(width: trailingAccessoryWidth, height: 16, alignment: .trailing) - } - - if let description = tab.customDescription { - SidebarWorkspaceDescriptionText( - markdown: description, - isActive: usesInvertedActiveForeground - ) - .id(description) - } - - if let subtitle = effectiveSubtitle { - Text(subtitle) - .font(.system(size: 10)) - .foregroundColor(activeSecondaryColor(0.8)) - .lineLimit(2) - .truncationMode(.tail) - .multilineTextAlignment(.leading) - } - - remoteWorkspaceSection - - if detailVisibility.showsMetadata { - let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder() - let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder() - if !metadataEntries.isEmpty { - SidebarMetadataRows( - entries: metadataEntries, - isActive: usesInvertedActiveForeground, - onFocus: { updateSelection() } - ) - .transition(.opacity.combined(with: .move(edge: .top))) - } - if !metadataBlocks.isEmpty { - SidebarMetadataMarkdownBlocks( - blocks: metadataBlocks, - isActive: usesInvertedActiveForeground, - onFocus: { updateSelection() } - ) - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - - // Latest log entry - if detailVisibility.showsLog, let latestLog = tab.logEntries.last { - HStack(spacing: 4) { - Image(systemName: logLevelIcon(latestLog.level)) - .font(.system(size: 8)) - .foregroundColor(logLevelColor(latestLog.level, isActive: usesInvertedActiveForeground)) - Text(latestLog.message) - .font(.system(size: 10)) - .foregroundColor(activeSecondaryColor(0.8)) - .lineLimit(1) - .truncationMode(.tail) - } - .transition(.opacity.combined(with: .move(edge: .top))) - } - - // Progress bar - if detailVisibility.showsProgress, let progress = tab.progress { - VStack(alignment: .leading, spacing: 2) { - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule() - .fill(activeProgressTrackColor) - Capsule() - .fill(activeProgressFillColor) - .frame(width: max(0, geo.size.width * CGFloat(progress.value))) - } - } - .frame(height: 3) - - if let label = progress.label { - Text(label) - .font(.system(size: 9)) - .foregroundColor(activeSecondaryColor(0.6)) - .lineLimit(1) - } - } - .transition(.opacity.combined(with: .move(edge: .top))) - } - - // Branch + directory row - if detailVisibility.showsBranchDirectory { - if sidebarBranchVerticalLayout { - if !branchDirectoryLines.isEmpty { - HStack(alignment: .top, spacing: 3) { - if sidebarShowGitBranchIcon, branchLinesContainBranch { - Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) - .foregroundColor(activeSecondaryColor(0.6)) - } - VStack(alignment: .leading, spacing: 1) { - ForEach(Array(branchDirectoryLines.enumerated()), id: \.offset) { _, line in - HStack(spacing: 3) { - if let branch = line.branch { - Text(branch) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.75)) - .lineLimit(1) - .truncationMode(.tail) - } - if line.branch != nil, line.directory != nil { - Image(systemName: "circle.fill") - .font(.system(size: 3)) - .foregroundColor(activeSecondaryColor(0.6)) - .padding(.horizontal, 1) - } - if let directory = line.directory { - Text(directory) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.75)) - .lineLimit(1) - .truncationMode(.tail) - } - } - } - } - } - } - } else if let dirRow = compactBranchDirectoryRow { - HStack(spacing: 3) { - if sidebarShowGitBranchIcon, compactGitBranchSummaryText != nil { - Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) - .foregroundColor(activeSecondaryColor(0.6)) - } - Text(dirRow) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.75)) - .lineLimit(1) - .truncationMode(.tail) - } - } - } - - // Pull request rows - if detailVisibility.showsPullRequests, !pullRequestRows.isEmpty { - VStack(alignment: .leading, spacing: 1) { - ForEach(pullRequestRows) { pullRequest in - Button(action: { - openPullRequestLink(pullRequest.url) - }) { - HStack(spacing: 4) { - PullRequestStatusIcon( - status: pullRequest.status, - color: pullRequestForegroundColor - ) - Text("\(pullRequest.label) #\(pullRequest.number)") - .underline() - .lineLimit(1) - .truncationMode(.tail) - Text(pullRequestStatusLabel(pullRequest.status, checks: pullRequest.checks)) - .lineLimit(1) - Spacer(minLength: 0) - } - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(pullRequestForegroundColor) - } - .buttonStyle(.plain) - .safeHelp(String(localized: "sidebar.pullRequest.openTooltip", defaultValue: "Open \(pullRequest.label) #\(pullRequest.number)")) - } - } - } - - // Ports row - if detailVisibility.showsPorts, !tab.listeningPorts.isEmpty { - HStack(spacing: 4) { - ForEach(tab.listeningPorts, id: \.self) { port in - Button(action: { - openPortLink(port) - }) { - Text(String(localized: "sidebar.port.label", defaultValue: ":\(port)")) - .underline() - } - .buttonStyle(.plain) - .safeHelp(String(localized: "sidebar.port.openTooltip", defaultValue: "Open localhost:\(port)")) - } - Spacer(minLength: 0) - } - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.75)) - .lineLimit(1) - } - } - .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( - RoundedRectangle(cornerRadius: 6) - .fill(backgroundColor) - .overlay { - RoundedRectangle(cornerRadius: 6) - .strokeBorder(activeBorderColor, lineWidth: activeBorderLineWidth) - } - .overlay(alignment: .leading) { - if showsLeadingRail { - Capsule(style: .continuous) - .fill(railColor) - .frame(width: 3) - .padding(.leading, 4) - .padding(.vertical, 5) - .offset(x: -1) - } - } - ) - .padding(.horizontal, 6) - .background { - GeometryReader { proxy in - Color.clear - .onAppear { - rowHeight = max(proxy.size.height, 1) - } - .onChange(of: proxy.size.height) { newHeight in - rowHeight = max(newHeight, 1) - } - } - } - .contentShape(Rectangle()) - .opacity(isBeingDragged ? 0.6 : 1) - .overlay { - MiddleClickCapture { - #if DEBUG - dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=middleClick") - #endif - tabManager.closeWorkspaceWithConfirmation(tab) - } - } - .overlay(alignment: .top) { - if showsCenteredTopDropIndicator { - Rectangle() - .fill(programaAccentColor()) - .frame(height: 2) - .padding(.horizontal, 8) - .offset(y: index == 0 ? 0 : -(rowSpacing / 2)) - } - } - .onReceive( - tab.sidebarImmediateObservationPublisher - .receive(on: RunLoop.main) - ) { _ in -#if DEBUG - let description = tab.customDescription ?? "" - dlog( - "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + - "source=immediate " + - "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + - "descLen=\((description as NSString).length) " + - "desc=\"\(debugCommandPaletteTextPreview(description))\"" - ) -#endif - workspaceObservationGeneration &+= 1 - } - .onReceive( - tab.sidebarObservationPublisher - .receive(on: RunLoop.main) - // Prompt-time sidebar telemetry can arrive as a short burst - // (pwd, branch, PR, shell state). Coalesce that burst so the - // row redraws once with the settled state instead of blinking. - .debounce(for: Self.workspaceObservationCoalesceInterval, scheduler: RunLoop.main) - ) { _ in -#if DEBUG - let description = tab.customDescription ?? "" - dlog( - "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + - "source=debounced " + - "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + - "descLen=\((description as NSString).length) " + - "desc=\"\(debugCommandPaletteTextPreview(description))\"" - ) -#endif - // Refresh expensive caches (tree walk, branch/dir/PR) before - // signalling a redraw. The immediate publisher intentionally does - // NOT call this so that title keystrokes skip the tree walk. - recomputeSidebarDetailCache() - workspaceObservationGeneration &+= 1 - } - .onDrag { - #if DEBUG - dlog("sidebar.onDrag tab=\(tab.id.uuidString.prefix(5))") - #endif - draggedTabId = tab.id - dropIndicator = nil - return SidebarTabDragPayload.provider(for: tab.id) - } - .internalOnlyTabDrag() - .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate( - targetTabId: tab.id, - tabManager: tabManager, - draggedTabId: $draggedTabId, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - targetRowHeight: rowHeight, - dragAutoScrollController: dragAutoScrollController, - dropIndicator: $dropIndicator - )) - .onDrop(of: BonsplitTabDragPayload.dropContentTypes, delegate: SidebarBonsplitTabDropDelegate( - targetWorkspaceId: tab.id, - tabManager: tabManager, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex - )) - .onTapGesture { - updateSelection() - } - .onHover { hovering in - isHovering = hovering - } - .accessibilityElement(children: .combine) - .accessibilityLabel(Text(accessibilityTitle)) - .accessibilityHint(Text(accessibilityHintText)) - .accessibilityAction(named: Text(moveUpActionText)) { - moveBy(-1) - } - .accessibilityAction(named: Text(moveDownActionText)) { - moveBy(1) - } - .onAppear { - // Prime the cache so branch/dir/PR rows appear immediately, - // before the first debounced publisher fires. - recomputeSidebarDetailCache() - } - .onChange(of: visibleAuxiliaryDetails) { _ in - // Toggling branch/PR columns changes which data we need to cache. - recomputeSidebarDetailCache() - } - .contextMenu { workspaceContextMenu } - } - - private func contextMenuLabel(multi: String, single: String, isMulti: Bool) -> String { - isMulti ? multi : single - } - - private func remoteContextMenuWorkspaces() -> [Workspace] { - guard !remoteContextMenuWorkspaceIds.isEmpty else { return [] } - return remoteContextMenuWorkspaceIds.compactMap { workspaceId in - tabManager.tabs.first(where: { $0.id == workspaceId }) - } - } - - @ViewBuilder - private var workspaceContextMenu: some View { - let targetIds = contextMenuWorkspaceIds - let isMulti = targetIds.count > 1 - let tabColorPalette = WorkspaceTabColorSettings.palette() - let shouldPin = !tab.isPinned - let reconnectLabel = contextMenuLabel( - multi: String(localized: "contextMenu.reconnectWorkspaces", defaultValue: "Reconnect Workspaces"), - single: String(localized: "contextMenu.reconnectWorkspace", defaultValue: "Reconnect Workspace"), - isMulti: isMulti) - let disconnectLabel = contextMenuLabel( - multi: String(localized: "contextMenu.disconnectWorkspaces", defaultValue: "Disconnect Workspaces"), - single: String(localized: "contextMenu.disconnectWorkspace", defaultValue: "Disconnect Workspace"), - isMulti: isMulti) - let pinLabel = shouldPin - ? contextMenuLabel( - multi: String(localized: "contextMenu.pinWorkspaces", defaultValue: "Pin Workspaces"), - single: String(localized: "contextMenu.pinWorkspace", defaultValue: "Pin Workspace"), - isMulti: isMulti) - : contextMenuLabel( - multi: String(localized: "contextMenu.unpinWorkspaces", defaultValue: "Unpin Workspaces"), - single: String(localized: "contextMenu.unpinWorkspace", defaultValue: "Unpin Workspace"), - isMulti: isMulti) - let closeLabel = contextMenuLabel( - multi: String(localized: "contextMenu.closeWorkspaces", defaultValue: "Close Workspaces"), - single: String(localized: "contextMenu.closeWorkspace", defaultValue: "Close Workspace"), - isMulti: isMulti) - let markReadLabel = contextMenuLabel( - multi: String(localized: "contextMenu.markWorkspacesRead", defaultValue: "Mark Workspaces as Read"), - single: String(localized: "contextMenu.markWorkspaceRead", defaultValue: "Mark Workspace as Read"), - isMulti: isMulti) - let markUnreadLabel = contextMenuLabel( - multi: String(localized: "contextMenu.markWorkspacesUnread", defaultValue: "Mark Workspaces as Unread"), - single: String(localized: "contextMenu.markWorkspaceUnread", defaultValue: "Mark Workspace as Unread"), - isMulti: isMulti) - let clearLatestNotificationLabel = contextMenuLabel( - multi: String(localized: "contextMenu.clearLatestNotifications", defaultValue: "Clear Latest Notifications"), - single: String(localized: "contextMenu.clearLatestNotification", defaultValue: "Clear Latest Notification"), - isMulti: isMulti) - let renameWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .renameWorkspace) - let editWorkspaceDescriptionShortcut = KeyboardShortcutSettings.shortcut(for: .editWorkspaceDescription) - let closeWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .closeWorkspace) - Button(pinLabel) { - for id in targetIds { - if let tab = tabManager.tabs.first(where: { $0.id == id }) { - tabManager.setPinned(tab, pinned: shouldPin) - } - } - syncSelectionAfterMutation() - } - - if let key = renameWorkspaceShortcut.keyEquivalent { - Button(String(localized: "contextMenu.renameWorkspace", defaultValue: "Rename Workspace…")) { - promptRename() - } - .keyboardShortcut(key, modifiers: renameWorkspaceShortcut.eventModifiers) - } else { - Button(String(localized: "contextMenu.renameWorkspace", defaultValue: "Rename Workspace…")) { - promptRename() - } - } - - if tab.hasCustomTitle { - Button(String(localized: "contextMenu.removeCustomWorkspaceName", defaultValue: "Remove Custom Workspace Name")) { - tabManager.clearCustomTitle(tabId: tab.id) - } - } - - if !isMulti { - if let key = editWorkspaceDescriptionShortcut.keyEquivalent { - Button(String(localized: "contextMenu.editWorkspaceDescription", defaultValue: "Edit Workspace Description…")) { - beginWorkspaceDescriptionEditFromContextMenu() - } - .keyboardShortcut(key, modifiers: editWorkspaceDescriptionShortcut.eventModifiers) - } else { - Button(String(localized: "contextMenu.editWorkspaceDescription", defaultValue: "Edit Workspace Description…")) { - beginWorkspaceDescriptionEditFromContextMenu() - } - } - - if tab.hasCustomDescription { - Button(String(localized: "contextMenu.clearWorkspaceDescription", defaultValue: "Clear Workspace Description")) { - tabManager.clearCustomDescription(tabId: tab.id) - } - } - } - - if !remoteContextMenuWorkspaceIds.isEmpty { - Divider() - - Button(reconnectLabel) { - for workspace in remoteContextMenuWorkspaces() { - workspace.reconnectRemoteConnection() - } - } - .disabled(allRemoteContextMenuTargetsConnecting) - - Button(disconnectLabel) { - for workspace in remoteContextMenuWorkspaces() { - workspace.disconnectRemoteConnection(clearConfiguration: false) - } - } - .disabled(allRemoteContextMenuTargetsDisconnected) - } - - Menu(String(localized: "contextMenu.workspaceColor", defaultValue: "Workspace Color")) { - if tab.customColor != nil { - Button { - applyTabColor(nil, targetIds: targetIds) - } label: { - Label(String(localized: "contextMenu.clearColor", defaultValue: "Clear Color"), systemImage: "xmark.circle") - } - } - - Button { - promptCustomColor(targetIds: targetIds) - } label: { - Label(String(localized: "contextMenu.chooseCustomColor", defaultValue: "Choose Custom Color…"), systemImage: "paintpalette") - } - - if !tabColorPalette.isEmpty { - Divider() - } - - ForEach(tabColorPalette, id: \.id) { entry in - Button { - applyTabColor(entry.hex, targetIds: targetIds) - } label: { - Label { - Text(entry.name) - } icon: { - Image(nsImage: coloredCircleImage(color: tabColorSwatchColor(for: entry.hex))) - } - } - } - } - - if let copyableSidebarSSHError { - Button(String(localized: "contextMenu.copySshError", defaultValue: "Copy SSH Error")) { - copyTextToPasteboard(copyableSidebarSSHError) - } - } - - Divider() - - Button(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")) { - moveBy(-1) - } - .disabled(index == 0) - - Button(String(localized: "contextMenu.moveDown", defaultValue: "Move Down")) { - moveBy(1) - } - .disabled(index >= tabManager.tabs.count - 1) - - Button(String(localized: "contextMenu.moveToTop", defaultValue: "Move to Top")) { - tabManager.moveTabsToTop(Set(targetIds)) - syncSelectionAfterMutation() - } - .disabled(targetIds.isEmpty) - - let referenceWindowId = AppDelegate.shared?.windowId(for: tabManager) - let windowMoveTargets = AppDelegate.shared?.windowMoveTargets(referenceWindowId: referenceWindowId) ?? [] - let moveMenuTitle = targetIds.count > 1 - ? String(localized: "contextMenu.moveWorkspacesToWindow", defaultValue: "Move Workspaces to Window") - : String(localized: "contextMenu.moveWorkspaceToWindow", defaultValue: "Move Workspace to Window") - Menu(moveMenuTitle) { - Button(String(localized: "contextMenu.newWindow", defaultValue: "New Window")) { - moveWorkspacesToNewWindow(targetIds) - } - .disabled(targetIds.isEmpty) - - if !windowMoveTargets.isEmpty { - Divider() - } - - ForEach(windowMoveTargets) { target in - Button(target.label) { - moveWorkspaces(targetIds, toWindow: target.windowId) - } - .disabled(target.isCurrentWindow || targetIds.isEmpty) - } - } - .disabled(targetIds.isEmpty) - - Divider() - - if let key = closeWorkspaceShortcut.keyEquivalent { - Button(closeLabel) { - closeTabs(targetIds, allowPinned: true) - } - .keyboardShortcut(key, modifiers: closeWorkspaceShortcut.eventModifiers) - .disabled(targetIds.isEmpty) - } else { - Button(closeLabel) { - closeTabs(targetIds, allowPinned: true) - } - .disabled(targetIds.isEmpty) - } - - Button(String(localized: "contextMenu.closeOtherWorkspaces", defaultValue: "Close Other Workspaces")) { - closeOtherTabs(targetIds) - } - .disabled(tabManager.tabs.count <= 1 || targetIds.count == tabManager.tabs.count) - - Button(String(localized: "contextMenu.closeWorkspacesBelow", defaultValue: "Close Workspaces Below")) { - closeTabsBelow(tabId: tab.id) - } - .disabled(index >= tabManager.tabs.count - 1) - - Button(String(localized: "contextMenu.closeWorkspacesAbove", defaultValue: "Close Workspaces Above")) { - closeTabsAbove(tabId: tab.id) - } - .disabled(index == 0) - - Divider() - - Button(markReadLabel) { - markTabsRead(targetIds) - } - .disabled(!hasUnreadNotifications(in: targetIds)) - - Button(markUnreadLabel) { - markTabsUnread(targetIds) - } - .disabled(!hasReadNotifications(in: targetIds)) - - Button(clearLatestNotificationLabel) { - clearLatestNotifications(targetIds) - } - .disabled(!hasLatestNotifications(in: targetIds)) - } - - private var selectionBackgroundColor: NSColor { - if let hex = sidebarSelectionColorHex, let parsed = NSColor(hex: hex) { - return parsed - } - return programaAccentNSColor(for: colorScheme) - } - - private var backgroundColor: Color { - switch activeTabIndicatorStyle { - case .leftRail: - if isActive { return Color(nsColor: selectionBackgroundColor) } - if isMultiSelected { return programaAccentColor().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 programaAccentColor().opacity(0.25) } - return Color.clear - } - } - - private var railColor: Color { - explicitRailColor ?? .clear - } - - private var explicitRailColor: Color? { - guard activeTabIndicatorStyle == .leftRail, - let custom = resolvedCustomTabColor else { - return nil - } - 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 { - WorkspaceTabColorSettings.displayNSColor( - hex: hex, - colorScheme: colorScheme, - forceBright: activeTabIndicatorStyle == .leftRail - ) ?? NSColor(hex: hex) ?? .gray - } - - private var showsCenteredTopDropIndicator: Bool { - guard draggedTabId != nil, let indicator = dropIndicator else { return false } - if indicator.tabId == tab.id && indicator.edge == .top { - return true - } - - guard indicator.edge == .bottom, - let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == tab.id }), - currentIndex > 0 - else { - return false - } - return tabManager.tabs[currentIndex - 1].id == indicator.tabId - } - - private var accessibilityTitle: String { - String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(accessibilityWorkspaceCount)") - } - - private func moveBy(_ delta: Int) { - let targetIndex = index + delta - guard targetIndex >= 0, targetIndex < tabManager.tabs.count else { return } - guard tabManager.reorderWorkspace(tabId: tab.id, toIndex: targetIndex) else { return } - selectedTabIds = [tab.id] - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == tab.id } - tabManager.selectTab(tab) - setSelectionToTabs() - } - - private func updateSelection() { - #if DEBUG - let mods = NSEvent.modifierFlags - var modStr = "" - if mods.contains(.command) { modStr += "cmd " } - if mods.contains(.shift) { modStr += "shift " } - if mods.contains(.option) { modStr += "opt " } - if mods.contains(.control) { modStr += "ctrl " } - 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) - let isShift = modifiers.contains(.shift) - let wasSelected = tabManager.selectedTabId == tab.id - - if isShift, let lastIndex = lastSidebarSelectionIndex { - let lower = min(lastIndex, index) - let upper = max(lastIndex, index) - let rangeIds = tabManager.tabs[lower...upper].map { $0.id } - if isCommand { - selectedTabIds.formUnion(rangeIds) - } else { - selectedTabIds = Set(rangeIds) - } - } else if isCommand { - if selectedTabIds.contains(tab.id) { - selectedTabIds.remove(tab.id) - } else { - selectedTabIds.insert(tab.id) - } - } else { - selectedTabIds = [tab.id] - } - - lastSidebarSelectionIndex = index - tabManager.selectTab(tab) - if wasSelected, !isCommand, !isShift { - tabManager.dismissNotificationOnDirectInteraction( - tabId: tab.id, - surfaceId: tabManager.focusedSurfaceId(for: tab.id) - ) - } - setSelectionToTabs() - } - - private func closeTabs(_ targetIds: [UUID], allowPinned: Bool) { - tabManager.closeWorkspacesWithConfirmation(targetIds, allowPinned: allowPinned) - syncSelectionAfterMutation() - } - - private func closeOtherTabs(_ targetIds: [UUID]) { - let keepIds = Set(targetIds) - let idsToClose = tabManager.tabs.compactMap { keepIds.contains($0.id) ? nil : $0.id } - closeTabs(idsToClose, allowPinned: true) - } - - private func closeTabsBelow(tabId: UUID) { - guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } - let idsToClose = tabManager.tabs.suffix(from: anchorIndex + 1).map { $0.id } - closeTabs(idsToClose, allowPinned: true) - } - - private func closeTabsAbove(tabId: UUID) { - guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } - let idsToClose = tabManager.tabs.prefix(upTo: anchorIndex).map { $0.id } - closeTabs(idsToClose, allowPinned: true) - } - - private func markTabsRead(_ targetIds: [UUID]) { - for id in targetIds { - notificationStore.markRead(forTabId: id) - } - } - - private func markTabsUnread(_ targetIds: [UUID]) { - for id in targetIds { - notificationStore.markUnread(forTabId: id) - } - } - - private func clearLatestNotifications(_ targetIds: [UUID]) { - for id in targetIds { - notificationStore.clearLatestNotification(forTabId: id) - } - } - - private func hasUnreadNotifications(in targetIds: [UUID]) -> Bool { - let targetSet = Set(targetIds) - return notificationStore.notifications.contains { targetSet.contains($0.tabId) && !$0.isRead } - } - - private func hasReadNotifications(in targetIds: [UUID]) -> Bool { - let targetSet = Set(targetIds) - return notificationStore.notifications.contains { targetSet.contains($0.tabId) && $0.isRead } - } - - private func hasLatestNotifications(in targetIds: [UUID]) -> Bool { - targetIds.contains { notificationStore.latestNotification(forTabId: $0) != nil } - } - - private func syncSelectionAfterMutation() { - let existingIds = Set(tabManager.tabs.map { $0.id }) - selectedTabIds = selectedTabIds.filter { existingIds.contains($0) } - if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { - selectedTabIds = [selectedId] - } - if let selectedId = tabManager.selectedTabId { - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } - } - } - - private var remoteStateHelpText: String { - let target = tab.remoteDisplayTarget ?? String( - localized: "sidebar.remote.help.targetFallback", - defaultValue: "remote host" - ) - let detail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) - switch tab.remoteConnectionState { - case .connected: - return String( - format: String( - localized: "sidebar.remote.help.connected", - defaultValue: "SSH connected to %@" - ), - locale: .current, - target - ) - case .connecting: - return String( - format: String( - localized: "sidebar.remote.help.connecting", - defaultValue: "SSH connecting to %@" - ), - locale: .current, - target - ) - case .error: - if let detail, !detail.isEmpty { - return String( - format: String( - localized: "sidebar.remote.help.errorWithDetail", - defaultValue: "SSH error for %@: %@" - ), - locale: .current, - target, - detail - ) - } - return String( - format: String( - localized: "sidebar.remote.help.error", - defaultValue: "SSH error for %@" - ), - locale: .current, - target - ) - case .disconnected: - return String( - format: String( - localized: "sidebar.remote.help.disconnected", - defaultValue: "SSH disconnected from %@" - ), - locale: .current, - target - ) - } - } - private func moveWorkspaces(_ workspaceIds: [UUID], toWindow windowId: UUID) { - guard let app = AppDelegate.shared else { return } - let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } - guard !orderedWorkspaceIds.isEmpty else { return } - - for (index, workspaceId) in orderedWorkspaceIds.enumerated() { - let shouldFocus = index == orderedWorkspaceIds.count - 1 - _ = app.moveWorkspaceToWindow(workspaceId: workspaceId, windowId: windowId, focus: shouldFocus) - } - - selectedTabIds.subtract(orderedWorkspaceIds) - syncSelectionAfterMutation() - } - - private func moveWorkspacesToNewWindow(_ workspaceIds: [UUID]) { - guard let app = AppDelegate.shared else { return } - let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } - guard let firstWorkspaceId = orderedWorkspaceIds.first else { return } - - let shouldFocusImmediately = orderedWorkspaceIds.count == 1 - guard let newWindowId = app.moveWorkspaceToNewWindow(workspaceId: firstWorkspaceId, focus: shouldFocusImmediately) else { - return - } - - if orderedWorkspaceIds.count > 1 { - for workspaceId in orderedWorkspaceIds.dropFirst() { - _ = app.moveWorkspaceToWindow(workspaceId: workspaceId, windowId: newWindowId, focus: false) - } - if let finalWorkspaceId = orderedWorkspaceIds.last { - _ = app.moveWorkspaceToWindow(workspaceId: finalWorkspaceId, windowId: newWindowId, focus: true) - } - } - - selectedTabIds.subtract(orderedWorkspaceIds) - syncSelectionAfterMutation() - } - - // latestNotificationText is now passed as a parameter from the parent view - // to avoid subscribing to notificationStore changes in every TabItemView. - - private func branchDirectoryRow( - gitSummary: String?, - directorySummary: String? - ) -> String? { - var parts: [String] = [] - - if let gitSummary { - parts.append(gitSummary) - } - - if let directorySummary { - parts.append(directorySummary) - } - - let result = parts.joined(separator: " · ") - return result.isEmpty ? nil : result - } - - private func gitBranchSummaryText(orderedPanelIds: [UUID]) -> String? { - let lines = gitBranchSummaryLines(orderedPanelIds: orderedPanelIds) - guard !lines.isEmpty else { return nil } - return lines.joined(separator: " | ") - } - - private func gitBranchSummaryLines(orderedPanelIds: [UUID]) -> [String] { - tab.sidebarGitBranchesInDisplayOrder(orderedPanelIds: orderedPanelIds).map { branch in - "\(branch.branch)\(branch.isDirty ? "*" : "")" - } - } - - private struct VerticalBranchDirectoryLine { - let branch: String? - let directory: String? - } - - /// Recomputes the expensive sidebar detail caches (bonsplit tree walk, - /// branch/directory lines, PR snapshot) and writes them into @State. - /// Must be called only from the debounced publisher, onAppear, and - /// settings-change handlers — never from the immediate (title) publisher. - private func recomputeSidebarDetailCache() { - let detail = visibleAuxiliaryDetails - let needsDetail = detail.showsBranchDirectory || detail.showsPullRequests - let ids: [UUID]? = needsDetail ? tab.sidebarOrderedPanelIds() : nil - cachedOrderedPanelIds = ids - cachedBranchDirectoryLines = { - guard detail.showsBranchDirectory, sidebarBranchVerticalLayout, let ids else { return [] } - return verticalBranchDirectoryLines(orderedPanelIds: ids) - }() - cachedPullRequestRows = { - guard detail.showsPullRequests, let ids else { return [] } - return pullRequestDisplays(orderedPanelIds: ids) - }() - } - - private func verticalBranchDirectoryLines(orderedPanelIds: [UUID]) -> [VerticalBranchDirectoryLine] { - let entries = tab.sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: orderedPanelIds) - let home = SidebarPathFormatter.homeDirectoryPath - return entries.compactMap { entry in - let branchText: String? = { - guard sidebarShowGitBranch, let branch = entry.branch else { return nil } - return "\(branch)\(entry.isDirty ? "*" : "")" - }() - - let directoryText: String? = { - guard let directory = entry.directory else { return nil } - let shortened = SidebarPathFormatter.shortenedPath(directory, homeDirectoryPath: home) - return shortened.isEmpty ? nil : shortened - }() - - switch (branchText, directoryText) { - case let (branch?, directory?): - return VerticalBranchDirectoryLine(branch: branch, directory: directory) - case let (branch?, nil): - return VerticalBranchDirectoryLine(branch: branch, directory: nil) - case let (nil, directory?): - return VerticalBranchDirectoryLine(branch: nil, directory: directory) - default: - return nil - } - } - } - - private func directorySummaryText(orderedPanelIds: [UUID]) -> String? { - let home = SidebarPathFormatter.homeDirectoryPath - let entries = tab.sidebarDirectoriesInDisplayOrder(orderedPanelIds: orderedPanelIds).compactMap { directory in - let shortened = SidebarPathFormatter.shortenedPath(directory, homeDirectoryPath: home) - return shortened.isEmpty ? nil : shortened - } - return entries.isEmpty ? nil : entries.joined(separator: " | ") - } - - private struct PullRequestDisplay: Identifiable { - let id: String - let number: Int - let label: String - let url: URL - let status: SidebarPullRequestStatus - let checks: SidebarPullRequestChecksStatus? - } - - private func pullRequestDisplays(orderedPanelIds: [UUID]) -> [PullRequestDisplay] { - tab.sidebarPullRequestsInDisplayOrder(orderedPanelIds: orderedPanelIds).map { pullRequest in - PullRequestDisplay( - id: "\(pullRequest.label.lowercased())#\(pullRequest.number)|\(pullRequest.url.absoluteString)", - number: pullRequest.number, - label: pullRequest.label, - url: pullRequest.url, - status: pullRequest.status, - checks: pullRequest.checks - ) - } - } - - private var pullRequestForegroundColor: Color { - isActive ? .white.opacity(0.75) : .secondary - } - - private func openPullRequestLink(_ url: URL) { - updateSelection() - if openSidebarPullRequestLinksInProgramaBrowser { - if tabManager.openBrowser( - inWorkspace: tab.id, - url: url, - preferSplitRight: true, - insertAtEnd: true - ) == nil { - NSWorkspace.shared.open(url) - } - return - } - NSWorkspace.shared.open(url) - } - - private func openPortLink(_ port: Int) { - guard let url = URL(string: "http://localhost:\(port)") else { return } - updateSelection() - if openSidebarPortLinksInProgramaBrowser { - if tabManager.openBrowser( - inWorkspace: tab.id, - url: url, - preferSplitRight: true, - insertAtEnd: true - ) == nil { - NSWorkspace.shared.open(url) - } - return - } - NSWorkspace.shared.open(url) - } - - private func pullRequestStatusLabel( - _ status: SidebarPullRequestStatus, - checks _: SidebarPullRequestChecksStatus? - ) -> String { - switch status { - case .open: return String(localized: "sidebar.pullRequest.statusOpen", defaultValue: "open") - case .merged: return String(localized: "sidebar.pullRequest.statusMerged", defaultValue: "merged") - case .closed: return String(localized: "sidebar.pullRequest.statusClosed", defaultValue: "closed") - } - } - - private func logLevelIcon(_ level: SidebarLogLevel) -> String { - switch level { - case .info: return "circle.fill" - case .progress: return "arrowtriangle.right.fill" - case .success: return "checkmark.circle.fill" - case .warning: return "exclamationmark.triangle.fill" - case .error: return "xmark.circle.fill" - } - } - - private func logLevelColor(_ level: SidebarLogLevel, isActive: Bool) -> Color { - if isActive { - switch level { - case .info: - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.5)) - case .progress: - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.8)) - case .success: - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) - case .warning: - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) - case .error: - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) - } - } - switch level { - case .info: return .secondary - case .progress: return .blue - case .success: return .green - case .warning: return .orange - case .error: return .red - } - } - - private func shortenPath(_ path: String, home: String) -> String { - let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return path } - if trimmed == home { - return "~" - } - if trimmed.hasPrefix(home + "/") { - return "~" + trimmed.dropFirst(home.count) - } - return trimmed - } - - private struct PullRequestStatusIcon: View { - let status: SidebarPullRequestStatus - let color: Color - private static let frameSize: CGFloat = 12 - - var body: some View { - switch status { - case .open: - PullRequestOpenIcon(color: color) - case .merged: - PullRequestMergedIcon(color: color) - case .closed: - Image(systemName: "xmark.circle") - .font(.system(size: 7, weight: .regular)) - .foregroundColor(color) - .frame(width: Self.frameSize, height: Self.frameSize) - } - } - } - - private struct PullRequestOpenIcon: View { - let color: Color - private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round) - private static let nodeDiameter: CGFloat = 3.0 - private static let frameSize: CGFloat = 13 - - var body: some View { - ZStack { - Path { path in - path.move(to: CGPoint(x: 3.0, y: 4.8)) - path.addLine(to: CGPoint(x: 3.0, y: 9.2)) - - path.move(to: CGPoint(x: 4.8, y: 3.0)) - path.addLine(to: CGPoint(x: 9.4, y: 3.0)) - path.addLine(to: CGPoint(x: 11.0, y: 4.6)) - path.addLine(to: CGPoint(x: 11.0, y: 9.2)) - } - .stroke(color, style: Self.stroke) - - Circle() - .stroke(color, lineWidth: Self.stroke.lineWidth) - .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) - .position(x: 3.0, y: 3.0) - - Circle() - .stroke(color, lineWidth: Self.stroke.lineWidth) - .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) - .position(x: 3.0, y: 11.0) - - Circle() - .stroke(color, lineWidth: Self.stroke.lineWidth) - .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) - .position(x: 11.0, y: 11.0) - } - .frame(width: Self.frameSize, height: Self.frameSize) - } - } - - private struct PullRequestMergedIcon: View { - let color: Color - private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round) - private static let nodeDiameter: CGFloat = 3.0 - private static let frameSize: CGFloat = 13 - - var body: some View { - ZStack { - Path { path in - path.move(to: CGPoint(x: 4.6, y: 4.6)) - path.addLine(to: CGPoint(x: 7.1, y: 7.0)) - path.addLine(to: CGPoint(x: 9.2, y: 7.0)) - - path.move(to: CGPoint(x: 4.6, y: 9.4)) - path.addLine(to: CGPoint(x: 7.1, y: 7.0)) - } - .stroke(color, style: Self.stroke) - - Circle() - .stroke(color, lineWidth: Self.stroke.lineWidth) - .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) - .position(x: 3.0, y: 3.0) - - Circle() - .stroke(color, lineWidth: Self.stroke.lineWidth) - .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) - .position(x: 3.0, y: 11.0) - - Circle() - .stroke(color, lineWidth: Self.stroke.lineWidth) - .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) - .position(x: 11.0, y: 7.0) - } - .frame(width: Self.frameSize, height: Self.frameSize) - } - } - - private func applyTabColor(_ hex: String?, targetIds: [UUID]) { - for targetId in targetIds { - tabManager.setTabColor(tabId: targetId, color: hex) - } - } - - private func promptCustomColor(targetIds: [UUID]) { - let alert = NSAlert() - alert.messageText = String(localized: "alert.customColor.title", defaultValue: "Custom Workspace Color") - alert.informativeText = String(localized: "alert.customColor.message", defaultValue: "Enter a hex color in the format #RRGGBB.") - - let seed = tab.customColor ?? WorkspaceTabColorSettings.customPaletteEntries().first?.hex ?? "" - let input = NSTextField(string: seed) - input.placeholderString = "#1565C0" - input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) - alert.accessoryView = input - alert.addButton(withTitle: String(localized: "alert.customColor.apply", defaultValue: "Apply")) - alert.addButton(withTitle: String(localized: "alert.customColor.cancel", defaultValue: "Cancel")) - - let alertWindow = alert.window - alertWindow.initialFirstResponder = input - DispatchQueue.main.async { - alertWindow.makeFirstResponder(input) - input.selectText(nil) - } - - let response = alert.runModal() - guard response == .alertFirstButtonReturn else { return } - guard let normalized = WorkspaceTabColorSettings.addCustomColor(input.stringValue) else { - showInvalidColorAlert(input.stringValue) - return - } - applyTabColor(normalized, targetIds: targetIds) - } - - private func showInvalidColorAlert(_ value: String) { - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = String(localized: "alert.invalidColor.title", defaultValue: "Invalid Color") - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - alert.informativeText = String(localized: "alert.invalidColor.emptyMessage", defaultValue: "Enter a hex color in the format #RRGGBB.") - } else { - alert.informativeText = String(localized: "alert.invalidColor.invalidMessage", defaultValue: "\"\(trimmed)\" is not a valid hex color. Use #RRGGBB.") - } - alert.addButton(withTitle: String(localized: "alert.invalidColor.ok", defaultValue: "OK")) - _ = alert.runModal() - } - - private func promptRename() { - let alert = NSAlert() - alert.messageText = String(localized: "alert.renameWorkspace.title", defaultValue: "Rename Workspace") - alert.informativeText = String(localized: "alert.renameWorkspace.message", defaultValue: "Enter a custom name for this workspace.") - let input = NSTextField(string: tab.customTitle ?? tab.title) - input.placeholderString = String(localized: "alert.renameWorkspace.placeholder", defaultValue: "Workspace name") - input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) - alert.accessoryView = input - alert.addButton(withTitle: String(localized: "alert.renameWorkspace.rename", defaultValue: "Rename")) - alert.addButton(withTitle: String(localized: "alert.renameWorkspace.cancel", defaultValue: "Cancel")) - let alertWindow = alert.window - alertWindow.initialFirstResponder = input - DispatchQueue.main.async { - alertWindow.makeFirstResponder(input) - input.selectText(nil) - } - let response = alert.runModal() - guard response == .alertFirstButtonReturn else { return } - tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue) - } - - private func beginWorkspaceDescriptionEditFromContextMenu() { - selectedTabIds = [tab.id] - lastSidebarSelectionIndex = index - tabManager.selectTab(tab) - setSelectionToTabs() - _ = AppDelegate.shared?.requestEditWorkspaceDescriptionViaCommandPalette() - } -} - -private struct SidebarWorkspaceDescriptionText: View { - let markdown: String - let isActive: Bool - - var body: some View { - let renderedMarkdown = SidebarMarkdownRenderer.renderWorkspaceDescription(markdown) - Group { - if let renderedMarkdown { - Text(renderedMarkdown) - } else { - Text(markdown) - } - } - .font(.system(size: 10.5)) - .foregroundColor(foregroundColor) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityIdentifier("SidebarWorkspaceDescriptionText") - .accessibilityLabel(accessibilityText(renderedMarkdown: renderedMarkdown)) - .onAppear { -#if DEBUG - let newlineCount = markdown.reduce(into: 0) { count, character in - if character == "\n" { count += 1 } - } - dlog( - "sidebar.description.render workspaceState=appear " + - "len=\((markdown as NSString).length) " + - "newlines=\(newlineCount) " + - "text=\"\(debugCommandPaletteTextPreview(markdown))\"" - ) -#endif - } - .onChange(of: markdown) { newValue in -#if DEBUG - let newlineCount = newValue.reduce(into: 0) { count, character in - if character == "\n" { count += 1 } - } - dlog( - "sidebar.description.render workspaceState=change " + - "len=\((newValue as NSString).length) " + - "newlines=\(newlineCount) " + - "text=\"\(debugCommandPaletteTextPreview(newValue))\"" - ) -#endif - } - } - - private var foregroundColor: Color { - isActive ? .white.opacity(0.84) : .secondary.opacity(0.95) - } - - private func accessibilityText(renderedMarkdown: AttributedString?) -> String { - if let renderedMarkdown { - return String(renderedMarkdown.characters) - } - return markdown - } -} - -enum SidebarMarkdownRenderer { - static func renderWorkspaceDescription(_ markdown: String) -> AttributedString? { - try? AttributedString( - markdown: markdown, - options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) - ) - } -} - -private struct SidebarMetadataRows: View { - let entries: [SidebarStatusEntry] - let isActive: Bool - let onFocus: () -> Void - - @State private var isExpanded: Bool = false - private let collapsedEntryLimit = 3 - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - ForEach(visibleEntries, id: \.key) { entry in - SidebarMetadataEntryRow(entry: entry, isActive: isActive, onFocus: onFocus) - } - - if shouldShowToggle { - Button(isExpanded ? String(localized: "sidebar.metadata.showLess", defaultValue: "Show less") : String(localized: "sidebar.metadata.showMore", defaultValue: "Show more")) { - onFocus() - withAnimation(.easeInOut(duration: 0.15)) { - isExpanded.toggle() - } - } - .buttonStyle(.plain) - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(isActive ? activeSecondaryTextColor : .secondary.opacity(0.9)) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .safeHelp(helpText) - } - - private var activeSecondaryTextColor: Color { - Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.65)) - } - - private var visibleEntries: [SidebarStatusEntry] { - guard !isExpanded, entries.count > collapsedEntryLimit else { return entries } - return Array(entries.prefix(collapsedEntryLimit)) - } - - private var helpText: String { - entries.map { entry in - let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? entry.key : trimmed - } - .joined(separator: "\n") - } - - private var shouldShowToggle: Bool { - entries.count > collapsedEntryLimit - } -} - -private struct SidebarMetadataEntryRow: View { - let entry: SidebarStatusEntry - let isActive: Bool - let onFocus: () -> Void - - var body: some View { - Group { - if let url = entry.url { - Button { - onFocus() - NSWorkspace.shared.open(url) - } label: { - rowContent(underlined: true) - } - .buttonStyle(.plain) - .safeHelp(url.absoluteString) - } else { - rowContent(underlined: false) - .contentShape(Rectangle()) - .onTapGesture { onFocus() } - } - } - } - - @ViewBuilder - private func rowContent(underlined: Bool) -> some View { - HStack(spacing: 4) { - if let icon = iconView { - icon - .foregroundColor(foregroundColor.opacity(0.95)) - } - metadataText(underlined: underlined) - .lineLimit(1) - .truncationMode(.tail) - Spacer(minLength: 0) - } - .font(.system(size: 10)) - .frame(maxWidth: .infinity, alignment: .leading) - } - - private var foregroundColor: Color { - if isActive, - let raw = entry.color, - Color(hex: raw) != nil { - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.95)) - } - if let raw = entry.color, let explicit = Color(hex: raw) { - return explicit - } - return isActive ? .white.opacity(0.8) : .secondary - } - - private var iconView: AnyView? { - guard let iconRaw = entry.icon?.trimmingCharacters(in: .whitespacesAndNewlines), - !iconRaw.isEmpty else { - return nil - } - if iconRaw.hasPrefix("emoji:") { - let value = String(iconRaw.dropFirst("emoji:".count)) - guard !value.isEmpty else { return nil } - return AnyView(Text(value).font(.system(size: 9))) - } - if iconRaw.hasPrefix("text:") { - let value = String(iconRaw.dropFirst("text:".count)) - guard !value.isEmpty else { return nil } - return AnyView(Text(value).font(.system(size: 8, weight: .semibold))) - } - let symbolName: String - if iconRaw.hasPrefix("sf:") { - symbolName = String(iconRaw.dropFirst("sf:".count)) - } else { - symbolName = iconRaw - } - guard !symbolName.isEmpty else { return nil } - return AnyView(Image(systemName: symbolName).font(.system(size: 8, weight: .medium))) - } - - @ViewBuilder - private func metadataText(underlined: Bool) -> some View { - let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) - let display = trimmed.isEmpty ? entry.key : trimmed - if entry.format == .markdown, - let attributed = try? AttributedString( - markdown: display, - options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) - ) { - Text(attributed) - .underline(underlined) - .foregroundColor(foregroundColor) - } else { - Text(display) - .underline(underlined) - .foregroundColor(foregroundColor) - } - } -} - -private struct SidebarMetadataMarkdownBlocks: View { - let blocks: [SidebarMetadataBlock] - let isActive: Bool - let onFocus: () -> Void - - @State private var isExpanded: Bool = false - private let collapsedBlockLimit = 1 - - var body: some View { - VStack(alignment: .leading, spacing: 3) { - ForEach(visibleBlocks, id: \.key) { block in - SidebarMetadataMarkdownBlockRow( - block: block, - isActive: isActive, - onFocus: onFocus - ) - } - - if shouldShowToggle { - Button(isExpanded ? String(localized: "sidebar.metadata.showLessDetails", defaultValue: "Show less details") : String(localized: "sidebar.metadata.showMoreDetails", defaultValue: "Show more details")) { - onFocus() - withAnimation(.easeInOut(duration: 0.15)) { - isExpanded.toggle() - } - } - .buttonStyle(.plain) - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(isActive ? .white.opacity(0.65) : .secondary.opacity(0.9)) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - } - - private var visibleBlocks: [SidebarMetadataBlock] { - guard !isExpanded, blocks.count > collapsedBlockLimit else { return blocks } - return Array(blocks.prefix(collapsedBlockLimit)) - } - - private var shouldShowToggle: Bool { - blocks.count > collapsedBlockLimit - } -} - -private struct SidebarMetadataMarkdownBlockRow: View { - let block: SidebarMetadataBlock - let isActive: Bool - let onFocus: () -> Void - - @State private var renderedMarkdown: AttributedString? - - var body: some View { - Group { - if let renderedMarkdown { - Text(renderedMarkdown) - .foregroundColor(foregroundColor) - } else { - Text(block.markdown) - .foregroundColor(foregroundColor) - } - } - .font(.system(size: 10)) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .contentShape(Rectangle()) - .onTapGesture { onFocus() } - .onAppear(perform: renderMarkdown) - .onChange(of: block.markdown) { _ in - renderMarkdown() - } - } - - private var foregroundColor: Color { - isActive ? .white.opacity(0.8) : .secondary - } - - private func renderMarkdown() { - renderedMarkdown = try? AttributedString( - markdown: block.markdown, - options: .init(interpretedSyntax: .full) - ) - } -} - -enum SidebarDropEdge: Equatable { - case top - case bottom -} - -struct SidebarDropIndicator: Equatable { - let tabId: UUID? - let edge: SidebarDropEdge -} - -enum SidebarDropPlanner { - static func indicator( - draggedTabId: UUID?, - targetTabId: UUID?, - tabIds: [UUID], - pinnedTabIds: Set, - pointerY: CGFloat? = nil, - targetHeight: CGFloat? = nil - ) -> SidebarDropIndicator? { - guard tabIds.count > 1, let draggedTabId else { return nil } - guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil } - - let insertionPosition: Int - if let targetTabId { - guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil } - let edge: SidebarDropEdge - if let pointerY, let targetHeight { - edge = edgeForPointer(locationY: pointerY, targetHeight: targetHeight) - } else { - edge = preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds) - } - insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex - } else { - insertionPosition = tabIds.count - } - - let legalInsertionPosition = legalInsertionPosition( - draggedTabId: draggedTabId, - proposedInsertionPosition: insertionPosition, - tabIds: tabIds, - pinnedTabIds: pinnedTabIds - ) - let legalTargetIndex = resolvedTargetIndex( - from: fromIndex, - insertionPosition: legalInsertionPosition, - totalCount: tabIds.count - ) - guard legalTargetIndex != fromIndex else { return nil } - return indicatorForInsertionPosition(legalInsertionPosition, tabIds: tabIds) - } - - static func targetIndex( - draggedTabId: UUID, - targetTabId: UUID?, - indicator: SidebarDropIndicator?, - tabIds: [UUID], - pinnedTabIds: Set - ) -> Int? { - guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil } - - let insertionPosition: Int - if let indicator, let indicatorInsertion = insertionPositionForIndicator(indicator, tabIds: tabIds) { - insertionPosition = indicatorInsertion - } else if let targetTabId { - guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil } - let edge = (indicator?.tabId == targetTabId) - ? (indicator?.edge ?? preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds)) - : preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds) - insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex - } else { - insertionPosition = tabIds.count - } - - let legalInsertionPosition = legalInsertionPosition( - draggedTabId: draggedTabId, - proposedInsertionPosition: insertionPosition, - tabIds: tabIds, - pinnedTabIds: pinnedTabIds - ) - return resolvedTargetIndex(from: fromIndex, insertionPosition: legalInsertionPosition, totalCount: tabIds.count) - } - - private static func indicatorForInsertionPosition(_ insertionPosition: Int, tabIds: [UUID]) -> SidebarDropIndicator { - let clampedInsertion = max(0, min(insertionPosition, tabIds.count)) - if clampedInsertion >= tabIds.count { - return SidebarDropIndicator(tabId: nil, edge: .bottom) - } - return SidebarDropIndicator(tabId: tabIds[clampedInsertion], edge: .top) - } - - private static func insertionPositionForIndicator(_ indicator: SidebarDropIndicator, tabIds: [UUID]) -> Int? { - if let tabId = indicator.tabId { - guard let targetTabIndex = tabIds.firstIndex(of: tabId) else { return nil } - return indicator.edge == .bottom ? targetTabIndex + 1 : targetTabIndex - } - return tabIds.count - } - - private static func preferredEdge(fromIndex: Int, targetTabId: UUID, tabIds: [UUID]) -> SidebarDropEdge { - guard let targetIndex = tabIds.firstIndex(of: targetTabId) else { return .top } - return fromIndex < targetIndex ? .bottom : .top - } - - private static func legalInsertionPosition( - draggedTabId: UUID, - proposedInsertionPosition: Int, - tabIds: [UUID], - pinnedTabIds: Set - ) -> Int { - let clampedInsertion = max(0, min(proposedInsertionPosition, tabIds.count)) - guard !pinnedTabIds.isEmpty else { return clampedInsertion } - - let pinnedCount = tabIds.reduce(into: 0) { count, tabId in - if pinnedTabIds.contains(tabId) { - count += 1 - } - } - guard pinnedCount > 0 else { return clampedInsertion } - - if pinnedTabIds.contains(draggedTabId) { - return min(clampedInsertion, pinnedCount) - } - return max(clampedInsertion, pinnedCount) - } - - static func edgeForPointer(locationY: CGFloat, targetHeight: CGFloat) -> SidebarDropEdge { - guard targetHeight > 0 else { return .top } - let clampedY = min(max(locationY, 0), targetHeight) - return clampedY < (targetHeight / 2) ? .top : .bottom - } - - private static func resolvedTargetIndex(from sourceIndex: Int, insertionPosition: Int, totalCount: Int) -> Int { - let clampedInsertion = max(0, min(insertionPosition, totalCount)) - let adjusted = clampedInsertion > sourceIndex ? clampedInsertion - 1 : clampedInsertion - return max(0, min(adjusted, max(0, totalCount - 1))) - } -} - -enum SidebarAutoScrollDirection: Equatable { - case up - case down -} - -struct SidebarAutoScrollPlan: Equatable { - let direction: SidebarAutoScrollDirection - let pointsPerTick: CGFloat -} - -enum SidebarDragAutoScrollPlanner { - static let edgeInset: CGFloat = 44 - static let minStep: CGFloat = 2 - static let maxStep: CGFloat = 12 - - static func plan( - distanceToTop: CGFloat, - distanceToBottom: CGFloat, - edgeInset: CGFloat = SidebarDragAutoScrollPlanner.edgeInset, - minStep: CGFloat = SidebarDragAutoScrollPlanner.minStep, - maxStep: CGFloat = SidebarDragAutoScrollPlanner.maxStep - ) -> SidebarAutoScrollPlan? { - guard edgeInset > 0, maxStep >= minStep else { return nil } - if distanceToTop <= edgeInset { - let normalized = max(0, min(1, (edgeInset - distanceToTop) / edgeInset)) - let step = minStep + ((maxStep - minStep) * normalized) - return SidebarAutoScrollPlan(direction: .up, pointsPerTick: step) - } - if distanceToBottom <= edgeInset { - let normalized = max(0, min(1, (edgeInset - distanceToBottom) / edgeInset)) - let step = minStep + ((maxStep - minStep) * normalized) - return SidebarAutoScrollPlan(direction: .down, pointsPerTick: step) - } - return nil - } -} - -@MainActor -private final class SidebarDragAutoScrollController: ObservableObject { - private weak var scrollView: NSScrollView? - private var timer: Timer? - private var activePlan: SidebarAutoScrollPlan? - - func attach(scrollView: NSScrollView?) { - self.scrollView = scrollView - } - - func updateFromDragLocation() { - guard let scrollView else { - stop() - return - } - guard let plan = plan(for: scrollView) else { - stop() - return - } - activePlan = plan - startTimerIfNeeded() - } - - func stop() { - timer?.invalidate() - timer = nil - activePlan = nil - } - - private func startTimerIfNeeded() { - guard timer == nil else { return } - let timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in - Task { @MainActor [weak self] in - self?.tick() - } - } - self.timer = timer - RunLoop.main.add(timer, forMode: .eventTracking) - } - - private func tick() { - guard NSEvent.pressedMouseButtons != 0 else { - stop() - return - } - guard let scrollView else { - stop() - return - } - - // AppKit drag/drop autoscroll guidance recommends autoscroll(with:) - // when periodic drag updates are available; use it first. - if applyNativeAutoscroll(to: scrollView) { - activePlan = plan(for: scrollView) - if activePlan == nil { - stop() - } - return - } - - activePlan = self.plan(for: scrollView) - guard let plan = activePlan else { - stop() - return - } - _ = apply(plan: plan, to: scrollView) - } - - private func applyNativeAutoscroll(to scrollView: NSScrollView) -> Bool { - guard let event = NSApp.currentEvent else { return false } - switch event.type { - case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: - break - default: - return false - } - - let clipView = scrollView.contentView - let didScroll = clipView.autoscroll(with: event) - if didScroll { - scrollView.reflectScrolledClipView(clipView) - } - return didScroll - } - - private func distancesToEdges(mousePoint: CGPoint, viewportHeight: CGFloat, isFlipped: Bool) -> (top: CGFloat, bottom: CGFloat) { - if isFlipped { - return (top: mousePoint.y, bottom: viewportHeight - mousePoint.y) - } - return (top: viewportHeight - mousePoint.y, bottom: mousePoint.y) - } - - private func planForMousePoint(_ mousePoint: CGPoint, in clipView: NSClipView) -> SidebarAutoScrollPlan? { - let viewportHeight = clipView.bounds.height - guard viewportHeight > 0 else { return nil } - - let distances = distancesToEdges(mousePoint: mousePoint, viewportHeight: viewportHeight, isFlipped: clipView.isFlipped) - return SidebarDragAutoScrollPlanner.plan(distanceToTop: distances.top, distanceToBottom: distances.bottom) - } - - private func mousePoint(in clipView: NSClipView) -> CGPoint { - let mouseInWindow = clipView.window?.convertPoint(fromScreen: NSEvent.mouseLocation) ?? .zero - return clipView.convert(mouseInWindow, from: nil) - } - - private func currentPlan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? { - let clipView = scrollView.contentView - let mouse = mousePoint(in: clipView) - return planForMousePoint(mouse, in: clipView) - } - - private func plan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? { - currentPlan(for: scrollView) - } - - private func apply(plan: SidebarAutoScrollPlan, to scrollView: NSScrollView) -> Bool { - guard let documentView = scrollView.documentView else { return false } - let clipView = scrollView.contentView - let maxOriginY = max(0, documentView.bounds.height - clipView.bounds.height) - guard maxOriginY > 0 else { return false } - - let directionMultiplier: CGFloat = (plan.direction == .down) ? 1 : -1 - let flippedMultiplier: CGFloat = documentView.isFlipped ? 1 : -1 - let delta = directionMultiplier * flippedMultiplier * plan.pointsPerTick - let currentY = clipView.bounds.origin.y - let targetY = min(max(currentY + delta, 0), maxOriginY) - guard abs(targetY - currentY) > 0.01 else { return false } - - clipView.scroll(to: CGPoint(x: clipView.bounds.origin.x, y: targetY)) - scrollView.reflectScrolledClipView(clipView) - return true - } -} - -private enum SidebarTabDragPayload { - static let typeIdentifier = "com.darkroom.programa.sidebar-tab-reorder" - static let dropContentType = UTType(exportedAs: typeIdentifier) - static let dropContentTypes: [UTType] = [dropContentType] - private static let prefix = "programa.sidebar-tab." - - static func provider(for tabId: UUID) -> NSItemProvider { - let provider = NSItemProvider() - let payload = "\(prefix)\(tabId.uuidString)" - provider.registerDataRepresentation(forTypeIdentifier: typeIdentifier, visibility: .ownProcess) { completion in - completion(payload.data(using: .utf8), nil) - return nil - } - return provider - } -} - -private enum BonsplitTabDragPayload { - static let typeIdentifier = "com.splittabbar.tabtransfer" - static let dropContentType = UTType(exportedAs: typeIdentifier) - static let dropContentTypes: [UTType] = [dropContentType] - private static let currentProcessId = Int32(ProcessInfo.processInfo.processIdentifier) - - struct Transfer: Decodable { - struct TabInfo: Decodable { - let id: UUID - } - - let tab: TabInfo - let sourcePaneId: UUID - let sourceProcessId: Int32 - - private enum CodingKeys: String, CodingKey { - case tab - case sourcePaneId - case sourceProcessId - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.tab = try container.decode(TabInfo.self, forKey: .tab) - self.sourcePaneId = try container.decode(UUID.self, forKey: .sourcePaneId) - // Legacy payloads won't include this field. Treat as foreign process. - self.sourceProcessId = try container.decodeIfPresent(Int32.self, forKey: .sourceProcessId) ?? -1 - } - } - - private static func isCurrentProcessTransfer(_ transfer: Transfer) -> Bool { - transfer.sourceProcessId == currentProcessId - } - - static func currentTransfer() -> Transfer? { - let pasteboard = NSPasteboard(name: .drag) - let type = NSPasteboard.PasteboardType(typeIdentifier) - - if let data = pasteboard.data(forType: type), - let transfer = try? JSONDecoder().decode(Transfer.self, from: data), - isCurrentProcessTransfer(transfer) { - return transfer - } - - if let raw = pasteboard.string(forType: type), - let data = raw.data(using: .utf8), - let transfer = try? JSONDecoder().decode(Transfer.self, from: data), - isCurrentProcessTransfer(transfer) { - return transfer - } - - return nil - } -} - -private struct SidebarBonsplitTabDropDelegate: 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 - } - - func dropUpdated(info: DropInfo) -> DropProposal? { - guard validateDrop(info: info) else { return nil } - return DropProposal(operation: .move) - } - - func performDrop(info: DropInfo) -> Bool { - guard validateDrop(info: info), - let transfer = BonsplitTabDragPayload.currentTransfer(), - let app = AppDelegate.shared else { - return false - } - - if let source = app.locateBonsplitSurface(tabId: transfer.tab.id), - source.workspaceId == targetWorkspaceId { - syncSidebarSelection() - return true - } - - guard app.moveBonsplitTab( - tabId: transfer.tab.id, - toWorkspace: targetWorkspaceId, - focus: true, - focusWindow: true - ) else { - return false - } - - selectedTabIds = [targetWorkspaceId] - syncSidebarSelection() - return true - } - - private func syncSidebarSelection() { - if let selectedId = tabManager.selectedTabId { - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } - } else { - lastSidebarSelectionIndex = nil - } - } -} - -private struct SidebarTabDropDelegate: DropDelegate { - let targetTabId: UUID? - let tabManager: TabManager - @Binding var draggedTabId: UUID? - @Binding var selectedTabIds: Set - @Binding var lastSidebarSelectionIndex: Int? - let targetRowHeight: CGFloat? - let dragAutoScrollController: SidebarDragAutoScrollController - @Binding var dropIndicator: SidebarDropIndicator? - - func validateDrop(info: DropInfo) -> Bool { - let hasType = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) - let hasDrag = draggedTabId != nil - #if DEBUG - dlog("sidebar.validateDrop target=\(targetTabId?.uuidString.prefix(5) ?? "end") hasType=\(hasType) hasDrag=\(hasDrag)") - #endif - return hasType && hasDrag - } - - func dropEntered(info: DropInfo) { - #if DEBUG - dlog("sidebar.dropEntered target=\(targetTabId?.uuidString.prefix(5) ?? "end")") - #endif - dragAutoScrollController.updateFromDragLocation() - updateDropIndicator(for: info) - } - - func dropExited(info: DropInfo) { -#if DEBUG - dlog("sidebar.dropExited target=\(targetTabId?.uuidString.prefix(5) ?? "end")") -#endif - if dropIndicator?.tabId == targetTabId { - dropIndicator = nil - } - } - - func dropUpdated(info: DropInfo) -> DropProposal? { - dragAutoScrollController.updateFromDragLocation() - updateDropIndicator(for: info) -#if DEBUG - dlog( - "sidebar.dropUpdated target=\(targetTabId?.uuidString.prefix(5) ?? "end") " + - "indicator=\(debugIndicator(dropIndicator))" - ) -#endif - return DropProposal(operation: .move) - } - - func performDrop(info: DropInfo) -> Bool { - defer { - draggedTabId = nil - dropIndicator = nil - dragAutoScrollController.stop() - } - #if DEBUG - dlog("sidebar.drop target=\(targetTabId?.uuidString.prefix(5) ?? "end")") - #endif - guard let draggedTabId else { -#if DEBUG - dlog("sidebar.drop.abort reason=missingDraggedTab") -#endif - return false - } - guard let fromIndex = tabManager.tabs.firstIndex(where: { $0.id == draggedTabId }) else { -#if DEBUG - dlog("sidebar.drop.abort reason=draggedTabMissing tab=\(draggedTabId.uuidString.prefix(5))") -#endif - return false - } - let tabIds = tabManager.tabs.map(\.id) - guard let targetIndex = SidebarDropPlanner.targetIndex( - draggedTabId: draggedTabId, - targetTabId: targetTabId, - indicator: dropIndicator, - tabIds: tabIds, - pinnedTabIds: Set(tabManager.tabs.filter(\.isPinned).map(\.id)) - ) else { -#if DEBUG - dlog( - "sidebar.drop.abort reason=noTargetIndex tab=\(draggedTabId.uuidString.prefix(5)) " + - "target=\(targetTabId?.uuidString.prefix(5) ?? "end") indicator=\(debugIndicator(dropIndicator))" - ) -#endif - return false - } - - guard fromIndex != targetIndex else { -#if DEBUG - dlog("sidebar.drop.noop from=\(fromIndex) to=\(targetIndex)") -#endif - syncSidebarSelection() - return true - } - -#if DEBUG - 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 { - selectedTabIds = [selectedId] - syncSidebarSelection(preferredSelectedTabId: selectedId) - } else { - selectedTabIds = [] - syncSidebarSelection() - } - return true - } - - private func updateDropIndicator(for info: DropInfo) { - let tabIds = tabManager.tabs.map(\.id) - let pinnedTabIds = Set(tabManager.tabs.filter(\.isPinned).map(\.id)) - let nextIndicator = SidebarDropPlanner.indicator( - draggedTabId: draggedTabId, - targetTabId: targetTabId, - tabIds: tabIds, - pinnedTabIds: pinnedTabIds, - pointerY: targetTabId == nil ? nil : info.location.y, - targetHeight: targetRowHeight - ) - guard dropIndicator != nextIndicator else { return } - dropIndicator = nextIndicator - } - - private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { - let selectedId = preferredSelectedTabId ?? tabManager.selectedTabId - if let selectedId { - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } - } else { - lastSidebarSelectionIndex = nil - } - } - - private func debugIndicator(_ indicator: SidebarDropIndicator?) -> String { - guard let indicator else { return "nil" } - let tabText = indicator.tabId.map { String($0.uuidString.prefix(5)) } ?? "end" - return "\(tabText):\(indicator.edge == .top ? "top" : "bottom")" - } -} - -private struct MiddleClickCapture: NSViewRepresentable { - let onMiddleClick: () -> Void - - func makeNSView(context: Context) -> MiddleClickCaptureView { - let view = MiddleClickCaptureView() - view.onMiddleClick = onMiddleClick - return view - } - - func updateNSView(_ nsView: MiddleClickCaptureView, context: Context) { - nsView.onMiddleClick = onMiddleClick - } -} - -private final class MiddleClickCaptureView: NSView { - var onMiddleClick: (() -> Void)? - - override func hitTest(_ point: NSPoint) -> NSView? { - // Only intercept middle-click so left-click selection and right-click context menus - // continue to hit-test through to SwiftUI/AppKit normally. - guard let event = NSApp.currentEvent, - event.type == .otherMouseDown, - event.buttonNumber == 2 else { - return nil - } - return self - } - - override func otherMouseDown(with event: NSEvent) { - guard event.buttonNumber == 2 else { - super.otherMouseDown(with: event) - return - } - onMiddleClick?() - } -} - -enum SidebarSelection { - case tabs - case notifications -} - -private struct ClearScrollBackground: ViewModifier { - func body(content: Content) -> some View { - content - .scrollContentBackground(.hidden) - .background(ScrollBackgroundClearer()) - } -} - -private struct ScrollBackgroundClearer: NSViewRepresentable { - func makeNSView(context: Context) -> NSView { - NSView() - } - - func updateNSView(_ nsView: NSView, context: Context) { - DispatchQueue.main.async { - guard let scrollView = findScrollView(startingAt: nsView) else { return } - // Clear all backgrounds and mark as non-opaque for transparency - scrollView.drawsBackground = false - scrollView.backgroundColor = .clear - scrollView.wantsLayer = true - scrollView.layer?.backgroundColor = NSColor.clear.cgColor - scrollView.layer?.isOpaque = false - - scrollView.contentView.drawsBackground = false - scrollView.contentView.backgroundColor = .clear - scrollView.contentView.wantsLayer = true - scrollView.contentView.layer?.backgroundColor = NSColor.clear.cgColor - scrollView.contentView.layer?.isOpaque = false - - if let docView = scrollView.documentView { - docView.wantsLayer = true - docView.layer?.backgroundColor = NSColor.clear.cgColor - docView.layer?.isOpaque = false - } - } - } - - private func findScrollView(startingAt view: NSView) -> NSScrollView? { - var current: NSView? = view - while let candidate = current { - if let scrollView = candidate as? NSScrollView { - return scrollView - } - current = candidate.superview - } - return nil - } -} - -private struct DraggableFolderIcon: View { - let directory: String - - var body: some View { - DraggableFolderIconRepresentable(directory: directory) - .frame(width: 16, height: 16) - .safeHelp(String(localized: "sidebar.folderIcon.dragHint", defaultValue: "Drag to open in Finder or another app")) - .onTapGesture(count: 2) { - NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directory) - } - } -} - -private struct DraggableFolderIconRepresentable: NSViewRepresentable { - let directory: String - - func makeNSView(context: Context) -> DraggableFolderNSView { - DraggableFolderNSView(directory: directory) - } - - func updateNSView(_ nsView: DraggableFolderNSView, context: Context) { - nsView.directory = directory - nsView.updateIcon() - } -} - -final class DraggableFolderNSView: NSView, NSDraggingSource { - private final class FolderIconImageView: NSImageView { - override var mouseDownCanMoveWindow: Bool { false } - } - - var directory: String - private var imageView: FolderIconImageView! - private var previousWindowMovableState: Bool? - private weak var suppressedWindow: NSWindow? - private var hasActiveDragSession = false - private var didArmWindowDragSuppression = false - - private func formatPoint(_ point: NSPoint) -> String { - String(format: "(%.1f,%.1f)", point.x, point.y) - } - - init(directory: String) { - self.directory = directory - super.init(frame: .zero) - setupImageView() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override var intrinsicContentSize: NSSize { - NSSize(width: 16, height: 16) - } - - override var mouseDownCanMoveWindow: Bool { false } - - private func setupImageView() { - imageView = FolderIconImageView() - imageView.imageScaling = .scaleProportionallyDown - imageView.translatesAutoresizingMaskIntoConstraints = false - addSubview(imageView) - NSLayoutConstraint.activate([ - imageView.leadingAnchor.constraint(equalTo: leadingAnchor), - imageView.trailingAnchor.constraint(equalTo: trailingAnchor), - imageView.topAnchor.constraint(equalTo: topAnchor), - imageView.bottomAnchor.constraint(equalTo: bottomAnchor), - imageView.widthAnchor.constraint(equalToConstant: 16), - imageView.heightAnchor.constraint(equalToConstant: 16), - ]) - updateIcon() - } - - func updateIcon() { - let icon = NSWorkspace.shared.icon(forFile: directory) - icon.size = NSSize(width: 16, height: 16) - imageView.image = icon - } - - func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { - return context == .outsideApplication ? [.copy, .link] : .copy - } - - func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { - hasActiveDragSession = false - restoreWindowMovableStateIfNeeded() - #if DEBUG - let nowMovable = window.map { String($0.isMovable) } ?? "nil" - let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil" - dlog("folder.dragEnd dir=\(directory) operation=\(operation.rawValue) screen=\(formatPoint(screenPoint)) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)") - #endif - } - - override func hitTest(_ point: NSPoint) -> NSView? { - guard bounds.contains(point) else { return nil } - let hit = super.hitTest(point) - #if DEBUG - let hitDesc = hit.map { String(describing: type(of: $0)) } ?? "nil" - let imageHit = (hit === imageView) - let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" - let nowMovable = window.map { String($0.isMovable) } ?? "nil" - dlog("folder.hitTest point=\(formatPoint(point)) hit=\(hitDesc) imageViewHit=\(imageHit) returning=DraggableFolderNSView wasMovable=\(wasMovable) nowMovable=\(nowMovable)") - #endif - return self - } - - override func mouseDown(with event: NSEvent) { - maybeDisableWindowDraggingEarly(trigger: "mouseDown") - hasActiveDragSession = false - #if DEBUG - let localPoint = convert(event.locationInWindow, from: nil) - let responderDesc = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" - let nowMovable = window.map { String($0.isMovable) } ?? "nil" - let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil" - 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) - - let iconImage = NSWorkspace.shared.icon(forFile: directory) - iconImage.size = NSSize(width: 32, height: 32) - draggingItem.setDraggingFrame(bounds, contents: iconImage) - - let session = beginDraggingSession(with: [draggingItem], event: event, source: self) - hasActiveDragSession = true - #if DEBUG - let itemCount = session.draggingPasteboard.pasteboardItems?.count ?? 0 - dlog("folder.dragStart dir=\(directory) pasteboardItems=\(itemCount)") - #endif - } - - override func mouseUp(with event: NSEvent) { - super.mouseUp(with: event) - // Always restore suppression on mouse-up; drag-session callbacks can be - // skipped for non-started drags, which would otherwise leave suppression stuck. - restoreWindowMovableStateIfNeeded() - } - - override func rightMouseDown(with event: NSEvent) { - let menu = buildPathMenu() - // Pop up menu at bottom-left of icon (like native proxy icon) - let menuLocation = NSPoint(x: 0, y: bounds.height) - menu.popUp(positioning: nil, at: menuLocation, in: self) - } - - private func buildPathMenu() -> NSMenu { - let menu = NSMenu() - let url = URL(fileURLWithPath: directory).standardized - var pathComponents: [URL] = [] - - // Build path from current directory up to root - var current = url - while current.path != "/" { - pathComponents.append(current) - current = current.deletingLastPathComponent() - } - pathComponents.append(URL(fileURLWithPath: "/")) - - // Add path components (current dir at top, root at bottom - matches native macOS) - for pathURL in pathComponents { - let icon = NSWorkspace.shared.icon(forFile: pathURL.path) - icon.size = NSSize(width: 16, height: 16) - - let displayName: String - if pathURL.path == "/" { - // Use the volume name for root - if let volumeName = try? URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeNameKey]).volumeName { - displayName = volumeName - } else { - displayName = String(localized: "sidebar.pathMenu.macintoshHD", defaultValue: "Macintosh HD") - } - } else { - displayName = FileManager.default.displayName(atPath: pathURL.path) - } - - let item = NSMenuItem(title: displayName, action: #selector(openPathComponent(_:)), keyEquivalent: "") - item.target = self - item.image = icon - item.representedObject = pathURL - menu.addItem(item) - } - - // Add computer name at the bottom (like native proxy icon) - let computerName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName - let computerIcon = NSImage(named: NSImage.computerName) ?? NSImage() - computerIcon.size = NSSize(width: 16, height: 16) - - let computerItem = NSMenuItem(title: computerName, action: #selector(openComputer(_:)), keyEquivalent: "") - computerItem.target = self - computerItem.image = computerIcon - menu.addItem(computerItem) - - return menu - } - - @objc private func openPathComponent(_ sender: NSMenuItem) { - guard let url = sender.representedObject as? URL else { return } - NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: url.path) - } - - @objc private func openComputer(_ sender: NSMenuItem) { - // Open "Computer" view in Finder (shows all volumes) - NSWorkspace.shared.open(URL(fileURLWithPath: "/", isDirectory: true)) - } - - private func restoreWindowMovableStateIfNeeded() { - guard didArmWindowDragSuppression || previousWindowMovableState != nil else { return } - let targetWindow = suppressedWindow ?? window - let depthAfter = endWindowDragSuppression(window: targetWindow) - restoreWindowDragging(window: targetWindow, previousMovableState: previousWindowMovableState) - self.previousWindowMovableState = nil - self.suppressedWindow = nil - self.didArmWindowDragSuppression = false - #if DEBUG - let nowMovable = targetWindow.map { String($0.isMovable) } ?? "nil" - dlog("folder.dragSuppression restore depth=\(depthAfter) nowMovable=\(nowMovable)") - #endif - } - - private func maybeDisableWindowDraggingEarly(trigger: String) { - guard !didArmWindowDragSuppression else { return } - guard let eventType = NSApp.currentEvent?.type, - eventType == .leftMouseDown || eventType == .leftMouseDragged else { - return - } - guard let currentWindow = window else { return } - - didArmWindowDragSuppression = true - suppressedWindow = currentWindow - let suppressionDepth = beginWindowDragSuppression(window: currentWindow) ?? 0 - if currentWindow.isMovable { - previousWindowMovableState = temporarilyDisableWindowDragging(window: currentWindow) - } else { - previousWindowMovableState = nil - } - #if DEBUG - let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" - let nowMovable = String(currentWindow.isMovable) - dlog( - "folder.dragSuppression trigger=\(trigger) event=\(eventType) depth=\(suppressionDepth) wasMovable=\(wasMovable) nowMovable=\(nowMovable)" - ) - #endif - } -} - -func temporarilyDisableWindowDragging(window: NSWindow?) -> Bool? { - guard let window else { return nil } - let wasMovable = window.isMovable - if wasMovable { - window.isMovable = false - } - return wasMovable -} - -func restoreWindowDragging(window: NSWindow?, previousMovableState: Bool?) { - guard let window, let previousMovableState else { return } - window.isMovable = previousMovableState -} - -/// Wrapper view that tries NSGlassEffectView (macOS 26+) when available or requested -private struct SidebarVisualEffectBackground: NSViewRepresentable { - let material: NSVisualEffectView.Material - let blendingMode: NSVisualEffectView.BlendingMode - let state: NSVisualEffectView.State - let opacity: Double - let tintColor: NSColor? - let cornerRadius: CGFloat - let preferLiquidGlass: Bool - - init( - material: NSVisualEffectView.Material = .hudWindow, - blendingMode: NSVisualEffectView.BlendingMode = .behindWindow, - state: NSVisualEffectView.State = .active, - opacity: Double = 1.0, - tintColor: NSColor? = nil, - cornerRadius: CGFloat = 0, - preferLiquidGlass: Bool = false - ) { - self.material = material - self.blendingMode = blendingMode - self.state = state - self.opacity = opacity - self.tintColor = tintColor - self.cornerRadius = cornerRadius - self.preferLiquidGlass = preferLiquidGlass - } - - static var liquidGlassAvailable: Bool { - NSClassFromString("NSGlassEffectView") != nil - } - - func makeNSView(context: Context) -> NSView { - // Try NSGlassEffectView if preferred or if we want to test availability - if preferLiquidGlass, let glassClass = NSClassFromString("NSGlassEffectView") as? NSView.Type { - let glass = glassClass.init(frame: .zero) - glass.autoresizingMask = [.width, .height] - glass.wantsLayer = true - return glass - } - - // Use NSVisualEffectView - let view = NSVisualEffectView() - view.autoresizingMask = [.width, .height] - view.wantsLayer = true - view.layerContentsRedrawPolicy = .onSetNeedsDisplay - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - // Configure based on view type - if nsView.className == "NSGlassEffectView" { - // NSGlassEffectView configuration via private API - nsView.alphaValue = max(0.0, min(1.0, opacity)) - nsView.layer?.cornerRadius = cornerRadius - nsView.layer?.masksToBounds = cornerRadius > 0 - - // Try to set tint color via private selector - if let color = tintColor { - let selector = NSSelectorFromString("setTintColor:") - if nsView.responds(to: selector) { - nsView.perform(selector, with: color) - } - } - } else if let visualEffect = nsView as? NSVisualEffectView { - // NSVisualEffectView configuration - visualEffect.material = material - visualEffect.blendingMode = blendingMode - visualEffect.state = state - visualEffect.alphaValue = max(0.0, min(1.0, opacity)) - visualEffect.layer?.cornerRadius = cornerRadius - visualEffect.layer?.masksToBounds = cornerRadius > 0 - visualEffect.needsDisplay = true - } - } -} - -/// Reads the leading inset required to clear traffic lights + left titlebar accessories. -final class TitlebarLeadingInsetPassthroughView: NSView { - override var mouseDownCanMoveWindow: Bool { false } - override func hitTest(_ point: NSPoint) -> NSView? { nil } -} - -private struct TitlebarLeadingInsetReader: NSViewRepresentable { - @Binding var inset: CGFloat - - func makeNSView(context: Context) -> NSView { - let view = TitlebarLeadingInsetPassthroughView() - view.setFrameSize(.zero) - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - DispatchQueue.main.async { - guard let window = nsView.window else { return } - // 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 - } - leading += 0 - if leading != inset { - inset = leading - } - } - } -} - -/// 1px trailing border on the sidebar, derived from the terminal chrome background -/// using the same logic as bonsplit's TabBarColors.nsColorSeparator: -/// dark bg → lighten RGB by 0.16 at 0.36 alpha; light bg → darken by 0.12 at 0.26 alpha. -private struct SidebarTrailingBorder: View { - @AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = false - @State private var separatorColor: NSColor = chromeSeparatorColor() - - var body: some View { - if matchTerminalBackground { - Rectangle() - .fill(Color(nsColor: separatorColor)) - .frame(width: 1) - .ignoresSafeArea() - .onAppear { - separatorColor = Self.chromeSeparatorColor() - } - .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in - separatorColor = Self.chromeSeparatorColor() - } - } - } - - /// Replicates bonsplit TabBarColors.nsColorSeparator derivation from chrome background. - private static func chromeSeparatorColor() -> NSColor { - let chrome = GhosttyBackgroundTheme.currentColor() - let srgb = chrome.usingColorSpace(.sRGB) ?? chrome - var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 - srgb.getRed(&r, green: &g, blue: &b, alpha: &a) - let luminance = 0.299 * r + 0.587 * g + 0.114 * b - let isLight = luminance > 0.5 - let amount: CGFloat = isLight ? -0.12 : 0.16 - let alpha: CGFloat = isLight ? 0.26 : 0.36 - return NSColor( - red: min(1.0, max(0.0, r + amount)), - green: min(1.0, max(0.0, g + amount)), - blue: min(1.0, max(0.0, b + amount)), - alpha: alpha - ) - } -} - -/// Sidebar background that uses the same technique as TitlebarLayerBackground: -/// fully opaque layer color + layer-level opacity. This matches how the terminal's -/// Metal surface composites its background. -private struct SidebarTerminalBackgroundView: NSViewRepresentable { - let backgroundColor: NSColor - let 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) - } -} - -private struct SidebarBackdrop: View { - @AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = false - @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity - @AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex - @AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String? - @AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String? - @AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue - @AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue - @AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue - @AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0 - @AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 1.0 - @Environment(\.colorScheme) private var colorScheme - @State private var terminalBackgroundColor: NSColor = GhosttyBackgroundTheme.currentColor() - - var body: some View { - let cornerRadius = CGFloat(max(0, sidebarCornerRadius)) - - if matchTerminalBackground { - // The terminal background is provided by a single CALayer, so - // the sidebar uses the configured opacity directly. - let alpha = CGFloat(GhosttyApp.shared.defaultBackgroundOpacity) - return AnyView( - SidebarTerminalBackgroundView( - backgroundColor: GhosttyApp.shared.defaultBackgroundColor, - opacity: alpha - ) - .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) - .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in - terminalBackgroundColor = GhosttyBackgroundTheme.currentColor() - } - ) - } - - let materialOption = SidebarMaterialOption(rawValue: sidebarMaterial) - let blendingMode = SidebarBlendModeOption(rawValue: sidebarBlendMode)?.mode ?? .behindWindow - let state = SidebarStateOption(rawValue: sidebarState)?.state ?? .active - let resolvedHex: String = { - if colorScheme == .dark, let dark = sidebarTintHexDark { - return dark - } else if colorScheme == .light, let light = sidebarTintHexLight { - return light - } - return sidebarTintHex - }() - let tintColor = (NSColor(hex: resolvedHex) ?? NSColor(hex: sidebarTintHex) ?? .black).withAlphaComponent(sidebarTintOpacity) - let useLiquidGlass = materialOption?.usesLiquidGlass ?? false - let useWindowLevelGlass = useLiquidGlass && blendingMode == .behindWindow - - return AnyView( - ZStack { - if let material = materialOption?.material { - // When using liquidGlass + behindWindow, window handles glass + tint - // Sidebar is fully transparent - if !useWindowLevelGlass { - SidebarVisualEffectBackground( - material: material, - blendingMode: blendingMode, - state: state, - opacity: sidebarBlurOpacity, - tintColor: tintColor, - cornerRadius: cornerRadius, - preferLiquidGlass: useLiquidGlass - ) - // Tint overlay for NSVisualEffectView fallback - if !useLiquidGlass { - Color(nsColor: tintColor) - } - } - } - // When material is none or useWindowLevelGlass, render nothing - } - .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) - ) - } -} - -enum SidebarMaterialOption: String, CaseIterable, Identifiable { - case none - case liquidGlass // macOS 26+ NSGlassEffectView - case sidebar - case hudWindow - case menu - case popover - case underWindowBackground - case windowBackground - case contentBackground - case fullScreenUI - case sheet - case headerView - case toolTip - - var id: String { rawValue } - - var title: String { - switch self { - case .none: return String(localized: "settings.material.none", defaultValue: "None") - case .liquidGlass: return String(localized: "settings.material.liquidGlass", defaultValue: "Liquid Glass (macOS 26+)") - case .sidebar: return String(localized: "settings.material.sidebar", defaultValue: "Sidebar") - case .hudWindow: return String(localized: "settings.material.hudWindow", defaultValue: "HUD Window") - case .menu: return String(localized: "settings.material.menu", defaultValue: "Menu") - case .popover: return String(localized: "settings.material.popover", defaultValue: "Popover") - case .underWindowBackground: return String(localized: "settings.material.underWindow", defaultValue: "Under Window") - case .windowBackground: return String(localized: "settings.material.windowBackground", defaultValue: "Window Background") - case .contentBackground: return String(localized: "settings.material.contentBackground", defaultValue: "Content Background") - case .fullScreenUI: return String(localized: "settings.material.fullScreenUI", defaultValue: "Full Screen UI") - case .sheet: return String(localized: "settings.material.sheet", defaultValue: "Sheet") - case .headerView: return String(localized: "settings.material.headerView", defaultValue: "Header View") - case .toolTip: return String(localized: "settings.material.toolTip", defaultValue: "Tool Tip") - } - } - - /// Returns true if this option should use NSGlassEffectView (macOS 26+) - var usesLiquidGlass: Bool { - self == .liquidGlass - } - - var material: NSVisualEffectView.Material? { - switch self { - case .none: return nil - case .liquidGlass: return .underWindowBackground // Fallback material - case .sidebar: return .sidebar - case .hudWindow: return .hudWindow - case .menu: return .menu - case .popover: return .popover - case .underWindowBackground: return .underWindowBackground - case .windowBackground: return .windowBackground - case .contentBackground: return .contentBackground - case .fullScreenUI: return .fullScreenUI - case .sheet: return .sheet - case .headerView: return .headerView - case .toolTip: return .toolTip - } - } -} - -enum SidebarBlendModeOption: String, CaseIterable, Identifiable { - case behindWindow - case withinWindow - - var id: String { rawValue } - - var title: String { - switch self { - case .behindWindow: return String(localized: "settings.blendMode.behindWindow", defaultValue: "Behind Window") - case .withinWindow: return String(localized: "settings.blendMode.withinWindow", defaultValue: "Within Window") - } - } - - var mode: NSVisualEffectView.BlendingMode { - switch self { - case .behindWindow: return .behindWindow - case .withinWindow: return .withinWindow - } - } -} - -enum SidebarStateOption: String, CaseIterable, Identifiable { - case active - case inactive - case followWindow - - var id: String { rawValue } - - var title: String { - switch self { - case .active: return String(localized: "settings.state.active", defaultValue: "Active") - case .inactive: return String(localized: "settings.state.inactive", defaultValue: "Inactive") - case .followWindow: return String(localized: "settings.state.followWindow", defaultValue: "Follow Window") - } - } - - var state: NSVisualEffectView.State { - switch self { - case .active: return .active - case .inactive: return .inactive - case .followWindow: return .followsWindowActiveState - } - } -} - -enum SidebarTintDefaults { - static let hex = "#000000" - static let opacity = 0.18 -} - -enum SidebarPresetOption: String, CaseIterable, Identifiable { - case nativeSidebar - case glassBehind - case softBlur - case popoverGlass - case hudGlass - case underWindow - - var id: String { rawValue } - - var title: String { - switch self { - case .nativeSidebar: return String(localized: "settings.preset.nativeSidebar", defaultValue: "Native Sidebar") - case .glassBehind: return String(localized: "settings.preset.raycastGray", defaultValue: "Raycast Gray") - case .softBlur: return String(localized: "settings.preset.softBlur", defaultValue: "Soft Blur") - case .popoverGlass: return String(localized: "settings.preset.popoverGlass", defaultValue: "Popover Glass") - case .hudGlass: return String(localized: "settings.preset.hudGlass", defaultValue: "HUD Glass") - case .underWindow: return String(localized: "settings.preset.underWindow", defaultValue: "Under Window") - } - } - - var material: SidebarMaterialOption { - switch self { - case .nativeSidebar: return .sidebar - case .glassBehind: return .sidebar - case .softBlur: return .sidebar - case .popoverGlass: return .popover - case .hudGlass: return .hudWindow - case .underWindow: return .underWindowBackground - } - } - - var blendMode: SidebarBlendModeOption { - switch self { - case .nativeSidebar: return .withinWindow - case .glassBehind: return .behindWindow - case .softBlur: return .behindWindow - case .popoverGlass: return .behindWindow - case .hudGlass: return .withinWindow - case .underWindow: return .withinWindow - } - } - - var state: SidebarStateOption { - switch self { - case .nativeSidebar: return .followWindow - case .glassBehind: return .active - case .softBlur: return .active - case .popoverGlass: return .active - case .hudGlass: return .active - case .underWindow: return .followWindow - } - } - - var tintHex: String { - switch self { - case .nativeSidebar: return "#000000" - case .glassBehind: return "#000000" - case .softBlur: return "#000000" - case .popoverGlass: return "#000000" - case .hudGlass: return "#000000" - case .underWindow: return "#000000" - } - } - - var tintOpacity: Double { - switch self { - case .nativeSidebar: return 0.18 - case .glassBehind: return 0.36 - case .softBlur: return 0.28 - case .popoverGlass: return 0.10 - case .hudGlass: return 0.62 - case .underWindow: return 0.14 - } - } - - var cornerRadius: Double { - switch self { - case .nativeSidebar: return 0.0 - case .glassBehind: return 0.0 - case .softBlur: return 0.0 - case .popoverGlass: return 10.0 - case .hudGlass: return 10.0 - case .underWindow: return 6.0 - } - } - - var blurOpacity: Double { - switch self { - case .nativeSidebar: return 1.0 - case .glassBehind: return 0.6 - case .softBlur: return 0.45 - case .popoverGlass: return 0.9 - case .hudGlass: return 0.98 - case .underWindow: return 0.9 - } - } -} - -extension NSColor { - func hexString(includeAlpha: Bool = false) -> String { - let color = usingColorSpace(.sRGB) ?? self - var red: CGFloat = 0 - var green: CGFloat = 0 - var blue: CGFloat = 0 - var alpha: CGFloat = 0 - color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) - let redByte = min(255, max(0, Int((red * 255).rounded()))) - let greenByte = min(255, max(0, Int((green * 255).rounded()))) - let blueByte = min(255, max(0, Int((blue * 255).rounded()))) - if includeAlpha { - let alphaByte = min(255, max(0, Int((alpha * 255).rounded()))) - return String(format: "#%02X%02X%02X%02X", redByte, greenByte, blueByte, alphaByte) - } - return String(format: "#%02X%02X%02X", redByte, greenByte, blueByte) - } -} diff --git a/Sources/FileDropOverlayView.swift b/Sources/FileDropOverlayView.swift new file mode 100644 index 00000000000..dcff59c5369 --- /dev/null +++ b/Sources/FileDropOverlayView.swift @@ -0,0 +1,489 @@ +// File-drop overlay NSView, extracted from ContentView.swift (nuclear-review #94.3). +// Pure move — behavior-identical relocation. + +import AppKit +import Bonsplit +import WebKit + + +/// 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 +/// 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. +/// +/// Mouse events are forwarded to the views below via a hide-send-unhide pattern so clicks, +/// scrolls, and other interactions pass through normally. +final class FileDropOverlayView: NSView { + /// Fallback handler when no terminal is found under the drop point. + var onDrop: (([URL]) -> Bool)? + private var isForwardingMouseEvent = false + private weak var forwardedMouseDragTarget: NSView? + private var forwardedMouseDragButton: ForwardedMouseDragButton? + /// The WKWebView currently receiving forwarded drag events, so we can + /// synthesize draggingExited/draggingEntered as the cursor moves. + private weak var activeDragWebView: WKWebView? + /// The WKWebView that accepted prepareForDragOperation so conclude can be + /// delivered to the same browser target after the drop completes. + private weak var preparedDragWebView: WKWebView? + private var lastHitTestLogSignature: String? + private var lastDragRouteLogSignatureByPhase: [String: String] = [:] + + override var acceptsFirstResponder: Bool { false } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + registerForDraggedTypes([.fileURL]) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") } + + private enum ForwardedMouseDragButton: Equatable { + case left + case right + case other(Int) + } + + private func dragButton(for event: NSEvent) -> ForwardedMouseDragButton? { + switch event.type { + case .leftMouseDown, .leftMouseUp, .leftMouseDragged: + return .left + case .rightMouseDown, .rightMouseUp, .rightMouseDragged: + return .right + case .otherMouseDown, .otherMouseUp, .otherMouseDragged: + return .other(Int(event.buttonNumber)) + default: + return nil + } + } + + private func shouldTrackForwardedMouseDragStart(for eventType: NSEvent.EventType) -> Bool { + switch eventType { + case .leftMouseDown, .rightMouseDown, .otherMouseDown: + return true + default: + return false + } + } + + private func shouldTrackForwardedMouseDragEnd(for eventType: NSEvent.EventType) -> Bool { + switch eventType { + case .leftMouseUp, .rightMouseUp, .otherMouseUp: + return true + default: + return false + } + } + + // MARK: Hit-testing — participation is routed by DragOverlayRoutingPolicy so + // file-drop, bonsplit tab drags, and sidebar tab reorder drags cannot conflict. + + override func hitTest(_ point: NSPoint) -> NSView? { + let pb = NSPasteboard(name: .drag) + let eventType = NSApp.currentEvent?.type + let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropOverlay( + pasteboardTypes: pb.types, + eventType: eventType + ) +#if DEBUG + logHitTestDecision( + pasteboardTypes: pb.types, + eventType: eventType, + shouldCapture: shouldCapture + ) +#endif + guard shouldCapture else { return nil } + + return super.hitTest(point) + } + + // MARK: Mouse forwarding — safety net for the rare case where stale drag pasteboard + // data causes hitTest to return self when no drag is actually active. + // We hit-test contentView directly and dispatch to the target rather than using + // window.sendEvent(), which caches the mouse target and causes infinite recursion. + + private func forwardEvent(_ event: NSEvent) { + guard !isForwardingMouseEvent else { return } + guard let window, let contentView = window.contentView else { return } + let eventButton = dragButton(for: event) + + isForwardingMouseEvent = true + isHidden = true + defer { + isHidden = false + isForwardingMouseEvent = false + } + + let target: NSView? + if let eventButton, + forwardedMouseDragButton == eventButton, + let activeTarget = forwardedMouseDragTarget, + activeTarget.window != nil { + // Preserve normal AppKit mouse-delivery semantics: once a drag starts, + // keep routing dragged/up events to the original mouseDown target. + target = activeTarget + } else { + let point = contentView.convert(event.locationInWindow, from: nil) + target = contentView.hitTest(point) + } + + guard let target, target !== self else { + if shouldTrackForwardedMouseDragEnd(for: event.type), + let eventButton, + forwardedMouseDragButton == eventButton { + forwardedMouseDragTarget = nil + forwardedMouseDragButton = nil + } + return + } + + if shouldTrackForwardedMouseDragStart(for: event.type), let eventButton { + forwardedMouseDragTarget = target + forwardedMouseDragButton = eventButton + } + + switch event.type { + case .leftMouseDown: target.mouseDown(with: event) + case .leftMouseUp: target.mouseUp(with: event) + case .leftMouseDragged: target.mouseDragged(with: event) + case .rightMouseDown: target.rightMouseDown(with: event) + case .rightMouseUp: target.rightMouseUp(with: event) + case .rightMouseDragged: target.rightMouseDragged(with: event) + case .otherMouseDown: target.otherMouseDown(with: event) + case .otherMouseUp: target.otherMouseUp(with: event) + case .otherMouseDragged: target.otherMouseDragged(with: event) + case .scrollWheel: target.scrollWheel(with: event) + default: break + } + + if shouldTrackForwardedMouseDragEnd(for: event.type), + let eventButton, + forwardedMouseDragButton == eventButton { + forwardedMouseDragTarget = nil + forwardedMouseDragButton = nil + } + } + + override func mouseDown(with event: NSEvent) { forwardEvent(event) } + override func mouseUp(with event: NSEvent) { forwardEvent(event) } + override func mouseDragged(with event: NSEvent) { forwardEvent(event) } + override func rightMouseDown(with event: NSEvent) { forwardEvent(event) } + override func rightMouseUp(with event: NSEvent) { forwardEvent(event) } + override func rightMouseDragged(with event: NSEvent) { forwardEvent(event) } + override func otherMouseDown(with event: NSEvent) { forwardEvent(event) } + override func otherMouseUp(with event: NSEvent) { forwardEvent(event) } + override func otherMouseDragged(with event: NSEvent) { forwardEvent(event) } + override func scrollWheel(with event: NSEvent) { forwardEvent(event) } + + // MARK: NSDraggingDestination – accept file drops over terminal and browser views. + // + // AppKit sends draggingEntered once when the drag enters this overlay, then + // draggingUpdated as the cursor moves within it. We track which WKWebView (if + // any) is under the cursor and synthesize enter/exit calls so the browser's + // HTML5 drag events (dragenter, dragleave, drop) fire correctly. + + override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + return updateDragTarget(sender, phase: "entered") + } + + override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { + return updateDragTarget(sender, phase: "updated") + } + + override func draggingExited(_ sender: (any NSDraggingInfo)?) { + preparedDragWebView = nil + if let prev = activeDragWebView { + prev.draggingExited(sender) + activeDragWebView = nil + } + } + + override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { + let hasLocalDraggingSource = sender.draggingSource != nil + let types = sender.draggingPasteboard.types + let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( + pasteboardTypes: types, + hasLocalDraggingSource: hasLocalDraggingSource + ) + let webView = shouldCapture ? (activeDragWebView ?? webViewUnderPoint(sender.draggingLocation)) : nil + let terminal = terminalUnderPoint(sender.draggingLocation) + let hasTerminalTarget = terminal != nil +#if DEBUG + logDragRouteDecision( + phase: "prepare", + pasteboardTypes: types, + shouldCapture: shouldCapture, + hasLocalDraggingSource: hasLocalDraggingSource, + hasTerminalTarget: hasTerminalTarget + ) +#endif + guard shouldCapture else { + preparedDragWebView = nil + return false + } + if let webView { + preparedDragWebView = webView + return webView.prepareForDragOperation(sender) + } + preparedDragWebView = nil + return hasTerminalTarget + } + + override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + let hasLocalDraggingSource = sender.draggingSource != nil + let types = sender.draggingPasteboard.types + let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( + pasteboardTypes: types, + hasLocalDraggingSource: hasLocalDraggingSource + ) + let webView = shouldCapture + ? (preparedDragWebView ?? activeDragWebView ?? webViewUnderPoint(sender.draggingLocation)) + : nil + let terminal = terminalUnderPoint(sender.draggingLocation) + let hasTerminalTarget = terminal != nil +#if DEBUG + logDragRouteDecision( + phase: "perform", + pasteboardTypes: types, + shouldCapture: shouldCapture, + hasLocalDraggingSource: hasLocalDraggingSource, + hasTerminalTarget: hasTerminalTarget + ) +#endif + guard shouldCapture else { + preparedDragWebView = nil + activeDragWebView = nil + return false + } + if let webView { + preparedDragWebView = webView + return webView.performDragOperation(sender) + } + preparedDragWebView = nil + activeDragWebView = nil + guard let terminal else { return false } + return terminal.performDragOperation(sender) + } + + override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) { + defer { + preparedDragWebView = nil + activeDragWebView = nil + } + guard let sender else { return } + let webView = preparedDragWebView ?? activeDragWebView ?? webViewUnderPoint(sender.draggingLocation) + webView?.concludeDragOperation(sender) + } + + private func updateDragTarget(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation { + let loc = sender.draggingLocation + let hasLocalDraggingSource = sender.draggingSource != nil + let types = sender.draggingPasteboard.types + let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( + pasteboardTypes: types, + hasLocalDraggingSource: hasLocalDraggingSource + ) + let webView = shouldCapture ? webViewUnderPoint(loc) : nil + + if let prev = activeDragWebView, prev !== webView { + prev.draggingExited(sender) + activeDragWebView = nil + } + + if let webView { + if activeDragWebView !== webView { + activeDragWebView = webView + return webView.draggingEntered(sender) + } + return webView.draggingUpdated(sender) + } + + let hasTerminalTarget = terminalUnderPoint(loc) != nil +#if DEBUG + logDragRouteDecision( + phase: phase, + pasteboardTypes: types, + shouldCapture: shouldCapture, + hasLocalDraggingSource: hasLocalDraggingSource, + hasTerminalTarget: hasTerminalTarget + ) +#endif + guard shouldCapture, hasTerminalTarget else { return [] } + return .copy + } + + private func debugPasteboardTypes(_ types: [NSPasteboard.PasteboardType]?) -> String { + guard let types, !types.isEmpty else { return "-" } + return types.map(\.rawValue).joined(separator: ",") + } + + /// Hit-tests the window to find a WKWebView (browser panel) under the cursor. + func webViewUnderPoint(_ windowPoint: NSPoint) -> WKWebView? { + if let window, + let portalWebView = BrowserWindowPortalRegistry.webViewAtWindowPoint(windowPoint, in: window) { + return portalWebView + } + + guard let window, let contentView = window.contentView else { return nil } + isHidden = true + defer { isHidden = false } + let point = contentView.convert(windowPoint, from: nil) + let hitView = contentView.hitTest(point) + + var current: NSView? = hitView + while let view = current { + if let webView = view as? WKWebView { return webView } + current = view.superview + } + return nil + } + + private func debugTopHitViewForCurrentEvent() -> String { + guard let window, + let currentEvent = NSApp.currentEvent, + let contentView = window.contentView, + let themeFrame = contentView.superview else { return "-" } + + let pointInTheme = themeFrame.convert(currentEvent.locationInWindow, from: nil) + // Don't toggle isHidden here — it triggers setNeedsDisplay which can + // exceed AppKit's display-pass limit during cursor-update display cycles. + guard let hit = themeFrame.hitTest(pointInTheme) else { return "nil" } + var chain: [String] = [] + var current: NSView? = hit + var depth = 0 + while let view = current, depth < 6 { + chain.append(debugHitViewDescriptor(view)) + current = view.superview + depth += 1 + } + return chain.joined(separator: "->") + } + + private func debugHitViewDescriptor(_ view: NSView) -> String { + let className = String(describing: type(of: view)) + let ptr = String(describing: Unmanaged.passUnretained(view).toOpaque()) + let dragTypes = debugRegisteredDragTypes(view) + return "\(className)@\(ptr){dragTypes=\(dragTypes)}" + } + + private func debugRegisteredDragTypes(_ view: NSView) -> String { + let types = view.registeredDraggedTypes + guard !types.isEmpty else { return "-" } + + let interestingTypes = types.filter { type in + let raw = type.rawValue + return raw == NSPasteboard.PasteboardType.fileURL.rawValue + || raw == DragOverlayRoutingPolicy.bonsplitTabTransferType.rawValue + || raw == DragOverlayRoutingPolicy.sidebarTabReorderType.rawValue + || raw.contains("public.text") + || raw.contains("public.url") + || raw.contains("public.data") + } + let selected = interestingTypes.isEmpty ? Array(types.prefix(3)) : interestingTypes + let rendered = selected.map(\.rawValue).joined(separator: ",") + if selected.count < types.count { + return "\(rendered),+\(types.count - selected.count)" + } + return rendered + } + + private func hasRelevantDragTypes(_ types: [NSPasteboard.PasteboardType]?) -> Bool { + guard let types else { return false } + return types.contains(.fileURL) + || types.contains(DragOverlayRoutingPolicy.bonsplitTabTransferType) + || types.contains(DragOverlayRoutingPolicy.sidebarTabReorderType) + } + + private func debugEventName(_ eventType: NSEvent.EventType?) -> String { + guard let eventType else { return "none" } + switch eventType { + case .cursorUpdate: return "cursorUpdate" + case .appKitDefined: return "appKitDefined" + case .systemDefined: return "systemDefined" + case .applicationDefined: return "applicationDefined" + case .periodic: return "periodic" + case .mouseMoved: return "mouseMoved" + case .mouseEntered: return "mouseEntered" + case .mouseExited: return "mouseExited" + case .flagsChanged: return "flagsChanged" + case .leftMouseDown: return "leftMouseDown" + case .leftMouseUp: return "leftMouseUp" + case .leftMouseDragged: return "leftMouseDragged" + case .rightMouseDown: return "rightMouseDown" + case .rightMouseUp: return "rightMouseUp" + case .rightMouseDragged: return "rightMouseDragged" + case .otherMouseDown: return "otherMouseDown" + case .otherMouseUp: return "otherMouseUp" + case .otherMouseDragged: return "otherMouseDragged" + case .scrollWheel: return "scrollWheel" + default: return "other(\(eventType.rawValue))" + } + } + +#if DEBUG + private func logHitTestDecision( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType?, + shouldCapture: Bool + ) { + let isDragEvent = eventType == .leftMouseDragged + || eventType == .rightMouseDragged + || eventType == .otherMouseDragged + guard shouldCapture || isDragEvent || hasRelevantDragTypes(pasteboardTypes) else { return } + + let signature = "\(shouldCapture ? 1 : 0)|\(debugEventName(eventType))|\(debugPasteboardTypes(pasteboardTypes))" + guard lastHitTestLogSignature != signature else { return } + lastHitTestLogSignature = signature + dlog( + "overlay.fileDrop.hitTest capture=\(shouldCapture ? 1 : 0) " + + "event=\(debugEventName(eventType)) " + + "topHit=\(debugTopHitViewForCurrentEvent()) " + + "types=\(debugPasteboardTypes(pasteboardTypes))" + ) + } + + private func logDragRouteDecision( + phase: String, + pasteboardTypes: [NSPasteboard.PasteboardType]?, + shouldCapture: Bool, + hasLocalDraggingSource: Bool, + hasTerminalTarget: Bool + ) { + guard shouldCapture || hasRelevantDragTypes(pasteboardTypes) else { return } + let signature = [ + shouldCapture ? "1" : "0", + hasLocalDraggingSource ? "1" : "0", + hasTerminalTarget ? "1" : "0", + debugPasteboardTypes(pasteboardTypes) + ].joined(separator: "|") + guard lastDragRouteLogSignatureByPhase[phase] != signature else { return } + lastDragRouteLogSignatureByPhase[phase] = signature + dlog( + "overlay.fileDrop.\(phase) capture=\(shouldCapture ? 1 : 0) " + + "localSource=\(hasLocalDraggingSource ? 1 : 0) " + + "hasTerminal=\(hasTerminalTarget ? 1 : 0) " + + "types=\(debugPasteboardTypes(pasteboardTypes))" + ) + } +#endif + /// Hit-tests the window to find the GhosttyNSView under the cursor. + func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? { + if let window, + let portalTerminal = TerminalWindowPortalRegistry.terminalViewAtWindowPoint(windowPoint, in: window) { + return portalTerminal + } + + guard let window, let contentView = window.contentView else { return nil } + isHidden = true + defer { isHidden = false } + let point = contentView.convert(windowPoint, from: nil) + let hitView = contentView.hitTest(point) + + var current: NSView? = hitView + while let view = current { + if let terminal = view as? GhosttyNSView { return terminal } + current = view.superview + } + return nil + } +} diff --git a/Sources/GhosttyTerminalView+IME.swift b/Sources/GhosttyTerminalView+IME.swift new file mode 100644 index 00000000000..cd4cc99448d --- /dev/null +++ b/Sources/GhosttyTerminalView+IME.swift @@ -0,0 +1,475 @@ +import Foundation +import SwiftUI +import AppKit +import Metal +import QuartzCore +import Combine +import CoreText +import Darwin +import Carbon.HIToolbox +import Bonsplit +import IOSurface +import UniformTypeIdentifiers + +// MARK: - GhosttyNSView + IME (NSTextInputClient) +// +// Input Method Editor conformance for GhosttyNSView: marked-text/composition +// state, committed-text delivery, and character-coordinate queries used by +// system input methods (Japanese/Chinese/Korean IME, dead-key composition, +// emoji picker, etc.). +// +// Split out of GhosttyTerminalView.swift (Nuclear Review #97). Extracted +// verbatim as a same-type extension, so call-site behavior is unchanged. + +extension GhosttyNSView: NSTextInputClient { + /// Deliver committed text using typed-input semantics so shells and editors + /// keep their normal interactive behaviors (autosuggestions, Return + /// execution, etc.). Programmatic callers can preserve literal ESC bytes so + /// automation payloads remain byte-for-byte stable. + fileprivate func sendTextToSurface(_ chars: String, preserveLiteralEscape: Bool) { + guard let surface = surface else { return } +#if DEBUG + let typingTimingStart = ProgramaTypingTiming.start() +#endif +#if DEBUG + programaWriteChildExitProbe( + [ + "probeInsertTextCharsHex": programaScalarHex(chars), + "probeInsertTextSurfaceId": terminalSurface?.id.uuidString ?? "", + ], + increments: ["probeInsertTextCount": 1] + ) +#endif + + var bufferedText = "" + var previousWasCR = false + + func flushBufferedText() { + guard !bufferedText.isEmpty else { return } + var keyEvent = ghostty_input_key_s() + keyEvent.action = GHOSTTY_ACTION_PRESS + keyEvent.keycode = 0 + keyEvent.mods = GHOSTTY_MODS_NONE + keyEvent.consumed_mods = GHOSTTY_MODS_NONE + keyEvent.unshifted_codepoint = 0 + keyEvent.composing = false + bufferedText.withCString { ptr in + keyEvent.text = ptr + _ = sendGhosttyKey(surface, keyEvent) + } + bufferedText.removeAll(keepingCapacity: true) + } + + func sendControlKey(_ keycode: UInt32) { + var keyEvent = ghostty_input_key_s() + keyEvent.action = GHOSTTY_ACTION_PRESS + keyEvent.keycode = keycode + keyEvent.mods = GHOSTTY_MODS_NONE + keyEvent.consumed_mods = GHOSTTY_MODS_NONE + keyEvent.unshifted_codepoint = 0 + keyEvent.composing = false + keyEvent.text = nil + _ = sendGhosttyKey(surface, keyEvent) + } + + for scalar in chars.unicodeScalars { + switch scalar.value { + case 0x0A: + if !previousWasCR { + flushBufferedText() + sendControlKey(0x24) // kVK_Return + } + previousWasCR = false + case 0x0D: + flushBufferedText() + sendControlKey(0x24) // kVK_Return + previousWasCR = true + case 0x09: + flushBufferedText() + sendControlKey(0x30) // kVK_Tab + previousWasCR = false + case 0x1B: + if preserveLiteralEscape { + bufferedText.unicodeScalars.append(scalar) + } else { + flushBufferedText() + sendControlKey(0x35) // kVK_Escape + } + previousWasCR = false + default: + bufferedText.unicodeScalars.append(scalar) + previousWasCR = false + } + } + flushBufferedText() +#if DEBUG + ProgramaTypingTiming.logDuration( + path: "terminal.sendTextToSurface", + startedAt: typingTimingStart, + extra: "textBytes=\(chars.utf8.count)" + ) +#endif + } + + /// External accessibility/dictation tools should commit plain text, but + /// some inject a leading escape sequence first. Strip those bytes on the + /// committed-text path so they can't leak into the PTY as literals. + static func sanitizeExternalCommittedText(_ text: String) -> String { + let bytes = Array(text.utf8) + guard !bytes.isEmpty else { return text } + + var index = 0 + while index < bytes.count { + let byte = bytes[index] + if byte == 0x1B { + index = consumeLeadingEscapeSequence(in: bytes, from: index) + continue + } + + if byte == 0xC2 { + let next = index + 1 + if next < bytes.count, bytes[next] == 0x9B { + // U+009B (C1 CSI) is encoded as the UTF-8 byte pair C2 9B. + index = consumeLeadingCSISequence(in: bytes, from: next + 1) + continue + } + } + + break + } + + if index == 0 { + return text + } + + guard index < bytes.count else { return "" } + return String(decoding: bytes[index...], as: UTF8.self) + } + + private static func consumeLeadingEscapeSequence( + in bytes: [UInt8], + from start: Int + ) -> Int { + let next = start + 1 + guard next < bytes.count else { return bytes.count } + + switch bytes[next] { + case 0x5B: + // CSI: ESC [ ... final + return consumeLeadingCSISequence(in: bytes, from: next + 1) + case 0x4F: + // SS3: ESC O final + return min(bytes.count, next + 2) + case 0x50, 0x5D, 0x5E, 0x5F: + // DCS/OSC/PM/APC: consume until BEL/ST or EOF. + return consumeLeadingEscapedStringSequence(in: bytes, from: next + 1) + default: + // Single-character escape. + return min(bytes.count, next + 1) + } + } + + private static func consumeLeadingCSISequence( + in bytes: [UInt8], + from start: Int + ) -> Int { + var index = start + while index < bytes.count { + let byte = bytes[index] + if (0x20...0x3F).contains(byte) { + index += 1 + continue + } + + if (0x40...0x7E).contains(byte) { + return index + 1 + } + + break + } + + return index + } + + private static func consumeLeadingEscapedStringSequence( + in bytes: [UInt8], + from start: Int + ) -> Int { + var index = start + while index < bytes.count { + let byte = bytes[index] + if byte == 0x07 { + return index + 1 + } + + if byte == 0x1B { + let next = index + 1 + if next < bytes.count, bytes[next] == 0x5C { + return next + 1 + } + return index + } + + if byte < 0x20 || byte == 0x7F { + return index + 1 + } + + index += 1 + } + + return bytes.count + } + + func hasMarkedText() -> Bool { + return markedText.length > 0 + } + + func markedRange() -> NSRange { + guard markedText.length > 0 else { return NSRange(location: NSNotFound, length: 0) } + return NSRange(location: 0, length: markedText.length) + } + + func selectedRange() -> NSRange { + readSelectionSnapshot()?.range ?? NSRange(location: 0, length: 0) + } + + func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { +#if DEBUG + let typingTimingStart = ProgramaTypingTiming.start() + defer { + ProgramaTypingTiming.logDuration( + path: "terminal.setMarkedText", + startedAt: typingTimingStart, + extra: "markedLength=\(markedText.length)" + ) + } +#endif + switch string { + case let v as NSAttributedString: + markedText = NSMutableAttributedString(attributedString: v) + case let v as String: + markedText = NSMutableAttributedString(string: v) + default: + break + } + + // If we're not in a keyDown event, sync preedit immediately. + // This can happen due to external events like changing keyboard layouts + // while composing. + if keyTextAccumulator == nil { + syncPreedit() + invalidateTextInputCoordinates(selectionChanged: true) + } + } + + func unmarkText() { +#if DEBUG + let hadMarkedText = markedText.length > 0 + let typingTimingStart = ProgramaTypingTiming.start() + defer { + ProgramaTypingTiming.logDuration( + path: "terminal.unmarkText", + startedAt: typingTimingStart, + extra: "hadMarkedText=\(hadMarkedText ? 1 : 0)" + ) + } +#endif + if markedText.length > 0 { + markedText.mutableString.setString("") + syncPreedit() + invalidateTextInputCoordinates(selectionChanged: true) + } + } + + /// Sync the preedit state based on the markedText value to libghostty. + /// This tells Ghostty about IME composition text so it can render the + /// preedit overlay (e.g. for Korean, Japanese, Chinese input). + func syncPreedit(clearIfNeeded: Bool = true) { +#if DEBUG + let typingTimingStart = ProgramaTypingTiming.start() + defer { + ProgramaTypingTiming.logDuration( + path: "terminal.syncPreedit", + startedAt: typingTimingStart, + extra: "markedLength=\(markedText.length) clearIfNeeded=\(clearIfNeeded ? 1 : 0)" + ) + } +#endif + guard let surface = surface else { return } + + if markedText.length > 0 { + let str = markedText.string + let len = str.utf8CString.count + if len > 0 { + str.withCString { ptr in + // Subtract 1 for the null terminator + ghostty_surface_preedit(surface, ptr, UInt(len - 1)) + } + } + } else if clearIfNeeded { + // If we had marked text before but don't now, we're no longer + // in a preedit state so we can clear it. + ghostty_surface_preedit(surface, nil, 0) + } + } + + func validAttributesForMarkedText() -> [NSAttributedString.Key] { + return [] + } + + func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { + guard range.length > 0, + let snapshot = readSelectionSnapshot() else { return nil } + actualRange?.pointee = snapshot.range + return NSAttributedString(string: snapshot.string) + } + + func characterIndex(for point: NSPoint) -> Int { + return selectedRange().location + } + + func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { + guard let window = self.window else { + return NSRect(x: frame.origin.x, y: frame.origin.y, width: 0, height: 0) + } + + // Use Ghostty's IME point API for accurate cursor position if available. + var x: Double = 0 + var y: Double = 0 + var w: Double = cellSize.width + var h: Double = cellSize.height +#if DEBUG + if range.length > 0, + range != selectedRange(), + let snapshot = readSelectionSnapshot() { + x = snapshot.topLeft.x - 2 + y = snapshot.topLeft.y + 2 + } else if let override = imePointOverrideForTesting { + x = override.x + y = override.y + w = override.width + h = override.height + } else if let surface = surface { + ghostty_surface_ime_point(surface, &x, &y, &w, &h) + } +#else + if range.length > 0, + range != selectedRange(), + let snapshot = readSelectionSnapshot() { + x = snapshot.topLeft.x - 2 + y = snapshot.topLeft.y + 2 + } else if let surface = surface { + ghostty_surface_ime_point(surface, &x, &y, &w, &h) + } +#endif + + if range.length == 0, w > 0 { + // Dictation expects a caret rect for insertion points rather than a box. + w = 0 + } + + // Ghostty coordinates are top-left origin; AppKit expects bottom-left. + let viewRect = NSRect( + x: x, + y: frame.size.height - y, + width: w, + height: max(h, cellSize.height) + ) + let winRect = convert(viewRect, to: nil) + return window.convertToScreen(winRect) + } + + func attributedString() -> NSAttributedString { + if markedText.length > 0 { + return NSAttributedString(attributedString: markedText) + } + if let snapshot = readSelectionSnapshot(), !snapshot.string.isEmpty { + return NSAttributedString(string: snapshot.string) + } + return NSAttributedString(string: "") + } + + func windowLevel() -> Int { + Int(window?.level.rawValue ?? NSWindow.Level.normal.rawValue) + } + + @available(macOS 14.0, *) + var unionRectInVisibleSelectedRange: NSRect { + firstRect(forCharacterRange: selectedRange(), actualRange: nil) + } + + @available(macOS 14.0, *) + var documentVisibleRect: NSRect { + visibleDocumentRectInScreenCoordinates() + } + + func insertText(_ string: Any, replacementRange: NSRange) { +#if DEBUG + let typingTimingStart = ProgramaTypingTiming.start() + defer { + ProgramaTypingTiming.logDuration( + path: "terminal.insertText", + startedAt: typingTimingStart, + event: NSApp.currentEvent, + extra: "replacementLocation=\(replacementRange.location) replacementLength=\(replacementRange.length)" + ) + } +#endif + // Get the string value + var chars = "" + switch string { + case let v as NSAttributedString: + chars = v.string + case let v as String: + chars = v + default: + return + } + + // Clear marked text since we're inserting + unmarkText() + + // Some IME/input-method paths call insertText with an empty payload to + // flush state. There is no terminal text to send in that case. + guard !chars.isEmpty else { return } + +#if DEBUG + if NSApp.currentEvent == nil { + dlog("ime.insertText.noEvent len=\(chars.count)") + } +#endif + + // If we have an accumulator, we're in a keyDown event - accumulate the text + if keyTextAccumulator != nil { + keyTextAccumulator?.append(chars) + return + } + + let isExternalCommittedText = externalCommittedTextDepth > 0 + let sanitizedChars = if isExternalCommittedText { + // Only sanitize explicit external committed-text paths used by + // AX/dictation integrations. Programmatic NSTextInputClient callers + // may intentionally start with ESC/CSI bytes. + Self.sanitizeExternalCommittedText(chars) + } else { + chars + } + +#if DEBUG + if sanitizedChars != chars { + dlog( + "ime.insertText.sanitized originalBytes=\(chars.utf8.count) " + + "sanitizedBytes=\(sanitizedChars.utf8.count)" + ) + } +#endif + + guard !sanitizedChars.isEmpty else { return } + + // Otherwise send directly to the terminal + sendTextToSurface( + sanitizedChars, + preserveLiteralEscape: !isExternalCommittedText + ) + } +} diff --git a/Sources/GhosttyTerminalView+RenderStats.swift b/Sources/GhosttyTerminalView+RenderStats.swift new file mode 100644 index 00000000000..e594afe178d --- /dev/null +++ b/Sources/GhosttyTerminalView+RenderStats.swift @@ -0,0 +1,291 @@ +import Foundation +import SwiftUI +import AppKit +import Metal +import QuartzCore +import Combine +import CoreText +import Darwin +import Carbon.HIToolbox +import Bonsplit +import IOSurface +import UniformTypeIdentifiers + +// MARK: - GhosttySurfaceScrollView + RenderStats +// +// Debug-only render/frame introspection for GhosttySurfaceScrollView: draw/present +// counters exposed to the debug socket, and IOSurface-backed frame sampling used to +// detect blank-frame regressions without Screen Recording permissions. +// +// Split out of GhosttyTerminalView.swift (Nuclear Review #97). Extracted verbatim +// as a same-type extension (entirely #if DEBUG), so behavior is unchanged. + +extension GhosttySurfaceScrollView { +#if DEBUG + struct DebugRenderStats { + let drawCount: Int + let lastDrawTime: CFTimeInterval + let metalDrawableCount: Int + let metalLastDrawableTime: CFTimeInterval + let presentCount: Int + let lastPresentTime: CFTimeInterval + let layerClass: String + let layerContentsKey: String + let inWindow: Bool + let windowIsKey: Bool + let windowOcclusionVisible: Bool + let appIsActive: Bool + let isActive: Bool + let desiredFocus: Bool + let isFirstResponder: Bool + } + + func debugRenderStats() -> DebugRenderStats { + let layerClass = surfaceView.layer.map { String(describing: type(of: $0)) } ?? "nil" + let (metalCount, metalLast) = (surfaceView.layer as? GhosttyMetalLayer)?.debugStats() ?? (0, 0) + let (drawCount, lastDraw): (Int, CFTimeInterval) = surfaceView.terminalSurface.map { terminalSurface in + Self.drawStats(for: terminalSurface.id) + } ?? (0, 0) + let (presentCount, lastPresent, contentsKey): (Int, CFTimeInterval, String) = surfaceView.terminalSurface.map { terminalSurface in + let stats = Self.updatePresentStats(surfaceId: terminalSurface.id, layer: surfaceView.layer) + return (stats.count, stats.last, stats.key) + } ?? (0, 0, Self.contentsKey(for: surfaceView.layer)) + let inWindow = (window != nil) + let windowIsKey = window?.isKeyWindow ?? false + let windowOcclusionVisible = (window?.occlusionState.contains(.visible) ?? false) || (window?.isKeyWindow ?? false) + let appIsActive = NSApp.isActive + let fr = window?.firstResponder as? NSView + let isFirstResponder = fr == surfaceView || (fr?.isDescendant(of: surfaceView) ?? false) + return DebugRenderStats( + drawCount: drawCount, + lastDrawTime: lastDraw, + metalDrawableCount: metalCount, + metalLastDrawableTime: metalLast, + presentCount: presentCount, + lastPresentTime: lastPresent, + layerClass: layerClass, + layerContentsKey: contentsKey, + inWindow: inWindow, + windowIsKey: windowIsKey, + windowOcclusionVisible: windowOcclusionVisible, + appIsActive: appIsActive, + isActive: isActive, + desiredFocus: surfaceView.desiredFocus, + isFirstResponder: isFirstResponder + ) + } +#endif + +#if DEBUG + struct DebugFrameSample { + let sampleCount: Int + let uniqueQuantized: Int + let lumaStdDev: Double + let modeFraction: Double + let fingerprint: UInt64 + let iosurfaceWidthPx: Int + let iosurfaceHeightPx: Int + let expectedWidthPx: Int + let expectedHeightPx: Int + let layerClass: String + let layerContentsGravity: String + let layerContentsKey: String + + var isProbablyBlank: Bool { + (lumaStdDev < 3.5 && modeFraction > 0.985) || + (uniqueQuantized <= 6 && modeFraction > 0.95) + } + } + + /// Create a CGImage from the terminal's IOSurface-backed layer contents. + /// + /// This avoids Screen Recording permissions (unlike CGWindowListCreateImage) and is therefore + /// suitable for debug socket tests running in headless/VM contexts. + func debugCopyIOSurfaceCGImage() -> CGImage? { + guard let modelLayer = surfaceView.layer else { return nil } + let layer = modelLayer.presentation() ?? modelLayer + guard let contents = layer.contents else { return nil } + + let cf = contents as CFTypeRef + guard CFGetTypeID(cf) == IOSurfaceGetTypeID() else { return nil } + let surfaceRef = (contents as! IOSurfaceRef) + + let width = Int(IOSurfaceGetWidth(surfaceRef)) + let height = Int(IOSurfaceGetHeight(surfaceRef)) + let bytesPerRow = Int(IOSurfaceGetBytesPerRow(surfaceRef)) + guard width > 0, height > 0, bytesPerRow > 0 else { return nil } + + IOSurfaceLock(surfaceRef, [], nil) + defer { IOSurfaceUnlock(surfaceRef, [], nil) } + + let base = IOSurfaceGetBaseAddress(surfaceRef) + let size = bytesPerRow * height + let data = Data(bytes: base, count: size) + + guard let provider = CGDataProvider(data: data as CFData) else { return nil } + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGBitmapInfo.byteOrder32Little.union( + CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue) + ) + + return CGImage( + width: width, + height: height, + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: bitmapInfo, + provider: provider, + decode: nil, + shouldInterpolate: false, + intent: .defaultIntent + ) + } + + /// Sample the IOSurface backing the terminal layer (if any) to detect a transient blank frame + /// without using screenshots/screen recording permissions. + func debugSampleIOSurface(normalizedCrop: CGRect) -> DebugFrameSample? { + guard let modelLayer = surfaceView.layer else { return nil } + // Prefer the presentation layer to better match what the user sees on screen. + let layer = modelLayer.presentation() ?? modelLayer + let layerClass = String(describing: type(of: layer)) + let layerContentsGravity = layer.contentsGravity.rawValue + let contentsKey = Self.contentsKey(for: layer) + let presentationScale = max(1.0, layer.contentsScale) + let expectedWidthPx = Int((layer.bounds.width * presentationScale).rounded(.toNearestOrAwayFromZero)) + let expectedHeightPx = Int((layer.bounds.height * presentationScale).rounded(.toNearestOrAwayFromZero)) + + // Ghostty uses a CoreAnimation layer whose `contents` is an IOSurface-backed object. + // The concrete layer class is often `IOSurfaceLayer` (private), so avoid referencing it directly. + guard let anySurface = layer.contents else { + // Treat "no contents" as a blank frame: this is the visual regression we're guarding. + return DebugFrameSample( + sampleCount: 0, + uniqueQuantized: 0, + lumaStdDev: 0, + modeFraction: 1, + fingerprint: 0, + iosurfaceWidthPx: 0, + iosurfaceHeightPx: 0, + expectedWidthPx: expectedWidthPx, + expectedHeightPx: expectedHeightPx, + layerClass: layerClass, + layerContentsGravity: layerContentsGravity, + layerContentsKey: contentsKey + ) + } + + // IOSurfaceLayer.contents is usually an IOSurface, but during mitigation we may + // temporarily replace contents with a CGImage snapshot to avoid blank flashes. + // Treat non-IOSurface contents as "non-blank" and avoid unsafe casts. + let cf = anySurface as CFTypeRef + guard CFGetTypeID(cf) == IOSurfaceGetTypeID() else { + var fnv: UInt64 = 1469598103934665603 + for b in contentsKey.utf8 { + fnv ^= UInt64(b) + fnv &*= 1099511628211 + } + return DebugFrameSample( + sampleCount: 1, + uniqueQuantized: 1, + lumaStdDev: 999, + modeFraction: 0, + fingerprint: fnv, + iosurfaceWidthPx: 0, + iosurfaceHeightPx: 0, + expectedWidthPx: expectedWidthPx, + expectedHeightPx: expectedHeightPx, + layerClass: layerClass, + layerContentsGravity: layerContentsGravity, + layerContentsKey: contentsKey + ) + } + + let surfaceRef = (anySurface as! IOSurfaceRef) + + let width = Int(IOSurfaceGetWidth(surfaceRef)) + let height = Int(IOSurfaceGetHeight(surfaceRef)) + if width <= 0 || height <= 0 { return nil } + + let cropPx = CGRect( + x: max(0, min(CGFloat(width - 1), normalizedCrop.origin.x * CGFloat(width))), + y: max(0, min(CGFloat(height - 1), normalizedCrop.origin.y * CGFloat(height))), + width: max(1, min(CGFloat(width), normalizedCrop.width * CGFloat(width))), + height: max(1, min(CGFloat(height), normalizedCrop.height * CGFloat(height))) + ).integral + + let x0 = Int(cropPx.minX) + let y0 = Int(cropPx.minY) + let x1 = Int(min(CGFloat(width), cropPx.maxX)) + let y1 = Int(min(CGFloat(height), cropPx.maxY)) + if x1 <= x0 || y1 <= y0 { return nil } + + IOSurfaceLock(surfaceRef, [], nil) + defer { IOSurfaceUnlock(surfaceRef, [], nil) } + + let base = IOSurfaceGetBaseAddress(surfaceRef) + let bytesPerRow = IOSurfaceGetBytesPerRow(surfaceRef) + if bytesPerRow <= 0 { return nil } + + // Assume 4 bytes/pixel BGRA (common for IOSurfaceLayer contents). + let bytesPerPixel = 4 + let step = 6 + + var hist = [UInt16: Int]() + hist.reserveCapacity(256) + + var lumas = [Double]() + lumas.reserveCapacity(((x1 - x0) / step) * ((y1 - y0) / step)) + + var count = 0 + var fnv: UInt64 = 1469598103934665603 + + for y in stride(from: y0, to: y1, by: step) { + let row = base.advanced(by: y * bytesPerRow) + for x in stride(from: x0, to: x1, by: step) { + let p = row.advanced(by: x * bytesPerPixel) + let b = Double(p.load(fromByteOffset: 0, as: UInt8.self)) + let g = Double(p.load(fromByteOffset: 1, as: UInt8.self)) + let r = Double(p.load(fromByteOffset: 2, as: UInt8.self)) + let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b + lumas.append(luma) + + let rq = UInt16(UInt8(r) >> 4) + let gq = UInt16(UInt8(g) >> 4) + let bq = UInt16(UInt8(b) >> 4) + let key = (rq << 8) | (gq << 4) | bq + hist[key, default: 0] += 1 + count += 1 + + let lq = UInt8(max(0, min(63, Int(luma / 4.0)))) + fnv ^= UInt64(lq) + fnv &*= 1099511628211 + } + } + + guard count > 0 else { return nil } + let mean = lumas.reduce(0.0, +) / Double(lumas.count) + let variance = lumas.reduce(0.0) { $0 + ($1 - mean) * ($1 - mean) } / Double(lumas.count) + let stddev = sqrt(variance) + + let modeCount = hist.values.max() ?? 0 + let modeFrac = Double(modeCount) / Double(count) + + return DebugFrameSample( + sampleCount: count, + uniqueQuantized: hist.count, + lumaStdDev: stddev, + modeFraction: modeFrac, + fingerprint: fnv, + iosurfaceWidthPx: width, + iosurfaceHeightPx: height, + expectedWidthPx: expectedWidthPx, + expectedHeightPx: expectedHeightPx, + layerClass: layerClass, + layerContentsGravity: layerContentsGravity, + layerContentsKey: contentsKey + ) + } +#endif +} diff --git a/Sources/GhosttyTerminalView+SwiftUIWrapper.swift b/Sources/GhosttyTerminalView+SwiftUIWrapper.swift new file mode 100644 index 00000000000..75d9a955f50 --- /dev/null +++ b/Sources/GhosttyTerminalView+SwiftUIWrapper.swift @@ -0,0 +1,482 @@ +import Foundation +import SwiftUI +import AppKit +import Metal +import QuartzCore +import Combine +import CoreText +import Darwin +import Carbon.HIToolbox +import Bonsplit +import IOSurface +import UniformTypeIdentifiers + +// MARK: - GhosttyTerminalView (SwiftUI Wrapper) +// +// The NSViewRepresentable bridge between SwiftUI panel layout and the +// AppKit-hosted terminal portal (GhosttySurfaceScrollView/GhosttyNSView), +// plus its Coordinator and the private HostContainerView it manages. +// +// Split out of GhosttyTerminalView.swift (Nuclear Review #97). Extracted +// verbatim, so call-site behavior is unchanged. + +// 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 previousDesiredShowsUnreadNotificationRing = coordinator.desiredShowsUnreadNotificationRing + 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 + dlog( + "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 { + dlog( + "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" + dlog( + "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 { + dlog( + "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 + dlog( + "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) + let shouldBindNow = + coordinator.lastBoundHostId != hostId || + hostedView.superview == nil || + portalEntryMissing || + previousDesiredIsVisibleInUI != isVisibleInUI || + previousDesiredShowsUnreadNotificationRing != showsUnreadNotificationRing || + previousDesiredPortalZPriority != portalZPriority + if portalBindingLive && shouldBindNow { +#if DEBUG + if portalEntryMissing { + dlog( + "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 { + dlog( + "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 { + dlog( + "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 + dlog( + "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 { + dlog( + "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/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index f766dd1b47c..e9766a53109 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1558,12 +1558,6 @@ class GhosttyApp { lastAppearanceColorScheme = GhosttyConfig.currentColorSchemePreference() NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) - // Pre-warm the surface pool so the first new tab opens instantly. - // Deferred to the next main-queue tick so the app is fully initialized. - DispatchQueue.main.async { - SurfacePool.shared.warmIfNeeded() - } - #if os(macOS) if let app { ghostty_app_set_focus(app, NSApp.isActive) @@ -4345,6 +4339,13 @@ final class TerminalSurface: Identifiable, ObservableObject { /// Keep `desiredFocusState` in sync when the hosted view's responder chain /// calls `ghostty_surface_set_focus` directly (bypassing `setFocus`). /// Without this, `createSurface` would replay a stale state on recreation. + /// + /// `desiredFocusState` must have exactly one writer per focus transition: + /// either this method (paired with a direct `ghostty_surface_set_focus` call, + /// or standing in for one that's deferred) or `setFocus`, never both for the + /// same transition. Calling this immediately before `setFocus` for the same + /// transition pre-sets the value `setFocus`'s dedup guard checks against, + /// which makes it silently skip the real `ghostty_surface_set_focus` push. func recordExternalFocusState(_ focused: Bool) { desiredFocusState = focused } @@ -5586,7 +5587,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } // Test-only IME point override so firstRect behavior can be regression tested. - private var imePointOverrideForTesting: (x: Double, y: Double, width: Double, height: Double)? + // private(set): read from the NSTextInputClient conformance in + // GhosttyTerminalView+IME.swift (Nuclear Review #97 split), written only here. + private(set) var imePointOverrideForTesting: (x: Double, y: Double, width: Double, height: Double)? func setIMEPointForTesting(x: Double, y: Double, width: Double, height: Double) { imePointOverrideForTesting = (x, y, width, height) @@ -5828,7 +5831,10 @@ final class GhosttySurfaceScrollView: NSView { private let backgroundView: NSView private let scrollView: GhosttyScrollView private let documentView: NSView - private let surfaceView: GhosttyNSView + // Widened from private to internal (immutable `let`, so this only grants + // read access): read from the debug-only RenderStats extension + // (GhosttyTerminalView+RenderStats.swift, Nuclear Review #97 split). + let surfaceView: GhosttyNSView private let inactiveOverlayView: GhosttyFlashOverlayView private let dropZoneOverlayView: GhosttyFlashOverlayView private let notificationRingOverlayView: GhosttyFlashOverlayView @@ -5863,7 +5869,9 @@ final class GhosttySurfaceScrollView: NSView { private var allowExplicitScrollbarSync = false /// 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(set): read from the debug-only RenderStats extension + // (GhosttyTerminalView+RenderStats.swift, Nuclear Review #97 split), written only here. + private(set) var isActive = true private var lastFocusRefreshAt: CFTimeInterval = 0 private var lastRequestedPortalOcclusionVisible: Bool? private var activeDropZone: DropZone? @@ -5921,7 +5929,9 @@ final class GhosttySurfaceScrollView: NSView { lastDrawTimes[surfaceId] = CACurrentMediaTime() } - private static func contentsKey(for layer: CALayer?) -> String { + // Widened from private to internal: called from the debug-only RenderStats + // extension (GhosttyTerminalView+RenderStats.swift, Nuclear Review #97 split). + static func contentsKey(for layer: CALayer?) -> String { guard let modelLayer = layer else { return "nil" } // Prefer the presentation layer to better reflect what the user sees on screen. let layer = modelLayer.presentation() ?? modelLayer @@ -5946,7 +5956,9 @@ final class GhosttySurfaceScrollView: NSView { return String(describing: contents) } - private static func updatePresentStats(surfaceId: UUID, layer: CALayer?) -> (count: Int, last: CFTimeInterval, key: String) { + // Widened from private to internal: called from the debug-only RenderStats + // extension (GhosttyTerminalView+RenderStats.swift, Nuclear Review #97 split). + static func updatePresentStats(surfaceId: UUID, layer: CALayer?) -> (count: Int, last: CFTimeInterval, key: String) { let key = contentsKey(for: layer) if lastContentsKeys[surfaceId] != key { presentCounts[surfaceId, default: 0] += 1 @@ -7716,9 +7728,19 @@ 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 } + // NOTE: recordExternalFocusState(true) is only called in the branches below that + // do NOT reach reassertTerminalSurfaceFocus(). Calling it unconditionally here used + // to pre-set TerminalSurface.desiredFocusState to true before reassertTerminalSurfaceFocus + // -> setFocus(true) ran; setFocus's dedup guard (`focused != desiredFocusState`) then + // saw no change and silently skipped the real ghostty_surface_set_focus push, leaving + // the surface's actual Ghostty-level focus stuck false after a reparent/split. Each + // branch below now has exactly one writer of desiredFocusState for this transition. + guard let window, window.isKeyWindow else { + surfaceView.terminalSurface?.recordExternalFocusState(true) + return + } guard !isHiddenForFocus, hasUsablePortalGeometry else { + surfaceView.terminalSurface?.recordExternalFocusState(true) #if DEBUG dlog( "focus.reparent.resume.defer surface=\(surfaceShort) " + @@ -7741,6 +7763,10 @@ final class GhosttySurfaceScrollView: NSView { #if DEBUG dlog("focus.reparent.resume surface=\(surfaceShort) firstResponder=\(String(describing: window.firstResponder))") #endif + // reassertTerminalSurfaceFocus() -> setFocus(true) is the sole writer of + // desiredFocusState on this path; it both updates the dedup guard and pushes + // ghostty_surface_set_focus in one place, so no separate recordExternalFocusState + // call is made here (see NOTE above). reassertTerminalSurfaceFocus(reason: "clearSuppressReparentFocus") } @@ -8117,274 +8143,6 @@ final class GhosttySurfaceScrollView: NSView { return textField.isDescendant(of: self) && isSearchOverlayOrDescendant(textField) } -#if DEBUG - struct DebugRenderStats { - let drawCount: Int - let lastDrawTime: CFTimeInterval - let metalDrawableCount: Int - let metalLastDrawableTime: CFTimeInterval - let presentCount: Int - let lastPresentTime: CFTimeInterval - let layerClass: String - let layerContentsKey: String - let inWindow: Bool - let windowIsKey: Bool - let windowOcclusionVisible: Bool - let appIsActive: Bool - let isActive: Bool - let desiredFocus: Bool - let isFirstResponder: Bool - } - - func debugRenderStats() -> DebugRenderStats { - let layerClass = surfaceView.layer.map { String(describing: type(of: $0)) } ?? "nil" - let (metalCount, metalLast) = (surfaceView.layer as? GhosttyMetalLayer)?.debugStats() ?? (0, 0) - let (drawCount, lastDraw): (Int, CFTimeInterval) = surfaceView.terminalSurface.map { terminalSurface in - Self.drawStats(for: terminalSurface.id) - } ?? (0, 0) - let (presentCount, lastPresent, contentsKey): (Int, CFTimeInterval, String) = surfaceView.terminalSurface.map { terminalSurface in - let stats = Self.updatePresentStats(surfaceId: terminalSurface.id, layer: surfaceView.layer) - return (stats.count, stats.last, stats.key) - } ?? (0, 0, Self.contentsKey(for: surfaceView.layer)) - let inWindow = (window != nil) - let windowIsKey = window?.isKeyWindow ?? false - let windowOcclusionVisible = (window?.occlusionState.contains(.visible) ?? false) || (window?.isKeyWindow ?? false) - let appIsActive = NSApp.isActive - let fr = window?.firstResponder as? NSView - let isFirstResponder = fr == surfaceView || (fr?.isDescendant(of: surfaceView) ?? false) - return DebugRenderStats( - drawCount: drawCount, - lastDrawTime: lastDraw, - metalDrawableCount: metalCount, - metalLastDrawableTime: metalLast, - presentCount: presentCount, - lastPresentTime: lastPresent, - layerClass: layerClass, - layerContentsKey: contentsKey, - inWindow: inWindow, - windowIsKey: windowIsKey, - windowOcclusionVisible: windowOcclusionVisible, - appIsActive: appIsActive, - isActive: isActive, - desiredFocus: surfaceView.desiredFocus, - isFirstResponder: isFirstResponder - ) - } -#endif - -#if DEBUG - struct DebugFrameSample { - let sampleCount: Int - let uniqueQuantized: Int - let lumaStdDev: Double - let modeFraction: Double - let fingerprint: UInt64 - let iosurfaceWidthPx: Int - let iosurfaceHeightPx: Int - let expectedWidthPx: Int - let expectedHeightPx: Int - let layerClass: String - let layerContentsGravity: String - let layerContentsKey: String - - var isProbablyBlank: Bool { - (lumaStdDev < 3.5 && modeFraction > 0.985) || - (uniqueQuantized <= 6 && modeFraction > 0.95) - } - } - - /// Create a CGImage from the terminal's IOSurface-backed layer contents. - /// - /// This avoids Screen Recording permissions (unlike CGWindowListCreateImage) and is therefore - /// suitable for debug socket tests running in headless/VM contexts. - func debugCopyIOSurfaceCGImage() -> CGImage? { - guard let modelLayer = surfaceView.layer else { return nil } - let layer = modelLayer.presentation() ?? modelLayer - guard let contents = layer.contents else { return nil } - - let cf = contents as CFTypeRef - guard CFGetTypeID(cf) == IOSurfaceGetTypeID() else { return nil } - let surfaceRef = (contents as! IOSurfaceRef) - - let width = Int(IOSurfaceGetWidth(surfaceRef)) - let height = Int(IOSurfaceGetHeight(surfaceRef)) - let bytesPerRow = Int(IOSurfaceGetBytesPerRow(surfaceRef)) - guard width > 0, height > 0, bytesPerRow > 0 else { return nil } - - IOSurfaceLock(surfaceRef, [], nil) - defer { IOSurfaceUnlock(surfaceRef, [], nil) } - - let base = IOSurfaceGetBaseAddress(surfaceRef) - let size = bytesPerRow * height - let data = Data(bytes: base, count: size) - - guard let provider = CGDataProvider(data: data as CFData) else { return nil } - let colorSpace = CGColorSpaceCreateDeviceRGB() - let bitmapInfo = CGBitmapInfo.byteOrder32Little.union( - CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue) - ) - - return CGImage( - width: width, - height: height, - bitsPerComponent: 8, - bitsPerPixel: 32, - bytesPerRow: bytesPerRow, - space: colorSpace, - bitmapInfo: bitmapInfo, - provider: provider, - decode: nil, - shouldInterpolate: false, - intent: .defaultIntent - ) - } - - /// Sample the IOSurface backing the terminal layer (if any) to detect a transient blank frame - /// without using screenshots/screen recording permissions. - func debugSampleIOSurface(normalizedCrop: CGRect) -> DebugFrameSample? { - guard let modelLayer = surfaceView.layer else { return nil } - // Prefer the presentation layer to better match what the user sees on screen. - let layer = modelLayer.presentation() ?? modelLayer - let layerClass = String(describing: type(of: layer)) - let layerContentsGravity = layer.contentsGravity.rawValue - let contentsKey = Self.contentsKey(for: layer) - let presentationScale = max(1.0, layer.contentsScale) - let expectedWidthPx = Int((layer.bounds.width * presentationScale).rounded(.toNearestOrAwayFromZero)) - let expectedHeightPx = Int((layer.bounds.height * presentationScale).rounded(.toNearestOrAwayFromZero)) - - // Ghostty uses a CoreAnimation layer whose `contents` is an IOSurface-backed object. - // The concrete layer class is often `IOSurfaceLayer` (private), so avoid referencing it directly. - guard let anySurface = layer.contents else { - // Treat "no contents" as a blank frame: this is the visual regression we're guarding. - return DebugFrameSample( - sampleCount: 0, - uniqueQuantized: 0, - lumaStdDev: 0, - modeFraction: 1, - fingerprint: 0, - iosurfaceWidthPx: 0, - iosurfaceHeightPx: 0, - expectedWidthPx: expectedWidthPx, - expectedHeightPx: expectedHeightPx, - layerClass: layerClass, - layerContentsGravity: layerContentsGravity, - layerContentsKey: contentsKey - ) - } - - // IOSurfaceLayer.contents is usually an IOSurface, but during mitigation we may - // temporarily replace contents with a CGImage snapshot to avoid blank flashes. - // Treat non-IOSurface contents as "non-blank" and avoid unsafe casts. - let cf = anySurface as CFTypeRef - guard CFGetTypeID(cf) == IOSurfaceGetTypeID() else { - var fnv: UInt64 = 1469598103934665603 - for b in contentsKey.utf8 { - fnv ^= UInt64(b) - fnv &*= 1099511628211 - } - return DebugFrameSample( - sampleCount: 1, - uniqueQuantized: 1, - lumaStdDev: 999, - modeFraction: 0, - fingerprint: fnv, - iosurfaceWidthPx: 0, - iosurfaceHeightPx: 0, - expectedWidthPx: expectedWidthPx, - expectedHeightPx: expectedHeightPx, - layerClass: layerClass, - layerContentsGravity: layerContentsGravity, - layerContentsKey: contentsKey - ) - } - - let surfaceRef = (anySurface as! IOSurfaceRef) - - let width = Int(IOSurfaceGetWidth(surfaceRef)) - let height = Int(IOSurfaceGetHeight(surfaceRef)) - if width <= 0 || height <= 0 { return nil } - - let cropPx = CGRect( - x: max(0, min(CGFloat(width - 1), normalizedCrop.origin.x * CGFloat(width))), - y: max(0, min(CGFloat(height - 1), normalizedCrop.origin.y * CGFloat(height))), - width: max(1, min(CGFloat(width), normalizedCrop.width * CGFloat(width))), - height: max(1, min(CGFloat(height), normalizedCrop.height * CGFloat(height))) - ).integral - - let x0 = Int(cropPx.minX) - let y0 = Int(cropPx.minY) - let x1 = Int(min(CGFloat(width), cropPx.maxX)) - let y1 = Int(min(CGFloat(height), cropPx.maxY)) - if x1 <= x0 || y1 <= y0 { return nil } - - IOSurfaceLock(surfaceRef, [], nil) - defer { IOSurfaceUnlock(surfaceRef, [], nil) } - - let base = IOSurfaceGetBaseAddress(surfaceRef) - let bytesPerRow = IOSurfaceGetBytesPerRow(surfaceRef) - if bytesPerRow <= 0 { return nil } - - // Assume 4 bytes/pixel BGRA (common for IOSurfaceLayer contents). - let bytesPerPixel = 4 - let step = 6 - - var hist = [UInt16: Int]() - hist.reserveCapacity(256) - - var lumas = [Double]() - lumas.reserveCapacity(((x1 - x0) / step) * ((y1 - y0) / step)) - - var count = 0 - var fnv: UInt64 = 1469598103934665603 - - for y in stride(from: y0, to: y1, by: step) { - let row = base.advanced(by: y * bytesPerRow) - for x in stride(from: x0, to: x1, by: step) { - let p = row.advanced(by: x * bytesPerPixel) - let b = Double(p.load(fromByteOffset: 0, as: UInt8.self)) - let g = Double(p.load(fromByteOffset: 1, as: UInt8.self)) - let r = Double(p.load(fromByteOffset: 2, as: UInt8.self)) - let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b - lumas.append(luma) - - let rq = UInt16(UInt8(r) >> 4) - let gq = UInt16(UInt8(g) >> 4) - let bq = UInt16(UInt8(b) >> 4) - let key = (rq << 8) | (gq << 4) | bq - hist[key, default: 0] += 1 - count += 1 - - let lq = UInt8(max(0, min(63, Int(luma / 4.0)))) - fnv ^= UInt64(lq) - fnv &*= 1099511628211 - } - } - - guard count > 0 else { return nil } - let mean = lumas.reduce(0.0, +) / Double(lumas.count) - let variance = lumas.reduce(0.0) { $0 + ($1 - mean) * ($1 - mean) } / Double(lumas.count) - let stddev = sqrt(variance) - - let modeCount = hist.values.max() ?? 0 - let modeFrac = Double(modeCount) / Double(count) - - return DebugFrameSample( - sampleCount: count, - uniqueQuantized: hist.count, - lumaStdDev: stddev, - modeFraction: modeFrac, - fingerprint: fnv, - iosurfaceWidthPx: width, - iosurfaceHeightPx: height, - expectedWidthPx: expectedWidthPx, - expectedHeightPx: expectedHeightPx, - layerClass: layerClass, - layerContentsGravity: layerContentsGravity, - layerContentsKey: contentsKey - ) - } -#endif - func cancelFocusRequest() { // Intentionally no-op (no retry loops). } @@ -8573,918 +8331,3 @@ final class GhosttySurfaceScrollView: NSView { } } -// MARK: - NSTextInputClient - -extension GhosttyNSView: NSTextInputClient { - /// Deliver committed text using typed-input semantics so shells and editors - /// keep their normal interactive behaviors (autosuggestions, Return - /// execution, etc.). Programmatic callers can preserve literal ESC bytes so - /// automation payloads remain byte-for-byte stable. - fileprivate func sendTextToSurface(_ chars: String, preserveLiteralEscape: Bool) { - guard let surface = surface else { return } -#if DEBUG - let typingTimingStart = ProgramaTypingTiming.start() -#endif -#if DEBUG - programaWriteChildExitProbe( - [ - "probeInsertTextCharsHex": programaScalarHex(chars), - "probeInsertTextSurfaceId": terminalSurface?.id.uuidString ?? "", - ], - increments: ["probeInsertTextCount": 1] - ) -#endif - - var bufferedText = "" - var previousWasCR = false - - func flushBufferedText() { - guard !bufferedText.isEmpty else { return } - var keyEvent = ghostty_input_key_s() - keyEvent.action = GHOSTTY_ACTION_PRESS - keyEvent.keycode = 0 - keyEvent.mods = GHOSTTY_MODS_NONE - keyEvent.consumed_mods = GHOSTTY_MODS_NONE - keyEvent.unshifted_codepoint = 0 - keyEvent.composing = false - bufferedText.withCString { ptr in - keyEvent.text = ptr - _ = sendGhosttyKey(surface, keyEvent) - } - bufferedText.removeAll(keepingCapacity: true) - } - - func sendControlKey(_ keycode: UInt32) { - var keyEvent = ghostty_input_key_s() - keyEvent.action = GHOSTTY_ACTION_PRESS - keyEvent.keycode = keycode - keyEvent.mods = GHOSTTY_MODS_NONE - keyEvent.consumed_mods = GHOSTTY_MODS_NONE - keyEvent.unshifted_codepoint = 0 - keyEvent.composing = false - keyEvent.text = nil - _ = sendGhosttyKey(surface, keyEvent) - } - - for scalar in chars.unicodeScalars { - switch scalar.value { - case 0x0A: - if !previousWasCR { - flushBufferedText() - sendControlKey(0x24) // kVK_Return - } - previousWasCR = false - case 0x0D: - flushBufferedText() - sendControlKey(0x24) // kVK_Return - previousWasCR = true - case 0x09: - flushBufferedText() - sendControlKey(0x30) // kVK_Tab - previousWasCR = false - case 0x1B: - if preserveLiteralEscape { - bufferedText.unicodeScalars.append(scalar) - } else { - flushBufferedText() - sendControlKey(0x35) // kVK_Escape - } - previousWasCR = false - default: - bufferedText.unicodeScalars.append(scalar) - previousWasCR = false - } - } - flushBufferedText() -#if DEBUG - ProgramaTypingTiming.logDuration( - path: "terminal.sendTextToSurface", - startedAt: typingTimingStart, - extra: "textBytes=\(chars.utf8.count)" - ) -#endif - } - - /// External accessibility/dictation tools should commit plain text, but - /// some inject a leading escape sequence first. Strip those bytes on the - /// committed-text path so they can't leak into the PTY as literals. - static func sanitizeExternalCommittedText(_ text: String) -> String { - let bytes = Array(text.utf8) - guard !bytes.isEmpty else { return text } - - var index = 0 - while index < bytes.count { - let byte = bytes[index] - if byte == 0x1B { - index = consumeLeadingEscapeSequence(in: bytes, from: index) - continue - } - - if byte == 0xC2 { - let next = index + 1 - if next < bytes.count, bytes[next] == 0x9B { - // U+009B (C1 CSI) is encoded as the UTF-8 byte pair C2 9B. - index = consumeLeadingCSISequence(in: bytes, from: next + 1) - continue - } - } - - break - } - - if index == 0 { - return text - } - - guard index < bytes.count else { return "" } - return String(decoding: bytes[index...], as: UTF8.self) - } - - private static func consumeLeadingEscapeSequence( - in bytes: [UInt8], - from start: Int - ) -> Int { - let next = start + 1 - guard next < bytes.count else { return bytes.count } - - switch bytes[next] { - case 0x5B: - // CSI: ESC [ ... final - return consumeLeadingCSISequence(in: bytes, from: next + 1) - case 0x4F: - // SS3: ESC O final - return min(bytes.count, next + 2) - case 0x50, 0x5D, 0x5E, 0x5F: - // DCS/OSC/PM/APC: consume until BEL/ST or EOF. - return consumeLeadingEscapedStringSequence(in: bytes, from: next + 1) - default: - // Single-character escape. - return min(bytes.count, next + 1) - } - } - - private static func consumeLeadingCSISequence( - in bytes: [UInt8], - from start: Int - ) -> Int { - var index = start - while index < bytes.count { - let byte = bytes[index] - if (0x20...0x3F).contains(byte) { - index += 1 - continue - } - - if (0x40...0x7E).contains(byte) { - return index + 1 - } - - break - } - - return index - } - - private static func consumeLeadingEscapedStringSequence( - in bytes: [UInt8], - from start: Int - ) -> Int { - var index = start - while index < bytes.count { - let byte = bytes[index] - if byte == 0x07 { - return index + 1 - } - - if byte == 0x1B { - let next = index + 1 - if next < bytes.count, bytes[next] == 0x5C { - return next + 1 - } - return index - } - - if byte < 0x20 || byte == 0x7F { - return index + 1 - } - - index += 1 - } - - return bytes.count - } - - func hasMarkedText() -> Bool { - return markedText.length > 0 - } - - func markedRange() -> NSRange { - guard markedText.length > 0 else { return NSRange(location: NSNotFound, length: 0) } - return NSRange(location: 0, length: markedText.length) - } - - func selectedRange() -> NSRange { - readSelectionSnapshot()?.range ?? NSRange(location: 0, length: 0) - } - - func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { -#if DEBUG - let typingTimingStart = ProgramaTypingTiming.start() - defer { - ProgramaTypingTiming.logDuration( - path: "terminal.setMarkedText", - startedAt: typingTimingStart, - extra: "markedLength=\(markedText.length)" - ) - } -#endif - switch string { - case let v as NSAttributedString: - markedText = NSMutableAttributedString(attributedString: v) - case let v as String: - markedText = NSMutableAttributedString(string: v) - default: - break - } - - // If we're not in a keyDown event, sync preedit immediately. - // This can happen due to external events like changing keyboard layouts - // while composing. - if keyTextAccumulator == nil { - syncPreedit() - invalidateTextInputCoordinates(selectionChanged: true) - } - } - - func unmarkText() { -#if DEBUG - let hadMarkedText = markedText.length > 0 - let typingTimingStart = ProgramaTypingTiming.start() - defer { - ProgramaTypingTiming.logDuration( - path: "terminal.unmarkText", - startedAt: typingTimingStart, - extra: "hadMarkedText=\(hadMarkedText ? 1 : 0)" - ) - } -#endif - if markedText.length > 0 { - markedText.mutableString.setString("") - syncPreedit() - invalidateTextInputCoordinates(selectionChanged: true) - } - } - - /// Sync the preedit state based on the markedText value to libghostty. - /// This tells Ghostty about IME composition text so it can render the - /// preedit overlay (e.g. for Korean, Japanese, Chinese input). - func syncPreedit(clearIfNeeded: Bool = true) { -#if DEBUG - let typingTimingStart = ProgramaTypingTiming.start() - defer { - ProgramaTypingTiming.logDuration( - path: "terminal.syncPreedit", - startedAt: typingTimingStart, - extra: "markedLength=\(markedText.length) clearIfNeeded=\(clearIfNeeded ? 1 : 0)" - ) - } -#endif - guard let surface = surface else { return } - - if markedText.length > 0 { - let str = markedText.string - let len = str.utf8CString.count - if len > 0 { - str.withCString { ptr in - // Subtract 1 for the null terminator - ghostty_surface_preedit(surface, ptr, UInt(len - 1)) - } - } - } else if clearIfNeeded { - // If we had marked text before but don't now, we're no longer - // in a preedit state so we can clear it. - ghostty_surface_preedit(surface, nil, 0) - } - } - - func validAttributesForMarkedText() -> [NSAttributedString.Key] { - return [] - } - - func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { - guard range.length > 0, - let snapshot = readSelectionSnapshot() else { return nil } - actualRange?.pointee = snapshot.range - return NSAttributedString(string: snapshot.string) - } - - func characterIndex(for point: NSPoint) -> Int { - return selectedRange().location - } - - func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { - guard let window = self.window else { - return NSRect(x: frame.origin.x, y: frame.origin.y, width: 0, height: 0) - } - - // Use Ghostty's IME point API for accurate cursor position if available. - var x: Double = 0 - var y: Double = 0 - var w: Double = cellSize.width - var h: Double = cellSize.height -#if DEBUG - if range.length > 0, - range != selectedRange(), - let snapshot = readSelectionSnapshot() { - x = snapshot.topLeft.x - 2 - y = snapshot.topLeft.y + 2 - } else if let override = imePointOverrideForTesting { - x = override.x - y = override.y - w = override.width - h = override.height - } else if let surface = surface { - ghostty_surface_ime_point(surface, &x, &y, &w, &h) - } -#else - if range.length > 0, - range != selectedRange(), - let snapshot = readSelectionSnapshot() { - x = snapshot.topLeft.x - 2 - y = snapshot.topLeft.y + 2 - } else if let surface = surface { - ghostty_surface_ime_point(surface, &x, &y, &w, &h) - } -#endif - - if range.length == 0, w > 0 { - // Dictation expects a caret rect for insertion points rather than a box. - w = 0 - } - - // Ghostty coordinates are top-left origin; AppKit expects bottom-left. - let viewRect = NSRect( - x: x, - y: frame.size.height - y, - width: w, - height: max(h, cellSize.height) - ) - let winRect = convert(viewRect, to: nil) - return window.convertToScreen(winRect) - } - - func attributedString() -> NSAttributedString { - if markedText.length > 0 { - return NSAttributedString(attributedString: markedText) - } - if let snapshot = readSelectionSnapshot(), !snapshot.string.isEmpty { - return NSAttributedString(string: snapshot.string) - } - return NSAttributedString(string: "") - } - - func windowLevel() -> Int { - Int(window?.level.rawValue ?? NSWindow.Level.normal.rawValue) - } - - @available(macOS 14.0, *) - var unionRectInVisibleSelectedRange: NSRect { - firstRect(forCharacterRange: selectedRange(), actualRange: nil) - } - - @available(macOS 14.0, *) - var documentVisibleRect: NSRect { - visibleDocumentRectInScreenCoordinates() - } - - func insertText(_ string: Any, replacementRange: NSRange) { -#if DEBUG - let typingTimingStart = ProgramaTypingTiming.start() - defer { - ProgramaTypingTiming.logDuration( - path: "terminal.insertText", - startedAt: typingTimingStart, - event: NSApp.currentEvent, - extra: "replacementLocation=\(replacementRange.location) replacementLength=\(replacementRange.length)" - ) - } -#endif - // Get the string value - var chars = "" - switch string { - case let v as NSAttributedString: - chars = v.string - case let v as String: - chars = v - default: - return - } - - // Clear marked text since we're inserting - unmarkText() - - // Some IME/input-method paths call insertText with an empty payload to - // flush state. There is no terminal text to send in that case. - guard !chars.isEmpty else { return } - -#if DEBUG - if NSApp.currentEvent == nil { - dlog("ime.insertText.noEvent len=\(chars.count)") - } -#endif - - // If we have an accumulator, we're in a keyDown event - accumulate the text - if keyTextAccumulator != nil { - keyTextAccumulator?.append(chars) - return - } - - let isExternalCommittedText = externalCommittedTextDepth > 0 - let sanitizedChars = if isExternalCommittedText { - // Only sanitize explicit external committed-text paths used by - // AX/dictation integrations. Programmatic NSTextInputClient callers - // may intentionally start with ESC/CSI bytes. - Self.sanitizeExternalCommittedText(chars) - } else { - chars - } - -#if DEBUG - if sanitizedChars != chars { - dlog( - "ime.insertText.sanitized originalBytes=\(chars.utf8.count) " + - "sanitizedBytes=\(sanitizedChars.utf8.count)" - ) - } -#endif - - guard !sanitizedChars.isEmpty else { return } - - // Otherwise send directly to the terminal - sendTextToSurface( - sanitizedChars, - preserveLiteralEscape: !isExternalCommittedText - ) - } -} - -// 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 previousDesiredShowsUnreadNotificationRing = coordinator.desiredShowsUnreadNotificationRing - 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 - dlog( - "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 { - dlog( - "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" - dlog( - "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 { - dlog( - "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 - dlog( - "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) - let shouldBindNow = - coordinator.lastBoundHostId != hostId || - hostedView.superview == nil || - portalEntryMissing || - previousDesiredIsVisibleInUI != isVisibleInUI || - previousDesiredShowsUnreadNotificationRing != showsUnreadNotificationRing || - previousDesiredPortalZPriority != portalZPriority - if portalBindingLive && shouldBindNow { -#if DEBUG - if portalEntryMissing { - dlog( - "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 { - dlog( - "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 { - dlog( - "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 - dlog( - "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 { - dlog( - "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/MainWindowHostingView.swift b/Sources/MainWindowHostingView.swift new file mode 100644 index 00000000000..21e9cf4a877 --- /dev/null +++ b/Sources/MainWindowHostingView.swift @@ -0,0 +1,33 @@ +import AppKit +import SwiftUI +import Bonsplit +import CoreServices +import UserNotifications +import WebKit +import Combine +import ObjectiveC.runtime +import Darwin + +final class MainWindowHostingView: NSHostingView { + private let zeroSafeAreaLayoutGuide = NSLayoutGuide() + + override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero } + override var safeAreaRect: NSRect { bounds } + override var safeAreaLayoutGuide: NSLayoutGuide { zeroSafeAreaLayoutGuide } + + required init(rootView: Content) { + super.init(rootView: rootView) + addLayoutGuide(zeroSafeAreaLayoutGuide) + NSLayoutConstraint.activate([ + zeroSafeAreaLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor), + zeroSafeAreaLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor), + zeroSafeAreaLayoutGuide.topAnchor.constraint(equalTo: topAnchor), + zeroSafeAreaLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Sources/MenuBarIconRenderer.swift b/Sources/MenuBarIconRenderer.swift new file mode 100644 index 00000000000..d9d35ba6fd8 --- /dev/null +++ b/Sources/MenuBarIconRenderer.swift @@ -0,0 +1,669 @@ +import AppKit +import SwiftUI +import Bonsplit +import CoreServices +import UserNotifications +import WebKit +import Combine +import ObjectiveC.runtime +import Darwin + +@MainActor +final class MenuBarExtraController: NSObject, NSMenuDelegate { + private let statusItem: NSStatusItem + private let menu = NSMenu(title: "Programa") + private let notificationStore: TerminalNotificationStore + private let onShowNotifications: () -> Void + private let onOpenNotification: (TerminalNotification) -> Void + private let onJumpToLatestUnread: () -> Void + private let onCheckForUpdates: () -> Void + private let onOpenPreferences: () -> Void + private let onQuitApp: () -> Void + private var notificationsCancellable: AnyCancellable? + private let buildHintTitle: String? + + private let stateHintItem = NSMenuItem(title: String(localized: "statusMenu.noUnread", defaultValue: "No unread notifications"), action: nil, keyEquivalent: "") + private let buildHintItem = NSMenuItem(title: "", action: nil, keyEquivalent: "") + private let notificationListSeparator = NSMenuItem.separator() + private let notificationSectionSeparator = NSMenuItem.separator() + private let showNotificationsItem = NSMenuItem(title: String(localized: "statusMenu.showNotifications", defaultValue: "Show Notifications"), action: nil, keyEquivalent: "") + private let jumpToUnreadItem = NSMenuItem(title: String(localized: "statusMenu.jumpToLatestUnread", defaultValue: "Jump to Latest Unread"), action: nil, keyEquivalent: "") + private let markAllReadItem = NSMenuItem(title: String(localized: "statusMenu.markAllRead", defaultValue: "Mark All Read"), action: nil, keyEquivalent: "") + private let clearAllItem = NSMenuItem(title: String(localized: "statusMenu.clearAll", defaultValue: "Clear All"), action: nil, keyEquivalent: "") + private let checkForUpdatesItem = NSMenuItem(title: String(localized: "menu.checkForUpdates", defaultValue: "Check for Updates…"), action: nil, keyEquivalent: "") + private let preferencesItem = NSMenuItem(title: String(localized: "menu.preferences", defaultValue: "Preferences…"), action: nil, keyEquivalent: "") + private let quitItem = NSMenuItem(title: String(localized: "menu.quitPrograma", defaultValue: "Quit Programa"), action: nil, keyEquivalent: "") + + private var notificationItems: [NSMenuItem] = [] + private let maxInlineNotificationItems = 6 + + init( + notificationStore: TerminalNotificationStore, + onShowNotifications: @escaping () -> Void, + onOpenNotification: @escaping (TerminalNotification) -> Void, + onJumpToLatestUnread: @escaping () -> Void, + onCheckForUpdates: @escaping () -> Void, + onOpenPreferences: @escaping () -> Void, + onQuitApp: @escaping () -> Void + ) { + self.notificationStore = notificationStore + self.onShowNotifications = onShowNotifications + self.onOpenNotification = onOpenNotification + self.onJumpToLatestUnread = onJumpToLatestUnread + self.onCheckForUpdates = onCheckForUpdates + self.onOpenPreferences = onOpenPreferences + self.onQuitApp = onQuitApp + self.buildHintTitle = MenuBarBuildHintFormatter.menuTitle() + self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + super.init() + + buildMenu() + statusItem.menu = menu + if let button = statusItem.button { + button.imagePosition = .imageOnly + button.imageScaling = .scaleProportionallyDown + button.image = MenuBarIconRenderer.makeImage(unreadCount: 0) + button.toolTip = "Programa" + } + + notificationsCancellable = notificationStore.$notifications + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshUI() + } + + refreshUI() + } + + private func buildMenu() { + menu.autoenablesItems = false + menu.delegate = self + + stateHintItem.isEnabled = false + menu.addItem(stateHintItem) + if let buildHintTitle { + buildHintItem.title = buildHintTitle + buildHintItem.isEnabled = false + menu.addItem(buildHintItem) + } + + menu.addItem(notificationListSeparator) + notificationSectionSeparator.isHidden = true + menu.addItem(notificationSectionSeparator) + + showNotificationsItem.target = self + showNotificationsItem.action = #selector(showNotificationsAction) + menu.addItem(showNotificationsItem) + + jumpToUnreadItem.target = self + jumpToUnreadItem.action = #selector(jumpToUnreadAction) + menu.addItem(jumpToUnreadItem) + + markAllReadItem.target = self + markAllReadItem.action = #selector(markAllReadAction) + menu.addItem(markAllReadItem) + + clearAllItem.target = self + clearAllItem.action = #selector(clearAllAction) + menu.addItem(clearAllItem) + + menu.addItem(.separator()) + + checkForUpdatesItem.target = self + checkForUpdatesItem.action = #selector(checkForUpdatesAction) + menu.addItem(checkForUpdatesItem) + + preferencesItem.target = self + preferencesItem.action = #selector(preferencesAction) + menu.addItem(preferencesItem) + + menu.addItem(.separator()) + + quitItem.target = self + quitItem.action = #selector(quitAction) + menu.addItem(quitItem) + } + + func menuWillOpen(_ menu: NSMenu) { + refreshUI() + } + + func refreshForDebugControls() { + refreshUI() + } + + func removeFromMenuBar() { + notificationsCancellable?.cancel() + notificationsCancellable = nil + statusItem.menu = nil + NSStatusBar.system.removeStatusItem(statusItem) + } + + private func refreshUI() { + let snapshot = NotificationMenuSnapshotBuilder.make( + notifications: notificationStore.notifications, + maxInlineNotificationItems: maxInlineNotificationItems + ) + let actualUnreadCount = snapshot.unreadCount + + let displayedUnreadCount: Int +#if DEBUG + displayedUnreadCount = MenuBarIconDebugSettings.displayedUnreadCount(actualUnreadCount: actualUnreadCount) +#else + displayedUnreadCount = actualUnreadCount +#endif + + stateHintItem.title = snapshot.stateHintTitle + + applyShortcut(KeyboardShortcutSettings.shortcut(for: .showNotifications), to: showNotificationsItem) + applyShortcut(KeyboardShortcutSettings.shortcut(for: .jumpToUnread), to: jumpToUnreadItem) + + jumpToUnreadItem.isEnabled = snapshot.hasUnreadNotifications + markAllReadItem.isEnabled = snapshot.hasUnreadNotifications + clearAllItem.isEnabled = snapshot.hasNotifications + + rebuildInlineNotificationItems(recentNotifications: snapshot.recentNotifications) + + if let button = statusItem.button { + button.image = MenuBarIconRenderer.makeImage(unreadCount: displayedUnreadCount) + button.toolTip = displayedUnreadCount == 0 + ? "Programa" + : displayedUnreadCount == 1 + ? "Programa: " + String(localized: "statusMenu.tooltip.unread.one", defaultValue: "1 unread notification") + : "Programa: " + String(localized: "statusMenu.tooltip.unread.other", defaultValue: "\(displayedUnreadCount) unread notifications") + } + } + + private func applyShortcut(_ shortcut: StoredShortcut, to item: NSMenuItem) { + guard let keyEquivalent = shortcut.menuItemKeyEquivalent else { + item.keyEquivalent = "" + item.keyEquivalentModifierMask = [] + return + } + item.keyEquivalent = keyEquivalent + item.keyEquivalentModifierMask = shortcut.modifierFlags + } + + private func rebuildInlineNotificationItems(recentNotifications: [TerminalNotification]) { + for item in notificationItems { + menu.removeItem(item) + } + notificationItems.removeAll(keepingCapacity: true) + + notificationListSeparator.isHidden = recentNotifications.isEmpty + notificationSectionSeparator.isHidden = recentNotifications.isEmpty + guard !recentNotifications.isEmpty else { return } + + let insertionIndex = menu.index(of: showNotificationsItem) + guard insertionIndex >= 0 else { return } + + for (offset, notification) in recentNotifications.enumerated() { + let tabTitle = AppDelegate.shared?.tabTitle(for: notification.tabId) + let item = makeNotificationItem(notification: notification, tabTitle: tabTitle) + menu.insertItem(item, at: insertionIndex + offset) + notificationItems.append(item) + } + } + + private func makeNotificationItem(notification: TerminalNotification, tabTitle: String?) -> NSMenuItem { + let item = NSMenuItem(title: "", action: #selector(openNotificationItemAction(_:)), keyEquivalent: "") + item.target = self + item.attributedTitle = MenuBarNotificationLineFormatter.attributedTitle(notification: notification, tabTitle: tabTitle) + item.toolTip = MenuBarNotificationLineFormatter.tooltip(notification: notification, tabTitle: tabTitle) + item.representedObject = NotificationMenuItemPayload(notification: notification) + return item + } + + @objc private func openNotificationItemAction(_ sender: NSMenuItem) { + guard let payload = sender.representedObject as? NotificationMenuItemPayload else { return } + onOpenNotification(payload.notification) + } + + @objc private func showNotificationsAction() { + onShowNotifications() + } + + @objc private func jumpToUnreadAction() { + onJumpToLatestUnread() + } + + @objc private func markAllReadAction() { + notificationStore.markAllRead() + } + + @objc private func clearAllAction() { + notificationStore.clearAll() + } + + @objc private func checkForUpdatesAction() { + onCheckForUpdates() + } + + @objc private func preferencesAction() { + onOpenPreferences() + } + + @objc private func quitAction() { + onQuitApp() + } +} + +private final class NotificationMenuItemPayload: NSObject { + let notification: TerminalNotification + + init(notification: TerminalNotification) { + self.notification = notification + super.init() + } +} + +struct NotificationMenuSnapshot { + let unreadCount: Int + let hasNotifications: Bool + let recentNotifications: [TerminalNotification] + + var hasUnreadNotifications: Bool { + unreadCount > 0 + } + + var stateHintTitle: String { + NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: unreadCount) + } +} + +enum NotificationMenuSnapshotBuilder { + static let defaultInlineNotificationLimit = 6 + + static func make( + notifications: [TerminalNotification], + maxInlineNotificationItems: Int = defaultInlineNotificationLimit + ) -> NotificationMenuSnapshot { + let unreadCount = notifications.reduce(into: 0) { count, notification in + if !notification.isRead { + count += 1 + } + } + + let inlineLimit = max(0, maxInlineNotificationItems) + return NotificationMenuSnapshot( + unreadCount: unreadCount, + hasNotifications: !notifications.isEmpty, + recentNotifications: Array(notifications.prefix(inlineLimit)) + ) + } + + static func stateHintTitle(unreadCount: Int) -> String { + switch unreadCount { + case 0: + return String(localized: "statusMenu.noUnread", defaultValue: "No unread notifications") + case 1: + return String(localized: "statusMenu.unreadCount.one", defaultValue: "1 unread notification") + default: + return String(localized: "statusMenu.unreadCount.other", defaultValue: "\(unreadCount) unread notifications") + } + } +} + +enum MenuBarBadgeLabelFormatter { + static func badgeText(for unreadCount: Int) -> String? { + guard unreadCount > 0 else { return nil } + if unreadCount > 9 { + return "9+" + } + return String(unreadCount) + } +} + +enum MenuBarNotificationLineFormatter { + static let defaultMaxMenuTextWidth: CGFloat = 280 + static let defaultMaxMenuTextLines = 3 + + static func plainTitle(notification: TerminalNotification, tabTitle: String?) -> String { + let dot = notification.isRead ? " " : "● " + let timeText = notification.createdAt.formatted(date: .omitted, time: .shortened) + var lines: [String] = [] + lines.append("\(dot)\(notification.title) \(timeText)") + + let detail = notification.body.isEmpty ? notification.subtitle : notification.body + if !detail.isEmpty { + lines.append(detail) + } + + if let tabTitle, !tabTitle.isEmpty { + lines.append(tabTitle) + } + + return lines.joined(separator: "\n") + } + + static func menuTitle( + notification: TerminalNotification, + tabTitle: String?, + maxWidth: CGFloat = defaultMaxMenuTextWidth, + maxLines: Int = defaultMaxMenuTextLines + ) -> String { + let base = plainTitle(notification: notification, tabTitle: tabTitle) + return wrappedAndTruncated(base, maxWidth: maxWidth, maxLines: maxLines) + } + + static func attributedTitle(notification: TerminalNotification, tabTitle: String?) -> NSAttributedString { + let paragraph = NSMutableParagraphStyle() + paragraph.lineBreakMode = .byWordWrapping + return NSAttributedString( + string: menuTitle(notification: notification, tabTitle: tabTitle), + attributes: [ + .font: NSFont.menuFont(ofSize: NSFont.systemFontSize), + .foregroundColor: NSColor.labelColor, + .paragraphStyle: paragraph, + ] + ) + } + + static func tooltip(notification: TerminalNotification, tabTitle: String?) -> String { + plainTitle(notification: notification, tabTitle: tabTitle) + } + + private static func wrappedAndTruncated(_ text: String, maxWidth: CGFloat, maxLines: Int) -> String { + let width = max(60, maxWidth) + let lines = max(1, maxLines) + let font = NSFont.menuFont(ofSize: NSFont.systemFontSize) + let wrapped = wrappedLines(for: text, maxWidth: width, font: font) + guard wrapped.count > lines else { return wrapped.joined(separator: "\n") } + + var clipped = Array(wrapped.prefix(lines)) + clipped[lines - 1] = truncateLine(clipped[lines - 1], maxWidth: width, font: font) + return clipped.joined(separator: "\n") + } + + private static func wrappedLines(for text: String, maxWidth: CGFloat, font: NSFont) -> [String] { + let storage = NSTextStorage(string: text, attributes: [.font: font]) + let layout = NSLayoutManager() + let container = NSTextContainer(size: NSSize(width: maxWidth, height: .greatestFiniteMagnitude)) + container.lineFragmentPadding = 0 + container.lineBreakMode = .byWordWrapping + layout.addTextContainer(container) + storage.addLayoutManager(layout) + _ = layout.glyphRange(for: container) + + let fullText = text as NSString + var rows: [String] = [] + var glyphIndex = 0 + while glyphIndex < layout.numberOfGlyphs { + var glyphRange = NSRange() + layout.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: &glyphRange) + if glyphRange.length == 0 { break } + + let charRange = layout.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) + let row = fullText.substring(with: charRange).trimmingCharacters(in: .newlines) + rows.append(row) + glyphIndex = NSMaxRange(glyphRange) + } + + if rows.isEmpty { + return [text] + } + return rows + } + + private static func truncateLine(_ line: String, maxWidth: CGFloat, font: NSFont) -> String { + let ellipsis = "…" + let full = line.trimmingCharacters(in: .whitespacesAndNewlines) + if full.isEmpty { return ellipsis } + + if measuredWidth(full + ellipsis, font: font) <= maxWidth { + return full + ellipsis + } + + var chars = Array(full) + while !chars.isEmpty { + chars.removeLast() + let candidateBase = String(chars).trimmingCharacters(in: .whitespacesAndNewlines) + let candidate = (candidateBase.isEmpty ? "" : candidateBase) + ellipsis + if measuredWidth(candidate, font: font) <= maxWidth { + return candidate + } + } + return ellipsis + } + + private static func measuredWidth(_ text: String, font: NSFont) -> CGFloat { + (text as NSString).size(withAttributes: [.font: font]).width + } +} + +enum MenuBarBuildHintFormatter { + static func menuTitle( + appName: String = defaultAppName(), + isDebugBuild: Bool = _isDebugAssertConfiguration() + ) -> String? { + guard isDebugBuild else { return nil } + let normalized = appName.trimmingCharacters(in: .whitespacesAndNewlines) + let prefix = "cmux DEV" + guard normalized.hasPrefix(prefix) else { return "Build: DEV" } + + let suffix = String(normalized.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) + if suffix.isEmpty { + return "Build: DEV (untagged)" + } + return "Build Tag: \(suffix)" + } + + private static func defaultAppName() -> String { + let bundle = Bundle.main + if let displayName = bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String, + !displayName.isEmpty { + return displayName + } + if let name = bundle.object(forInfoDictionaryKey: "CFBundleName") as? String, !name.isEmpty { + return name + } + return ProcessInfo.processInfo.processName + } +} + +enum MenuBarExtraSettings { + static let showInMenuBarKey = "showMenuBarExtra" + static let defaultShowInMenuBar = true + + static func showsMenuBarExtra(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: showInMenuBarKey) == nil { + return defaultShowInMenuBar + } + return defaults.bool(forKey: showInMenuBarKey) + } +} + +struct MenuBarBadgeRenderConfig { + var badgeRect: NSRect + var singleDigitFontSize: CGFloat + var multiDigitFontSize: CGFloat + var singleDigitYOffset: CGFloat + var multiDigitYOffset: CGFloat + var singleDigitXAdjust: CGFloat + var multiDigitXAdjust: CGFloat + var textRectWidthAdjust: CGFloat +} + +enum MenuBarIconDebugSettings { + static let previewEnabledKey = "menubarDebugPreviewEnabled" + static let previewCountKey = "menubarDebugPreviewCount" + static let badgeRectXKey = "menubarDebugBadgeRectX" + static let badgeRectYKey = "menubarDebugBadgeRectY" + static let badgeRectWidthKey = "menubarDebugBadgeRectWidth" + static let badgeRectHeightKey = "menubarDebugBadgeRectHeight" + static let singleDigitFontSizeKey = "menubarDebugSingleDigitFontSize" + static let multiDigitFontSizeKey = "menubarDebugMultiDigitFontSize" + static let singleDigitYOffsetKey = "menubarDebugSingleDigitYOffset" + static let multiDigitYOffsetKey = "menubarDebugMultiDigitYOffset" + static let singleDigitXAdjustKey = "menubarDebugSingleDigitXAdjust" + static let legacySingleDigitXAdjustKey = "menubarDebugTextRectXAdjust" + static let multiDigitXAdjustKey = "menubarDebugMultiDigitXAdjust" + static let textRectWidthAdjustKey = "menubarDebugTextRectWidthAdjust" + + static let defaultBadgeRect = NSRect(x: 5.38, y: 6.43, width: 10.75, height: 11.58) + static let defaultSingleDigitFontSize: CGFloat = 6.7 + static let defaultMultiDigitFontSize: CGFloat = 6.7 + static let defaultSingleDigitYOffset: CGFloat = 0.6 + static let defaultMultiDigitYOffset: CGFloat = 0.6 + static let defaultSingleDigitXAdjust: CGFloat = -1.1 + static let defaultMultiDigitXAdjust: CGFloat = 2.42 + static let defaultTextRectWidthAdjust: CGFloat = 1.8 + + static func displayedUnreadCount(actualUnreadCount: Int, defaults: UserDefaults = .standard) -> Int { + guard defaults.bool(forKey: previewEnabledKey) else { return actualUnreadCount } + let value = defaults.integer(forKey: previewCountKey) + return max(0, min(value, 99)) + } + + static func badgeRenderConfig(defaults: UserDefaults = .standard) -> MenuBarBadgeRenderConfig { + let x = value(defaults, key: badgeRectXKey, fallback: defaultBadgeRect.origin.x, range: 0...20) + let y = value(defaults, key: badgeRectYKey, fallback: defaultBadgeRect.origin.y, range: 0...20) + let width = value(defaults, key: badgeRectWidthKey, fallback: defaultBadgeRect.width, range: 4...14) + let height = value(defaults, key: badgeRectHeightKey, fallback: defaultBadgeRect.height, range: 4...14) + let singleFont = value(defaults, key: singleDigitFontSizeKey, fallback: defaultSingleDigitFontSize, range: 6...14) + let multiFont = value(defaults, key: multiDigitFontSizeKey, fallback: defaultMultiDigitFontSize, range: 6...14) + let singleY = value(defaults, key: singleDigitYOffsetKey, fallback: defaultSingleDigitYOffset, range: -3...4) + let multiY = value(defaults, key: multiDigitYOffsetKey, fallback: defaultMultiDigitYOffset, range: -3...4) + let singleX = value( + defaults, + key: singleDigitXAdjustKey, + legacyKey: legacySingleDigitXAdjustKey, + fallback: defaultSingleDigitXAdjust, + range: -4...4 + ) + let multiX = value(defaults, key: multiDigitXAdjustKey, fallback: defaultMultiDigitXAdjust, range: -4...4) + let widthAdjust = value(defaults, key: textRectWidthAdjustKey, fallback: defaultTextRectWidthAdjust, range: -3...5) + + return MenuBarBadgeRenderConfig( + badgeRect: NSRect(x: x, y: y, width: width, height: height), + singleDigitFontSize: singleFont, + multiDigitFontSize: multiFont, + singleDigitYOffset: singleY, + multiDigitYOffset: multiY, + singleDigitXAdjust: singleX, + multiDigitXAdjust: multiX, + textRectWidthAdjust: widthAdjust + ) + } + + static func copyPayload(defaults: UserDefaults = .standard) -> String { + let config = badgeRenderConfig(defaults: defaults) + let previewEnabled = defaults.bool(forKey: previewEnabledKey) + let previewCount = max(0, min(defaults.integer(forKey: previewCountKey), 99)) + return """ + menubarDebugPreviewEnabled=\(previewEnabled) + menubarDebugPreviewCount=\(previewCount) + menubarDebugBadgeRectX=\(String(format: "%.2f", config.badgeRect.origin.x)) + menubarDebugBadgeRectY=\(String(format: "%.2f", config.badgeRect.origin.y)) + menubarDebugBadgeRectWidth=\(String(format: "%.2f", config.badgeRect.width)) + menubarDebugBadgeRectHeight=\(String(format: "%.2f", config.badgeRect.height)) + menubarDebugSingleDigitFontSize=\(String(format: "%.2f", config.singleDigitFontSize)) + menubarDebugMultiDigitFontSize=\(String(format: "%.2f", config.multiDigitFontSize)) + menubarDebugSingleDigitYOffset=\(String(format: "%.2f", config.singleDigitYOffset)) + menubarDebugMultiDigitYOffset=\(String(format: "%.2f", config.multiDigitYOffset)) + menubarDebugSingleDigitXAdjust=\(String(format: "%.2f", config.singleDigitXAdjust)) + menubarDebugMultiDigitXAdjust=\(String(format: "%.2f", config.multiDigitXAdjust)) + menubarDebugTextRectWidthAdjust=\(String(format: "%.2f", config.textRectWidthAdjust)) + """ + } + + private static func value( + _ defaults: UserDefaults, + key: String, + legacyKey: String? = nil, + fallback: CGFloat, + range: ClosedRange + ) -> CGFloat { + if let parsed = parse(defaults.object(forKey: key), fallback: fallback, range: range) { + return parsed + } + if let legacyKey, let parsed = parse(defaults.object(forKey: legacyKey), fallback: fallback, range: range) { + return parsed + } + return fallback + } + + private static func parse( + _ object: Any?, + fallback: CGFloat, + range: ClosedRange + ) -> CGFloat? { + guard let number = object as? NSNumber else { + return nil + } + let candidate = CGFloat(number.doubleValue) + guard candidate.isFinite else { return fallback } + return max(range.lowerBound, min(candidate, range.upperBound)) + } +} + +enum MenuBarIconRenderer { + + static func makeImage(unreadCount: Int) -> NSImage { + let badgeText = MenuBarBadgeLabelFormatter.badgeText(for: unreadCount) + let config = MenuBarIconDebugSettings.badgeRenderConfig() + let size = NSSize(width: 18, height: 18) + let image = NSImage(size: size) + image.lockFocus() + defer { image.unlockFocus() } + + let glyphRect = NSRect(x: 1.2, y: 1.5, width: 11.6, height: 15.0) + drawGlyph(in: glyphRect) + + if let text = badgeText { + drawBadge(text: text, in: config.badgeRect, config: config) + } + + image.isTemplate = true + return image + } + + private static func drawGlyph(in rect: NSRect) { + // Match the canonical cmux center-mark path from Icon Center Image Artwork.svg. + let srcMinX: CGFloat = 384.0 + let srcMinY: CGFloat = 255.0 + let srcWidth: CGFloat = 369.0 + let srcHeight: CGFloat = 513.0 + + func map(_ x: CGFloat, _ y: CGFloat) -> NSPoint { + let nx = (x - srcMinX) / srcWidth + let ny = (y - srcMinY) / srcHeight + return NSPoint( + x: rect.minX + nx * rect.width, + y: rect.minY + (1.0 - ny) * rect.height + ) + } + + let path = NSBezierPath() + path.move(to: map(384.0, 255.0)) + path.line(to: map(753.0, 511.5)) + path.line(to: map(384.0, 768.0)) + path.line(to: map(384.0, 654.0)) + path.line(to: map(582.692, 511.5)) + path.line(to: map(384.0, 369.0)) + path.close() + + NSColor.black.setFill() + path.fill() + } + + private static func drawBadge(text: String, in rect: NSRect, config: MenuBarBadgeRenderConfig) { + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .center + let fontSize: CGFloat = text.count > 1 ? config.multiDigitFontSize : config.singleDigitFontSize + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: fontSize, weight: .bold), + .foregroundColor: NSColor.systemBlue, + .paragraphStyle: paragraph, + ] + let yOffset: CGFloat = text.count > 1 ? config.multiDigitYOffset : config.singleDigitYOffset + let xAdjust: CGFloat = text.count > 1 ? config.multiDigitXAdjust : config.singleDigitXAdjust + let textRect = NSRect( + x: rect.origin.x + xAdjust, + y: rect.origin.y + yOffset, + width: rect.width + config.textRectWidthAdjust, + height: rect.height + ) + (text as NSString).draw(in: textRect, withAttributes: attrs) + } +} diff --git a/Sources/RemoteSCPUpload.swift b/Sources/RemoteSCPUpload.swift new file mode 100644 index 00000000000..c99c69d95d7 --- /dev/null +++ b/Sources/RemoteSCPUpload.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Uploads each item in `items` via `performUpload`, tracking which remote paths have +/// been recorded so far. If any upload throws (including cancellation surfaced by +/// `checkCancelled`), invokes `cleanup` with whatever remote paths were recorded before +/// the failure, then rethrows the original error. +/// +/// This is the shared control flow behind the two previously-independent +/// scp-upload-with-cancel-cleanup routines: the ad-hoc detected-SSH-session path +/// (`TerminalSSHSessionDetector.swift`) and the daemon-relay managed-workspace path +/// (`WorkspaceRemoteSession.swift`). The two differ in how a single file is transferred +/// (which executable/argument builder they use) and in whether they record a file's +/// remote destination before or after the transfer completes — both differences are +/// preserved by leaving them to `performUpload`, which decides when to call `record`. +/// Refs #92. +func performSCPUploadWithCancelCleanup( + items: [Item], + checkCancelled: () throws -> Void, + performUpload: (_ item: Item, _ record: (String) -> Void) throws -> Void, + cleanup: (_ uploadedRemotePaths: [String]) -> Void +) throws -> [String] { + guard !items.isEmpty else { return [] } + + var uploadedRemotePaths: [String] = [] + do { + for item in items { + try checkCancelled() + try performUpload(item) { uploadedRemotePaths.append($0) } + } + return uploadedRemotePaths + } catch { + cleanup(uploadedRemotePaths) + throw error + } +} diff --git a/Sources/RemoteSSHConnectionPolicy.swift b/Sources/RemoteSSHConnectionPolicy.swift new file mode 100644 index 00000000000..bedf20833f1 --- /dev/null +++ b/Sources/RemoteSSHConnectionPolicy.swift @@ -0,0 +1,89 @@ +import Foundation + +/// Shared building blocks for constructing `ssh`/`scp` argument lists. +/// +/// Consolidates the connection-policy flags (keepalive timeouts, the +/// `StrictHostKeyChecking` default, the `BatchMode`/`ControlMaster` pairing) and the +/// `-o key=value` option-parsing helpers that were previously copy-pasted across +/// `WorkspaceRemoteSession.swift`, `WorkspaceRemoteDaemon.swift`, and +/// `TerminalSSHSessionDetector.swift`. Each call site still assembles its own argument +/// list (they differ in scp/ssh flags, jump-host/proxy handling, and port-flag +/// spelling), but the identical policy fragments now have one definition. Refs #92. +enum RemoteSSHConnectionPolicy { + /// `-o ConnectTimeout=6 -o ServerAliveInterval=20 -o ServerAliveCountMax=2` + static let keepaliveArguments: [String] = [ + "-o", "ConnectTimeout=6", + "-o", "ServerAliveInterval=20", + "-o", "ServerAliveCountMax=2", + ] + + /// `-o BatchMode=yes -o ControlMaster=no`, for non-interactive/background invocations. + static let batchModeArguments: [String] = [ + "-o", "BatchMode=yes", + "-o", "ControlMaster=no", + ] + + /// `-o StrictHostKeyChecking=accept-new`, appended unless the caller's own `-o` + /// options already set `StrictHostKeyChecking` explicitly. + static func strictHostKeyCheckingArguments(unlessSetIn options: [String]) -> [String] { + hasOptionKey(options, key: "StrictHostKeyChecking") ? [] : ["-o", "StrictHostKeyChecking=accept-new"] + } + + static func hasOptionKey(_ options: [String], key: String) -> Bool { + let loweredKey = key.lowercased() + return options.contains { optionKey($0) == loweredKey } + } + + static func normalizedOptions(_ options: [String]) -> [String] { + options.compactMap { option in + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + } + } + + private static let backgroundExcludedOptionKeys: Set = [ + "controlmaster", + "controlpersist", + ] + + /// Strips `ControlMaster`/`ControlPersist` so a batch invocation can't negotiate (or + /// collide with) an interactive control-master. + static func backgroundOptions(_ options: [String]) -> [String] { + normalizedOptions(options).filter { option in + guard let key = optionKey(option) else { return false } + return !backgroundExcludedOptionKeys.contains(key) + } + } + + /// Looks up the value of a named `-o key=value` option within a list of raw options. + static func optionValue(named key: String, in options: [String]) -> String? { + let loweredKey = key.lowercased() + for option in normalizedOptions(options) { + let parts = option.split( + maxSplits: 1, + omittingEmptySubsequences: true, + whereSeparator: { $0 == "=" || $0.isWhitespace } + ) + guard parts.count == 2, parts[0].lowercased() == loweredKey else { continue } + let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { return value } + } + return nil + } + + static func optionKey(_ option: String) -> String? { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) + .first + .map(String.init)? + .lowercased() + } + + /// POSIX single-quote a string for interpolation into a `sh -c '...'` command. + static func shellSingleQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } +} diff --git a/Sources/SidebarDragDrop.swift b/Sources/SidebarDragDrop.swift new file mode 100644 index 00000000000..5413e57ab83 --- /dev/null +++ b/Sources/SidebarDragDrop.swift @@ -0,0 +1,926 @@ +// Sidebar drag-and-drop domain, extracted from ContentView.swift (nuclear-review #94.4). +// Pure move: DragOverlayRoutingPolicy, drag payload enums, drop delegates, +// drop edge/indicator/planner, auto-scroll planner, and drag lifecycle/failsafe +// types, consolidated from several non-contiguous locations in ContentView.swift. +// +// Access-level widening: SidebarDragFailsafeMonitor and SidebarExternalDropOverlay +// were file-private to the old ContentView.swift; widened to internal (dropped +// the `private` modifier) because VerticalTabsSidebar and ContentView, which stay +// in ContentView.swift, still construct/reference them. No other behavior change. + +import AppKit +import Bonsplit +import Combine +import SwiftUI +import UniformTypeIdentifiers + +enum DragOverlayRoutingPolicy { + static let bonsplitTabTransferType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") + static let sidebarTabReorderType = NSPasteboard.PasteboardType(SidebarTabDragPayload.typeIdentifier) + + static func hasBonsplitTabTransfer(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { + guard let pasteboardTypes else { return false } + return pasteboardTypes.contains(bonsplitTabTransferType) + } + + static func hasSidebarTabReorder(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { + guard let pasteboardTypes else { return false } + return pasteboardTypes.contains(sidebarTabReorderType) + } + + static func hasFileURL(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { + guard let pasteboardTypes else { return false } + return pasteboardTypes.contains(.fileURL) + } + + static func shouldCaptureFileDropDestination( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + hasLocalDraggingSource: Bool + ) -> 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. + _ = 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 } + if hasSidebarTabReorder(pasteboardTypes) { return false } + return true + } + + static func shouldCaptureFileDropDestination( + pasteboardTypes: [NSPasteboard.PasteboardType]? + ) -> Bool { + shouldCaptureFileDropDestination( + pasteboardTypes: pasteboardTypes, + hasLocalDraggingSource: false + ) + } + + static func shouldCaptureFileDropOverlay( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType? + ) -> Bool { + guard shouldCaptureFileDropDestination(pasteboardTypes: pasteboardTypes) else { return false } + guard isDragMouseEvent(eventType) else { return false } + return true + } + + static func shouldCaptureSidebarExternalOverlay( + hasSidebarDragState: Bool, + pasteboardTypes: [NSPasteboard.PasteboardType]? + ) -> Bool { + guard hasSidebarDragState else { return false } + return hasSidebarTabReorder(pasteboardTypes) + } + + static func shouldCaptureSidebarExternalOverlay( + draggedTabId: UUID?, + pasteboardTypes: [NSPasteboard.PasteboardType]? + ) -> Bool { + shouldCaptureSidebarExternalOverlay( + hasSidebarDragState: draggedTabId != nil, + pasteboardTypes: pasteboardTypes + ) + } + + static func shouldPassThroughPortalHitTesting( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType? + ) -> Bool { + guard isPortalDragEvent(eventType) else { return false } + return hasBonsplitTabTransfer(pasteboardTypes) || hasSidebarTabReorder(pasteboardTypes) + } + + private static func isDragMouseEvent(_ eventType: NSEvent.EventType?) -> Bool { + eventType == .leftMouseDragged + || eventType == .rightMouseDragged + || eventType == .otherMouseDragged + } + + private static func isPortalDragEvent(_ eventType: NSEvent.EventType?) -> Bool { + // Restrict portal pass-through to explicit drag-motion events so stale + // NSPasteboard(name: .drag) types cannot hijack normal pointer input. + guard let eventType else { return false } + switch eventType { + case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: + return true + default: + return false + } + } +} + +enum SidebarDragLifecycleNotification { + static let stateDidChange = Notification.Name("programa.sidebarDragStateDidChange") + static let requestClear = Notification.Name("programa.sidebarDragRequestClear") + static let tabIdKey = "tabId" + static let reasonKey = "reason" + + static func postStateDidChange(tabId: UUID?, reason: String) { + var userInfo: [AnyHashable: Any] = [reasonKey: reason] + if let tabId { + userInfo[tabIdKey] = tabId + } + NotificationCenter.default.post( + name: stateDidChange, + object: nil, + userInfo: userInfo + ) + } + + static func postClearRequest(reason: String) { + NotificationCenter.default.post( + name: requestClear, + object: nil, + userInfo: [reasonKey: reason] + ) + } + + static func tabId(from notification: Notification) -> UUID? { + notification.userInfo?[tabIdKey] as? UUID + } + + static func reason(from notification: Notification) -> String { + notification.userInfo?[reasonKey] as? String ?? "unknown" + } +} + +enum SidebarOutsideDropResetPolicy { + static func shouldResetDrag(draggedTabId: UUID?, hasSidebarDragPayload: Bool) -> Bool { + draggedTabId != nil && hasSidebarDragPayload + } +} + +enum SidebarDragFailsafePolicy { + static let clearDelay: TimeInterval = 0.15 + + static func shouldRequestClear(isDragActive: Bool, isLeftMouseButtonDown: Bool) -> Bool { + isDragActive && !isLeftMouseButtonDown + } + + static func shouldRequestClearWhenMonitoringStarts(isLeftMouseButtonDown: Bool) -> Bool { + shouldRequestClear( + isDragActive: true, + isLeftMouseButtonDown: isLeftMouseButtonDown + ) + } + + static func shouldRequestClear(forMouseEventType eventType: NSEvent.EventType) -> Bool { + eventType == .leftMouseUp + } +} + +@MainActor +final class SidebarDragFailsafeMonitor: ObservableObject { + private static let escapeKeyCode: UInt16 = 53 + private var pendingClearWorkItem: DispatchWorkItem? + private var appResignObserver: NSObjectProtocol? + private var keyDownMonitor: Any? + private var localMouseMonitor: Any? + private var globalMouseMonitor: Any? + private var onRequestClear: ((String) -> Void)? + + func start(onRequestClear: @escaping (String) -> Void) { + self.onRequestClear = onRequestClear + if SidebarDragFailsafePolicy.shouldRequestClearWhenMonitoringStarts( + isLeftMouseButtonDown: CGEventSource.buttonState( + .combinedSessionState, + button: .left + ) + ) { + requestClearSoon(reason: "mouse_up_failsafe") + } + if appResignObserver == nil { + appResignObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.requestClearSoon(reason: "app_resign_active") + } + } + } + if keyDownMonitor == nil { + keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + if event.keyCode == Self.escapeKeyCode { + self?.requestClearSoon(reason: "escape_cancel") + } + return event + } + } + if localMouseMonitor == nil { + localMouseMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in + if SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) { + self?.requestClearSoon(reason: "mouse_up_failsafe") + } + return event + } + } + if globalMouseMonitor == nil { + globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in + guard SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) else { return } + Task { @MainActor [weak self] in + self?.requestClearSoon(reason: "mouse_up_failsafe") + } + } + } + } + + func stop() { + pendingClearWorkItem?.cancel() + pendingClearWorkItem = nil + if let appResignObserver { + NotificationCenter.default.removeObserver(appResignObserver) + self.appResignObserver = nil + } + if let keyDownMonitor { + NSEvent.removeMonitor(keyDownMonitor) + self.keyDownMonitor = nil + } + if let localMouseMonitor { + NSEvent.removeMonitor(localMouseMonitor) + self.localMouseMonitor = nil + } + if let globalMouseMonitor { + NSEvent.removeMonitor(globalMouseMonitor) + self.globalMouseMonitor = nil + } + onRequestClear = nil + } + + private func requestClearSoon(reason: String) { + guard pendingClearWorkItem == nil else { return } +#if DEBUG + dlog("sidebar.dragFailsafe.schedule reason=\(reason)") +#endif + let workItem = DispatchWorkItem { [weak self] in +#if DEBUG + dlog("sidebar.dragFailsafe.fire reason=\(reason)") +#endif + self?.pendingClearWorkItem = nil + self?.onRequestClear?(reason) + } + pendingClearWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + SidebarDragFailsafePolicy.clearDelay, execute: workItem) + } +} + +struct SidebarExternalDropOverlay: View { + let draggedTabId: UUID? + + var body: some View { + let dragPasteboardTypes = NSPasteboard(name: .drag).types + let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureSidebarExternalOverlay( + draggedTabId: draggedTabId, + pasteboardTypes: dragPasteboardTypes + ) + Group { + if shouldCapture { + Color.clear + .contentShape(Rectangle()) + .allowsHitTesting(true) + .onDrop( + of: SidebarTabDragPayload.dropContentTypes, + delegate: SidebarExternalDropDelegate(draggedTabId: draggedTabId) + ) + } else { + Color.clear + .contentShape(Rectangle()) + .allowsHitTesting(false) + } + } + } +} + +private struct SidebarExternalDropDelegate: DropDelegate { + let draggedTabId: UUID? + + func validateDrop(info: DropInfo) -> Bool { + let hasSidebarPayload = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) + let shouldReset = SidebarOutsideDropResetPolicy.shouldResetDrag( + draggedTabId: draggedTabId, + hasSidebarDragPayload: hasSidebarPayload + ) +#if DEBUG + dlog( + "sidebar.dropOutside.validate tab=\(debugShortSidebarTabId(draggedTabId)) " + + "hasType=\(hasSidebarPayload) allowed=\(shouldReset)" + ) +#endif + return shouldReset + } + + func dropEntered(info: DropInfo) { +#if DEBUG + dlog("sidebar.dropOutside.entered tab=\(debugShortSidebarTabId(draggedTabId))") +#endif + } + + func dropExited(info: DropInfo) { +#if DEBUG + dlog("sidebar.dropOutside.exited tab=\(debugShortSidebarTabId(draggedTabId))") +#endif + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + guard validateDrop(info: info) else { return nil } +#if DEBUG + dlog("sidebar.dropOutside.updated tab=\(debugShortSidebarTabId(draggedTabId)) op=move") +#endif + // Explicit move proposal avoids AppKit showing a copy (+) cursor. + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + guard validateDrop(info: info) else { return false } +#if DEBUG + dlog("sidebar.dropOutside.perform tab=\(debugShortSidebarTabId(draggedTabId))") +#endif + SidebarDragLifecycleNotification.postClearRequest(reason: "outside_sidebar_drop") + return true + } + + private func debugShortSidebarTabId(_ id: UUID?) -> String { + guard let id else { return "nil" } + return String(id.uuidString.prefix(5)) + } +} + +@MainActor + +enum SidebarDropEdge: Equatable { + case top + case bottom +} + +struct SidebarDropIndicator: Equatable { + let tabId: UUID? + let edge: SidebarDropEdge +} + +enum SidebarDropPlanner { + static func indicator( + draggedTabId: UUID?, + targetTabId: UUID?, + tabIds: [UUID], + pinnedTabIds: Set, + pointerY: CGFloat? = nil, + targetHeight: CGFloat? = nil + ) -> SidebarDropIndicator? { + guard tabIds.count > 1, let draggedTabId else { return nil } + guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil } + + let insertionPosition: Int + if let targetTabId { + guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil } + let edge: SidebarDropEdge + if let pointerY, let targetHeight { + edge = edgeForPointer(locationY: pointerY, targetHeight: targetHeight) + } else { + edge = preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds) + } + insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex + } else { + insertionPosition = tabIds.count + } + + let legalInsertionPosition = legalInsertionPosition( + draggedTabId: draggedTabId, + proposedInsertionPosition: insertionPosition, + tabIds: tabIds, + pinnedTabIds: pinnedTabIds + ) + let legalTargetIndex = resolvedTargetIndex( + from: fromIndex, + insertionPosition: legalInsertionPosition, + totalCount: tabIds.count + ) + guard legalTargetIndex != fromIndex else { return nil } + return indicatorForInsertionPosition(legalInsertionPosition, tabIds: tabIds) + } + + static func targetIndex( + draggedTabId: UUID, + targetTabId: UUID?, + indicator: SidebarDropIndicator?, + tabIds: [UUID], + pinnedTabIds: Set + ) -> Int? { + guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil } + + let insertionPosition: Int + if let indicator, let indicatorInsertion = insertionPositionForIndicator(indicator, tabIds: tabIds) { + insertionPosition = indicatorInsertion + } else if let targetTabId { + guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil } + let edge = (indicator?.tabId == targetTabId) + ? (indicator?.edge ?? preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds)) + : preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds) + insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex + } else { + insertionPosition = tabIds.count + } + + let legalInsertionPosition = legalInsertionPosition( + draggedTabId: draggedTabId, + proposedInsertionPosition: insertionPosition, + tabIds: tabIds, + pinnedTabIds: pinnedTabIds + ) + return resolvedTargetIndex(from: fromIndex, insertionPosition: legalInsertionPosition, totalCount: tabIds.count) + } + + private static func indicatorForInsertionPosition(_ insertionPosition: Int, tabIds: [UUID]) -> SidebarDropIndicator { + let clampedInsertion = max(0, min(insertionPosition, tabIds.count)) + if clampedInsertion >= tabIds.count { + return SidebarDropIndicator(tabId: nil, edge: .bottom) + } + return SidebarDropIndicator(tabId: tabIds[clampedInsertion], edge: .top) + } + + private static func insertionPositionForIndicator(_ indicator: SidebarDropIndicator, tabIds: [UUID]) -> Int? { + if let tabId = indicator.tabId { + guard let targetTabIndex = tabIds.firstIndex(of: tabId) else { return nil } + return indicator.edge == .bottom ? targetTabIndex + 1 : targetTabIndex + } + return tabIds.count + } + + private static func preferredEdge(fromIndex: Int, targetTabId: UUID, tabIds: [UUID]) -> SidebarDropEdge { + guard let targetIndex = tabIds.firstIndex(of: targetTabId) else { return .top } + return fromIndex < targetIndex ? .bottom : .top + } + + private static func legalInsertionPosition( + draggedTabId: UUID, + proposedInsertionPosition: Int, + tabIds: [UUID], + pinnedTabIds: Set + ) -> Int { + let clampedInsertion = max(0, min(proposedInsertionPosition, tabIds.count)) + guard !pinnedTabIds.isEmpty else { return clampedInsertion } + + let pinnedCount = tabIds.reduce(into: 0) { count, tabId in + if pinnedTabIds.contains(tabId) { + count += 1 + } + } + guard pinnedCount > 0 else { return clampedInsertion } + + if pinnedTabIds.contains(draggedTabId) { + return min(clampedInsertion, pinnedCount) + } + return max(clampedInsertion, pinnedCount) + } + + static func edgeForPointer(locationY: CGFloat, targetHeight: CGFloat) -> SidebarDropEdge { + guard targetHeight > 0 else { return .top } + let clampedY = min(max(locationY, 0), targetHeight) + return clampedY < (targetHeight / 2) ? .top : .bottom + } + + private static func resolvedTargetIndex(from sourceIndex: Int, insertionPosition: Int, totalCount: Int) -> Int { + let clampedInsertion = max(0, min(insertionPosition, totalCount)) + let adjusted = clampedInsertion > sourceIndex ? clampedInsertion - 1 : clampedInsertion + return max(0, min(adjusted, max(0, totalCount - 1))) + } +} + +enum SidebarAutoScrollDirection: Equatable { + case up + case down +} + +struct SidebarAutoScrollPlan: Equatable { + let direction: SidebarAutoScrollDirection + let pointsPerTick: CGFloat +} + +enum SidebarDragAutoScrollPlanner { + static let edgeInset: CGFloat = 44 + static let minStep: CGFloat = 2 + static let maxStep: CGFloat = 12 + + static func plan( + distanceToTop: CGFloat, + distanceToBottom: CGFloat, + edgeInset: CGFloat = SidebarDragAutoScrollPlanner.edgeInset, + minStep: CGFloat = SidebarDragAutoScrollPlanner.minStep, + maxStep: CGFloat = SidebarDragAutoScrollPlanner.maxStep + ) -> SidebarAutoScrollPlan? { + guard edgeInset > 0, maxStep >= minStep else { return nil } + if distanceToTop <= edgeInset { + let normalized = max(0, min(1, (edgeInset - distanceToTop) / edgeInset)) + let step = minStep + ((maxStep - minStep) * normalized) + return SidebarAutoScrollPlan(direction: .up, pointsPerTick: step) + } + if distanceToBottom <= edgeInset { + let normalized = max(0, min(1, (edgeInset - distanceToBottom) / edgeInset)) + let step = minStep + ((maxStep - minStep) * normalized) + return SidebarAutoScrollPlan(direction: .down, pointsPerTick: step) + } + return nil + } +} + +@MainActor +final class SidebarDragAutoScrollController: ObservableObject { + private weak var scrollView: NSScrollView? + private var timer: Timer? + private var activePlan: SidebarAutoScrollPlan? + + func attach(scrollView: NSScrollView?) { + self.scrollView = scrollView + } + + func updateFromDragLocation() { + guard let scrollView else { + stop() + return + } + guard let plan = plan(for: scrollView) else { + stop() + return + } + activePlan = plan + startTimerIfNeeded() + } + + func stop() { + timer?.invalidate() + timer = nil + activePlan = nil + } + + private func startTimerIfNeeded() { + guard timer == nil else { return } + let timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + self?.tick() + } + } + self.timer = timer + RunLoop.main.add(timer, forMode: .eventTracking) + } + + private func tick() { + guard NSEvent.pressedMouseButtons != 0 else { + stop() + return + } + guard let scrollView else { + stop() + return + } + + // AppKit drag/drop autoscroll guidance recommends autoscroll(with:) + // when periodic drag updates are available; use it first. + if applyNativeAutoscroll(to: scrollView) { + activePlan = plan(for: scrollView) + if activePlan == nil { + stop() + } + return + } + + activePlan = self.plan(for: scrollView) + guard let plan = activePlan else { + stop() + return + } + _ = apply(plan: plan, to: scrollView) + } + + private func applyNativeAutoscroll(to scrollView: NSScrollView) -> Bool { + guard let event = NSApp.currentEvent else { return false } + switch event.type { + case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: + break + default: + return false + } + + let clipView = scrollView.contentView + let didScroll = clipView.autoscroll(with: event) + if didScroll { + scrollView.reflectScrolledClipView(clipView) + } + return didScroll + } + + private func distancesToEdges(mousePoint: CGPoint, viewportHeight: CGFloat, isFlipped: Bool) -> (top: CGFloat, bottom: CGFloat) { + if isFlipped { + return (top: mousePoint.y, bottom: viewportHeight - mousePoint.y) + } + return (top: viewportHeight - mousePoint.y, bottom: mousePoint.y) + } + + private func planForMousePoint(_ mousePoint: CGPoint, in clipView: NSClipView) -> SidebarAutoScrollPlan? { + let viewportHeight = clipView.bounds.height + guard viewportHeight > 0 else { return nil } + + let distances = distancesToEdges(mousePoint: mousePoint, viewportHeight: viewportHeight, isFlipped: clipView.isFlipped) + return SidebarDragAutoScrollPlanner.plan(distanceToTop: distances.top, distanceToBottom: distances.bottom) + } + + private func mousePoint(in clipView: NSClipView) -> CGPoint { + let mouseInWindow = clipView.window?.convertPoint(fromScreen: NSEvent.mouseLocation) ?? .zero + return clipView.convert(mouseInWindow, from: nil) + } + + private func currentPlan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? { + let clipView = scrollView.contentView + let mouse = mousePoint(in: clipView) + return planForMousePoint(mouse, in: clipView) + } + + private func plan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? { + currentPlan(for: scrollView) + } + + private func apply(plan: SidebarAutoScrollPlan, to scrollView: NSScrollView) -> Bool { + guard let documentView = scrollView.documentView else { return false } + let clipView = scrollView.contentView + let maxOriginY = max(0, documentView.bounds.height - clipView.bounds.height) + guard maxOriginY > 0 else { return false } + + let directionMultiplier: CGFloat = (plan.direction == .down) ? 1 : -1 + let flippedMultiplier: CGFloat = documentView.isFlipped ? 1 : -1 + let delta = directionMultiplier * flippedMultiplier * plan.pointsPerTick + let currentY = clipView.bounds.origin.y + let targetY = min(max(currentY + delta, 0), maxOriginY) + guard abs(targetY - currentY) > 0.01 else { return false } + + clipView.scroll(to: CGPoint(x: clipView.bounds.origin.x, y: targetY)) + scrollView.reflectScrolledClipView(clipView) + return true + } +} + +enum SidebarTabDragPayload { + static let typeIdentifier = "com.darkroom.programa.sidebar-tab-reorder" + static let dropContentType = UTType(exportedAs: typeIdentifier) + static let dropContentTypes: [UTType] = [dropContentType] + private static let prefix = "programa.sidebar-tab." + + static func provider(for tabId: UUID) -> NSItemProvider { + let provider = NSItemProvider() + let payload = "\(prefix)\(tabId.uuidString)" + provider.registerDataRepresentation(forTypeIdentifier: typeIdentifier, visibility: .ownProcess) { completion in + completion(payload.data(using: .utf8), nil) + return nil + } + return provider + } +} + +enum BonsplitTabDragPayload { + static let typeIdentifier = "com.splittabbar.tabtransfer" + static let dropContentType = UTType(exportedAs: typeIdentifier) + static let dropContentTypes: [UTType] = [dropContentType] + private static let currentProcessId = Int32(ProcessInfo.processInfo.processIdentifier) + + struct Transfer: Decodable { + struct TabInfo: Decodable { + let id: UUID + } + + let tab: TabInfo + let sourcePaneId: UUID + let sourceProcessId: Int32 + + private enum CodingKeys: String, CodingKey { + case tab + case sourcePaneId + case sourceProcessId + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.tab = try container.decode(TabInfo.self, forKey: .tab) + self.sourcePaneId = try container.decode(UUID.self, forKey: .sourcePaneId) + // Legacy payloads won't include this field. Treat as foreign process. + self.sourceProcessId = try container.decodeIfPresent(Int32.self, forKey: .sourceProcessId) ?? -1 + } + } + + private static func isCurrentProcessTransfer(_ transfer: Transfer) -> Bool { + transfer.sourceProcessId == currentProcessId + } + + static func currentTransfer() -> Transfer? { + let pasteboard = NSPasteboard(name: .drag) + let type = NSPasteboard.PasteboardType(typeIdentifier) + + if let data = pasteboard.data(forType: type), + let transfer = try? JSONDecoder().decode(Transfer.self, from: data), + isCurrentProcessTransfer(transfer) { + return transfer + } + + if let raw = pasteboard.string(forType: type), + let data = raw.data(using: .utf8), + let transfer = try? JSONDecoder().decode(Transfer.self, from: data), + isCurrentProcessTransfer(transfer) { + return transfer + } + + return nil + } +} + +struct SidebarBonsplitTabDropDelegate: 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 + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + guard validateDrop(info: info) else { return nil } + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + guard validateDrop(info: info), + let transfer = BonsplitTabDragPayload.currentTransfer(), + let app = AppDelegate.shared else { + return false + } + + if let source = app.locateBonsplitSurface(tabId: transfer.tab.id), + source.workspaceId == targetWorkspaceId { + syncSidebarSelection() + return true + } + + guard app.moveBonsplitTab( + tabId: transfer.tab.id, + toWorkspace: targetWorkspaceId, + focus: true, + focusWindow: true + ) else { + return false + } + + selectedTabIds = [targetWorkspaceId] + syncSidebarSelection() + return true + } + + private func syncSidebarSelection() { + if let selectedId = tabManager.selectedTabId { + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } else { + lastSidebarSelectionIndex = nil + } + } +} + +struct SidebarTabDropDelegate: DropDelegate { + let targetTabId: UUID? + let tabManager: TabManager + @Binding var draggedTabId: UUID? + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + let targetRowHeight: CGFloat? + let dragAutoScrollController: SidebarDragAutoScrollController + @Binding var dropIndicator: SidebarDropIndicator? + + func validateDrop(info: DropInfo) -> Bool { + let hasType = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) + let hasDrag = draggedTabId != nil + #if DEBUG + dlog("sidebar.validateDrop target=\(targetTabId?.uuidString.prefix(5) ?? "end") hasType=\(hasType) hasDrag=\(hasDrag)") + #endif + return hasType && hasDrag + } + + func dropEntered(info: DropInfo) { + #if DEBUG + dlog("sidebar.dropEntered target=\(targetTabId?.uuidString.prefix(5) ?? "end")") + #endif + dragAutoScrollController.updateFromDragLocation() + updateDropIndicator(for: info) + } + + func dropExited(info: DropInfo) { +#if DEBUG + dlog("sidebar.dropExited target=\(targetTabId?.uuidString.prefix(5) ?? "end")") +#endif + if dropIndicator?.tabId == targetTabId { + dropIndicator = nil + } + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + dragAutoScrollController.updateFromDragLocation() + updateDropIndicator(for: info) +#if DEBUG + dlog( + "sidebar.dropUpdated target=\(targetTabId?.uuidString.prefix(5) ?? "end") " + + "indicator=\(debugIndicator(dropIndicator))" + ) +#endif + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + defer { + draggedTabId = nil + dropIndicator = nil + dragAutoScrollController.stop() + } + #if DEBUG + dlog("sidebar.drop target=\(targetTabId?.uuidString.prefix(5) ?? "end")") + #endif + guard let draggedTabId else { +#if DEBUG + dlog("sidebar.drop.abort reason=missingDraggedTab") +#endif + return false + } + guard let fromIndex = tabManager.tabs.firstIndex(where: { $0.id == draggedTabId }) else { +#if DEBUG + dlog("sidebar.drop.abort reason=draggedTabMissing tab=\(draggedTabId.uuidString.prefix(5))") +#endif + return false + } + let tabIds = tabManager.tabs.map(\.id) + guard let targetIndex = SidebarDropPlanner.targetIndex( + draggedTabId: draggedTabId, + targetTabId: targetTabId, + indicator: dropIndicator, + tabIds: tabIds, + pinnedTabIds: Set(tabManager.tabs.filter(\.isPinned).map(\.id)) + ) else { +#if DEBUG + dlog( + "sidebar.drop.abort reason=noTargetIndex tab=\(draggedTabId.uuidString.prefix(5)) " + + "target=\(targetTabId?.uuidString.prefix(5) ?? "end") indicator=\(debugIndicator(dropIndicator))" + ) +#endif + return false + } + + guard fromIndex != targetIndex else { +#if DEBUG + dlog("sidebar.drop.noop from=\(fromIndex) to=\(targetIndex)") +#endif + syncSidebarSelection() + return true + } + +#if DEBUG + 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 { + selectedTabIds = [selectedId] + syncSidebarSelection(preferredSelectedTabId: selectedId) + } else { + selectedTabIds = [] + syncSidebarSelection() + } + return true + } + + private func updateDropIndicator(for info: DropInfo) { + let tabIds = tabManager.tabs.map(\.id) + let pinnedTabIds = Set(tabManager.tabs.filter(\.isPinned).map(\.id)) + let nextIndicator = SidebarDropPlanner.indicator( + draggedTabId: draggedTabId, + targetTabId: targetTabId, + tabIds: tabIds, + pinnedTabIds: pinnedTabIds, + pointerY: targetTabId == nil ? nil : info.location.y, + targetHeight: targetRowHeight + ) + guard dropIndicator != nextIndicator else { return } + dropIndicator = nextIndicator + } + + private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { + let selectedId = preferredSelectedTabId ?? tabManager.selectedTabId + if let selectedId { + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } else { + lastSidebarSelectionIndex = nil + } + } + + private func debugIndicator(_ indicator: SidebarDropIndicator?) -> String { + guard let indicator else { return "nil" } + let tabText = indicator.tabId.map { String($0.uuidString.prefix(5)) } ?? "end" + return "\(tabText):\(indicator.edge == .top ? "top" : "bottom")" + } +} + diff --git a/Sources/SidebarVisuals.swift b/Sources/SidebarVisuals.swift new file mode 100644 index 00000000000..7ce7bd54a65 --- /dev/null +++ b/Sources/SidebarVisuals.swift @@ -0,0 +1,1533 @@ +// Sidebar visual chrome, extracted from ContentView.swift (nuclear-review #94.5). +// Pure move: sidebar footer/help-menu/scrim/blur/material/tint/preset enums, +// visual-effect/backdrop views, and the NSColor extension, consolidated from two +// non-contiguous locations in ContentView.swift. +// +// Access-level widening: SidebarFooter, SidebarTopScrim, SidebarScrollViewResolver, +// SidebarEmptyArea, ClearScrollBackground, DraggableFolderIcon, +// TitlebarLeadingInsetReader, SidebarTrailingBorder, and SidebarBackdrop were +// file-private to the old ContentView.swift; widened to internal (dropped the +// `private` modifier) because VerticalTabsSidebar and ContentView, which stay in +// ContentView.swift, still construct/reference them. No other behavior change. + +import AppKit +import Bonsplit +import SwiftUI + +struct SidebarFooter: View { + @ObservedObject var updateViewModel: UpdateViewModel + let onSendFeedback: () -> Void + + var body: some View { +#if DEBUG + SidebarDevFooter(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) +#else + SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) + .padding(.leading, 6) + .padding(.trailing, 10) + .padding(.bottom, 6) +#endif + } +} + +private struct SidebarFooterButtons: View { + @ObservedObject var updateViewModel: UpdateViewModel + let onSendFeedback: () -> Void + + var body: some View { + HStack(spacing: 4) { + SidebarHelpMenuButton(onSendFeedback: onSendFeedback) + UpdatePill(model: updateViewModel) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private enum SidebarHelpMenuAction { + case importBrowserData + case keyboardShortcuts + case docs + case changelog + case github + case githubIssues + case discord + case checkForUpdates + case sendFeedback + case welcome +} + +private struct SidebarHelpMenuButton: View { + private let docsURL = URL(string: "https://github.com/darkroomengineering/programa/tree/main/docs") + private let changelogURL = URL(string: "https://github.com/darkroomengineering/programa/blob/main/CHANGELOG.md") + private let githubURL = URL(string: "https://github.com/darkroomengineering/programa") + private let githubIssuesURL = URL(string: "https://github.com/darkroomengineering/programa/issues") + private let discordURL = URL(string: "https://discord.gg/xsgFEVrWCZ") + private let helpTitle = String(localized: "sidebar.help.button", defaultValue: "Help") + private let buttonSize: CGFloat = 22 + private let iconSize: CGFloat = 11 + @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared + + let onSendFeedback: () -> Void + + @State private var isPopoverPresented = false + + private var sendFeedbackShortcutHint: String { + let _ = keyboardShortcutSettingsObserver.revision + return KeyboardShortcutSettings.shortcut(for: .sendFeedback).displayString + } + + var body: some View { + Button { + isPopoverPresented.toggle() + } label: { + Image(systemName: "questionmark.circle") + .symbolRenderingMode(.monochrome) + .font(.system(size: iconSize, weight: .medium)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .frame(width: buttonSize, height: buttonSize, alignment: .center) + } + .buttonStyle(SidebarFooterIconButtonStyle()) + .frame(width: buttonSize, height: buttonSize, alignment: .center) + .background(ArrowlessPopoverAnchor( + isPresented: $isPopoverPresented, + preferredEdge: .maxY, + detachedGap: 4 + ) { + helpPopover + }) + .accessibilityElement(children: .ignore) + .safeHelp(helpTitle) + .accessibilityLabel(helpTitle) + .accessibilityIdentifier("SidebarHelpMenuButton") + } + + private var helpPopover: some View { + VStack(alignment: .leading, spacing: 2) { + helpOptionButton( + title: String(localized: "sidebar.help.welcome", defaultValue: "Welcome to Programa!"), + action: .welcome, + accessibilityIdentifier: "SidebarHelpMenuOptionWelcome", + isExternalLink: false + ) + helpOptionButton( + title: String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback"), + action: .sendFeedback, + accessibilityIdentifier: "SidebarHelpMenuOptionSendFeedback", + isExternalLink: false, + shortcutHint: sendFeedbackShortcutHint, + trailingSystemImage: "bubble.left.and.text.bubble.right" + ) + helpOptionButton( + title: String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts"), + action: .keyboardShortcuts, + accessibilityIdentifier: "SidebarHelpMenuOptionKeyboardShortcuts", + isExternalLink: false + ) + helpOptionButton( + title: String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…"), + action: .importBrowserData, + accessibilityIdentifier: "SidebarHelpMenuOptionImportBrowserData", + isExternalLink: false + ) + if docsURL != nil { + helpOptionButton( + title: String(localized: "about.docs", defaultValue: "Docs"), + action: .docs, + accessibilityIdentifier: "SidebarHelpMenuOptionDocs", + isExternalLink: true + ) + } + if changelogURL != nil { + helpOptionButton( + title: String(localized: "sidebar.help.changelog", defaultValue: "Changelog"), + action: .changelog, + accessibilityIdentifier: "SidebarHelpMenuOptionChangelog", + isExternalLink: true + ) + } + if githubURL != nil { + helpOptionButton( + title: String(localized: "about.github", defaultValue: "GitHub"), + action: .github, + accessibilityIdentifier: "SidebarHelpMenuOptionGitHub", + isExternalLink: true + ) + } + if githubIssuesURL != nil { + helpOptionButton( + title: String(localized: "sidebar.help.githubIssues", defaultValue: "GitHub Issues"), + action: .githubIssues, + accessibilityIdentifier: "SidebarHelpMenuOptionGitHubIssues", + isExternalLink: true + ) + } + if discordURL != nil { + helpOptionButton( + title: String(localized: "sidebar.help.discord", defaultValue: "Discord"), + action: .discord, + accessibilityIdentifier: "SidebarHelpMenuOptionDiscord", + isExternalLink: true + ) + } + helpOptionButton( + title: String(localized: "command.checkForUpdates.title", defaultValue: "Check for Updates"), + action: .checkForUpdates, + accessibilityIdentifier: "SidebarHelpMenuOptionCheckForUpdates", + isExternalLink: false + ) + } + .padding(8) + .frame(minWidth: 200) + } + + private func helpOptionButton( + title: String, + action: SidebarHelpMenuAction, + accessibilityIdentifier: String, + isExternalLink: Bool, + shortcutHint: String? = nil, + trailingSystemImage: String? = nil + ) -> some View { + Button { + isPopoverPresented = false + perform(action) + } label: { + HStack(spacing: 8) { + Text(title) + .font(.system(size: 12)) + Spacer(minLength: 0) + if let shortcutHint { + helpOptionShortcutHint(text: shortcutHint) + } + if let trailingSystemImage { + helpOptionTrailingIcon(systemName: trailingSystemImage) + } + if isExternalLink { + helpOptionTrailingIcon(systemName: "arrow.up.right", size: 8) + } + } + .padding(.horizontal, 8) + .frame(height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier(accessibilityIdentifier) + } + + private func helpOptionShortcutHint(text: String) -> some View { + Text(text) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .font(.system(size: 10, weight: .regular, design: .rounded)) + .monospacedDigit() + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + } + + private func helpOptionTrailingIcon(systemName: String, size: CGFloat = 13) -> some View { + Image(systemName: systemName) + .resizable() + .scaledToFit() + .frame(width: size, height: size) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + } + + private func perform(_ action: SidebarHelpMenuAction) { + switch action { + case .importBrowserData: + isPopoverPresented = false + DispatchQueue.main.async { + BrowserDataImportCoordinator.shared.presentImportDialog() + } + case .keyboardShortcuts: + isPopoverPresented = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { + Task { @MainActor in + if let appDelegate = AppDelegate.shared { + appDelegate.openPreferencesWindow( + debugSource: "sidebarHelpMenu.keyboardShortcuts", + navigationTarget: .keyboardShortcuts + ) + } else { + AppDelegate.presentPreferencesWindow(navigationTarget: .keyboardShortcuts) + } + } + } + case .docs: + guard let docsURL else { return } + NSWorkspace.shared.open(docsURL) + case .changelog: + guard let changelogURL else { return } + NSWorkspace.shared.open(changelogURL) + case .github: + guard let githubURL else { return } + NSWorkspace.shared.open(githubURL) + case .githubIssues: + guard let githubIssuesURL else { return } + NSWorkspace.shared.open(githubIssuesURL) + case .discord: + guard let discordURL else { return } + NSWorkspace.shared.open(discordURL) + case .checkForUpdates: + Task { @MainActor in + AppDelegate.shared?.checkForUpdates(nil) + } + case .sendFeedback: + isPopoverPresented = false + onSendFeedback() + case .welcome: + isPopoverPresented = false + Task { @MainActor in + if let appDelegate = AppDelegate.shared { + appDelegate.openWelcomeWorkspace() + } + } + } + } + +} + +private struct ArrowlessPopoverAnchor: NSViewRepresentable { + @Binding var isPresented: Bool + let preferredEdge: NSRectEdge + let detachedGap: CGFloat + @ViewBuilder let content: () -> PopoverContent + + func makeNSView(context: Context) -> NSView { + let view = NSView() + context.coordinator.anchorView = view + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + context.coordinator.anchorView = nsView + context.coordinator.updateRootView(AnyView(content())) + + if isPresented { + context.coordinator.present( + preferredEdge: preferredEdge, + detachedGap: detachedGap + ) + } else { + context.coordinator.dismiss() + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(isPresented: $isPresented) + } + + final class Coordinator: NSObject, NSPopoverDelegate { + @Binding var isPresented: Bool + + weak var anchorView: NSView? + private let hostingController = NSHostingController(rootView: AnyView(EmptyView())) + private var popover: NSPopover? + + init(isPresented: Binding) { + _isPresented = isPresented + } + + func updateRootView(_ rootView: AnyView) { + hostingController.rootView = AnyView(rootView.fixedSize()) + hostingController.view.invalidateIntrinsicContentSize() + hostingController.view.layoutSubtreeIfNeeded() + } + + func present(preferredEdge: NSRectEdge, detachedGap: CGFloat) { + guard let anchorView else { + isPresented = false + dismiss() + return + } + + let popover = popover ?? makePopover() + if popover.isShown { + return + } + + hostingController.view.invalidateIntrinsicContentSize() + hostingController.view.layoutSubtreeIfNeeded() + let fittingSize = hostingController.view.fittingSize + if fittingSize.width > 0, fittingSize.height > 0 { + popover.contentSize = NSSize( + width: ceil(fittingSize.width), + height: ceil(fittingSize.height) + ) + } + + popover.show( + relativeTo: positioningRect( + for: anchorView.bounds, + preferredEdge: preferredEdge, + detachedGap: detachedGap + ), + of: anchorView, + preferredEdge: preferredEdge + ) + } + + func dismiss() { + popover?.performClose(nil) + popover = nil + } + + func popoverDidClose(_ notification: Notification) { + popover = nil + if isPresented { + isPresented = false + } + } + + private func makePopover() -> NSPopover { + let popover = NSPopover() + popover.behavior = .semitransient + popover.animates = true + popover.setValue(true, forKeyPath: "shouldHideAnchor") + popover.contentViewController = hostingController + popover.delegate = self + self.popover = popover + return popover + } + + private func positioningRect( + for bounds: CGRect, + preferredEdge: NSRectEdge, + detachedGap: CGFloat + ) -> CGRect { + let hiddenArrowInset: CGFloat = 13 + let compensation = max(hiddenArrowInset - detachedGap, 0) + + switch preferredEdge { + case .maxY: + return NSRect( + x: bounds.minX, + y: bounds.maxY - compensation, + width: bounds.width, + height: compensation + ) + case .minY: + return NSRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width, + height: compensation + ) + case .maxX: + return NSRect( + x: bounds.maxX - compensation, + y: bounds.minY, + width: compensation, + height: bounds.height + ) + case .minX: + return NSRect( + x: bounds.minX, + y: bounds.minY, + width: compensation, + height: bounds.height + ) + @unknown default: + return bounds + } + } + } +} + +private struct SidebarFooterIconButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + SidebarFooterIconButtonStyleBody(configuration: configuration) + } +} + +private struct SidebarFooterIconButtonStyleBody: View { + let configuration: SidebarFooterIconButtonStyle.Configuration + + @Environment(\.isEnabled) private var isEnabled + @State private var isHovered = false + + private var backgroundOpacity: Double { + guard isEnabled else { return 0.0 } + if configuration.isPressed { return 0.16 } + if isHovered { return 0.08 } + return 0.0 + } + + var body: some View { + configuration.label + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.primary.opacity(backgroundOpacity)) + ) + .onHover { hovering in + isHovered = hovering + } + .animation(.easeOut(duration: 0.12), value: isHovered) + .animation(.easeOut(duration: 0.08), value: configuration.isPressed) + } +} + +#if DEBUG +private struct SidebarDevFooter: View { + @ObservedObject var updateViewModel: UpdateViewModel + let onSendFeedback: () -> Void + @AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey) + private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) + if showSidebarDevBuildBanner { + Text(String(localized: "debug.devBuildBanner.title", defaultValue: "THIS IS A DEV BUILD")) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.red) + } + } + .padding(.leading, 6) + .padding(.trailing, 10) + .padding(.bottom, 6) + } +} +#endif + +struct SidebarTopScrim: View { + let height: CGFloat + + var body: some View { + SidebarTopBlurEffect() + .frame(height: height) + .mask( + LinearGradient( + colors: [ + Color.black.opacity(0.95), + Color.black.opacity(0.75), + Color.black.opacity(0.35), + Color.clear + ], + startPoint: .top, + endPoint: .bottom + ) + ) + } +} + +private struct SidebarTopBlurEffect: NSViewRepresentable { + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.blendingMode = .withinWindow + view.material = .underWindowBackground + view.state = .active + view.isEmphasized = false + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} +} + +struct SidebarScrollViewResolver: NSViewRepresentable { + let onResolve: (NSScrollView?) -> Void + + func makeNSView(context: Context) -> SidebarScrollViewResolverView { + let view = SidebarScrollViewResolverView() + view.onResolve = onResolve + return view + } + + func updateNSView(_ nsView: SidebarScrollViewResolverView, context: Context) { + nsView.onResolve = onResolve + nsView.resolveScrollView() + } +} + +final class SidebarScrollViewResolverView: NSView { + var onResolve: ((NSScrollView?) -> Void)? + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + resolveScrollView() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + resolveScrollView() + } + + func resolveScrollView() { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + onResolve?(self.enclosingScrollView) + } + } +} + +struct SidebarEmptyArea: View { + @EnvironmentObject var tabManager: TabManager + let rowSpacing: CGFloat + @Binding var selection: SidebarSelection + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + let dragAutoScrollController: SidebarDragAutoScrollController + @Binding var draggedTabId: UUID? + @Binding var dropIndicator: SidebarDropIndicator? + + var body: some View { + Color.clear + .contentShape(Rectangle()) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onTapGesture(count: 2) { + tabManager.addWorkspace(placementOverride: .end) + if let selectedId = tabManager.selectedTabId { + selectedTabIds = [selectedId] + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } + selection = .tabs + } + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate( + targetTabId: nil, + tabManager: tabManager, + draggedTabId: $draggedTabId, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + targetRowHeight: nil, + dragAutoScrollController: dragAutoScrollController, + dropIndicator: $dropIndicator + )) + .overlay(alignment: .top) { + if shouldShowTopDropIndicator { + Rectangle() + .fill(programaAccentColor()) + .frame(height: 2) + .padding(.horizontal, 8) + .offset(y: -(rowSpacing / 2)) + } + } + } + + private var shouldShowTopDropIndicator: Bool { + guard draggedTabId != nil, let indicator = dropIndicator else { return false } + if indicator.tabId == nil { + return true + } + guard indicator.edge == .bottom, let lastTabId = tabManager.tabs.last?.id else { return false } + return indicator.tabId == lastTabId + } +} + +enum SidebarPathFormatter { + static let homeDirectoryPath: String = FileManager.default.homeDirectoryForCurrentUser.path + + static func shortenedPath( + _ path: String, + homeDirectoryPath: String = Self.homeDirectoryPath + ) -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return path } + if trimmed == homeDirectoryPath { + return "~" + } + if trimmed.hasPrefix(homeDirectoryPath + "/") { + return "~" + trimmed.dropFirst(homeDirectoryPath.count) + } + return trimmed + } +} + +enum SidebarWorkspaceShortcutHintMetrics { + private static let measurementFont = NSFont.systemFont(ofSize: 10, weight: .semibold) + private static let minimumSlotWidth: CGFloat = 28 + private static let horizontalPadding: CGFloat = 12 + private static let lock = NSLock() + private static var cachedHintWidths: [String: CGFloat] = [:] + #if DEBUG + private static var measurementCount = 0 + #endif + + static func slotWidth(label: String?, debugXOffset: Double) -> CGFloat { + guard let label else { return minimumSlotWidth } + let positiveDebugInset = max(0, CGFloat(ShortcutHintDebugSettings.clamped(debugXOffset))) + 2 + return max(minimumSlotWidth, hintWidth(for: label) + positiveDebugInset) + } + + static func hintWidth(for label: String) -> CGFloat { + lock.lock() + if let cached = cachedHintWidths[label] { + lock.unlock() + return cached + } + lock.unlock() + + let textWidth = (label as NSString).size(withAttributes: [.font: measurementFont]).width + let measuredWidth = ceil(textWidth) + horizontalPadding + + lock.lock() + cachedHintWidths[label] = measuredWidth + #if DEBUG + measurementCount += 1 + #endif + lock.unlock() + return measuredWidth + } + + #if DEBUG + static func resetCacheForTesting() { + lock.lock() + cachedHintWidths.removeAll() + measurementCount = 0 + lock.unlock() + } + + static func measurementCountForTesting() -> Int { + lock.lock() + let count = measurementCount + lock.unlock() + return count + } + #endif +} + +enum SidebarTrailingAccessoryWidthPolicy { + static let closeButtonWidth: CGFloat = 16 + + static func width( + canCloseWorkspace: Bool, + showsWorkspaceShortcutHint: Bool, + workspaceShortcutLabel: String?, + debugXOffset: Double + ) -> CGFloat { + if showsWorkspaceShortcutHint, let workspaceShortcutLabel { + return SidebarWorkspaceShortcutHintMetrics.slotWidth( + label: workspaceShortcutLabel, + debugXOffset: debugXOffset + ) + } + + return canCloseWorkspace ? closeButtonWidth : 0 + } +} + + +struct MiddleClickCapture: NSViewRepresentable { + let onMiddleClick: () -> Void + + func makeNSView(context: Context) -> MiddleClickCaptureView { + let view = MiddleClickCaptureView() + view.onMiddleClick = onMiddleClick + return view + } + + func updateNSView(_ nsView: MiddleClickCaptureView, context: Context) { + nsView.onMiddleClick = onMiddleClick + } +} + +final class MiddleClickCaptureView: NSView { + var onMiddleClick: (() -> Void)? + + override func hitTest(_ point: NSPoint) -> NSView? { + // Only intercept middle-click so left-click selection and right-click context menus + // continue to hit-test through to SwiftUI/AppKit normally. + guard let event = NSApp.currentEvent, + event.type == .otherMouseDown, + event.buttonNumber == 2 else { + return nil + } + return self + } + + override func otherMouseDown(with event: NSEvent) { + guard event.buttonNumber == 2 else { + super.otherMouseDown(with: event) + return + } + onMiddleClick?() + } +} + +enum SidebarSelection { + case tabs + case notifications +} + +struct ClearScrollBackground: ViewModifier { + func body(content: Content) -> some View { + content + .scrollContentBackground(.hidden) + .background(ScrollBackgroundClearer()) + } +} + +private struct ScrollBackgroundClearer: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + NSView() + } + + func updateNSView(_ nsView: NSView, context: Context) { + DispatchQueue.main.async { + guard let scrollView = findScrollView(startingAt: nsView) else { return } + // Clear all backgrounds and mark as non-opaque for transparency + scrollView.drawsBackground = false + scrollView.backgroundColor = .clear + scrollView.wantsLayer = true + scrollView.layer?.backgroundColor = NSColor.clear.cgColor + scrollView.layer?.isOpaque = false + + scrollView.contentView.drawsBackground = false + scrollView.contentView.backgroundColor = .clear + scrollView.contentView.wantsLayer = true + scrollView.contentView.layer?.backgroundColor = NSColor.clear.cgColor + scrollView.contentView.layer?.isOpaque = false + + if let docView = scrollView.documentView { + docView.wantsLayer = true + docView.layer?.backgroundColor = NSColor.clear.cgColor + docView.layer?.isOpaque = false + } + } + } + + private func findScrollView(startingAt view: NSView) -> NSScrollView? { + var current: NSView? = view + while let candidate = current { + if let scrollView = candidate as? NSScrollView { + return scrollView + } + current = candidate.superview + } + return nil + } +} + +struct DraggableFolderIcon: View { + let directory: String + + var body: some View { + DraggableFolderIconRepresentable(directory: directory) + .frame(width: 16, height: 16) + .safeHelp(String(localized: "sidebar.folderIcon.dragHint", defaultValue: "Drag to open in Finder or another app")) + .onTapGesture(count: 2) { + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directory) + } + } +} + +private struct DraggableFolderIconRepresentable: NSViewRepresentable { + let directory: String + + func makeNSView(context: Context) -> DraggableFolderNSView { + DraggableFolderNSView(directory: directory) + } + + func updateNSView(_ nsView: DraggableFolderNSView, context: Context) { + nsView.directory = directory + nsView.updateIcon() + } +} + +final class DraggableFolderNSView: NSView, NSDraggingSource { + private final class FolderIconImageView: NSImageView { + override var mouseDownCanMoveWindow: Bool { false } + } + + var directory: String + private var imageView: FolderIconImageView! + private var previousWindowMovableState: Bool? + private weak var suppressedWindow: NSWindow? + private var hasActiveDragSession = false + private var didArmWindowDragSuppression = false + + private func formatPoint(_ point: NSPoint) -> String { + String(format: "(%.1f,%.1f)", point.x, point.y) + } + + init(directory: String) { + self.directory = directory + super.init(frame: .zero) + setupImageView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: NSSize { + NSSize(width: 16, height: 16) + } + + override var mouseDownCanMoveWindow: Bool { false } + + private func setupImageView() { + imageView = FolderIconImageView() + imageView.imageScaling = .scaleProportionallyDown + imageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: trailingAnchor), + imageView.topAnchor.constraint(equalTo: topAnchor), + imageView.bottomAnchor.constraint(equalTo: bottomAnchor), + imageView.widthAnchor.constraint(equalToConstant: 16), + imageView.heightAnchor.constraint(equalToConstant: 16), + ]) + updateIcon() + } + + func updateIcon() { + let icon = NSWorkspace.shared.icon(forFile: directory) + icon.size = NSSize(width: 16, height: 16) + imageView.image = icon + } + + func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { + return context == .outsideApplication ? [.copy, .link] : .copy + } + + func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { + hasActiveDragSession = false + restoreWindowMovableStateIfNeeded() + #if DEBUG + let nowMovable = window.map { String($0.isMovable) } ?? "nil" + let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil" + dlog("folder.dragEnd dir=\(directory) operation=\(operation.rawValue) screen=\(formatPoint(screenPoint)) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)") + #endif + } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard bounds.contains(point) else { return nil } + let hit = super.hitTest(point) + #if DEBUG + let hitDesc = hit.map { String(describing: type(of: $0)) } ?? "nil" + let imageHit = (hit === imageView) + let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" + let nowMovable = window.map { String($0.isMovable) } ?? "nil" + dlog("folder.hitTest point=\(formatPoint(point)) hit=\(hitDesc) imageViewHit=\(imageHit) returning=DraggableFolderNSView wasMovable=\(wasMovable) nowMovable=\(nowMovable)") + #endif + return self + } + + override func mouseDown(with event: NSEvent) { + maybeDisableWindowDraggingEarly(trigger: "mouseDown") + hasActiveDragSession = false + #if DEBUG + let localPoint = convert(event.locationInWindow, from: nil) + let responderDesc = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" + let nowMovable = window.map { String($0.isMovable) } ?? "nil" + let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil" + 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) + + let iconImage = NSWorkspace.shared.icon(forFile: directory) + iconImage.size = NSSize(width: 32, height: 32) + draggingItem.setDraggingFrame(bounds, contents: iconImage) + + let session = beginDraggingSession(with: [draggingItem], event: event, source: self) + hasActiveDragSession = true + #if DEBUG + let itemCount = session.draggingPasteboard.pasteboardItems?.count ?? 0 + dlog("folder.dragStart dir=\(directory) pasteboardItems=\(itemCount)") + #endif + } + + override func mouseUp(with event: NSEvent) { + super.mouseUp(with: event) + // Always restore suppression on mouse-up; drag-session callbacks can be + // skipped for non-started drags, which would otherwise leave suppression stuck. + restoreWindowMovableStateIfNeeded() + } + + override func rightMouseDown(with event: NSEvent) { + let menu = buildPathMenu() + // Pop up menu at bottom-left of icon (like native proxy icon) + let menuLocation = NSPoint(x: 0, y: bounds.height) + menu.popUp(positioning: nil, at: menuLocation, in: self) + } + + private func buildPathMenu() -> NSMenu { + let menu = NSMenu() + let url = URL(fileURLWithPath: directory).standardized + var pathComponents: [URL] = [] + + // Build path from current directory up to root + var current = url + while current.path != "/" { + pathComponents.append(current) + current = current.deletingLastPathComponent() + } + pathComponents.append(URL(fileURLWithPath: "/")) + + // Add path components (current dir at top, root at bottom - matches native macOS) + for pathURL in pathComponents { + let icon = NSWorkspace.shared.icon(forFile: pathURL.path) + icon.size = NSSize(width: 16, height: 16) + + let displayName: String + if pathURL.path == "/" { + // Use the volume name for root + if let volumeName = try? URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeNameKey]).volumeName { + displayName = volumeName + } else { + displayName = String(localized: "sidebar.pathMenu.macintoshHD", defaultValue: "Macintosh HD") + } + } else { + displayName = FileManager.default.displayName(atPath: pathURL.path) + } + + let item = NSMenuItem(title: displayName, action: #selector(openPathComponent(_:)), keyEquivalent: "") + item.target = self + item.image = icon + item.representedObject = pathURL + menu.addItem(item) + } + + // Add computer name at the bottom (like native proxy icon) + let computerName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName + let computerIcon = NSImage(named: NSImage.computerName) ?? NSImage() + computerIcon.size = NSSize(width: 16, height: 16) + + let computerItem = NSMenuItem(title: computerName, action: #selector(openComputer(_:)), keyEquivalent: "") + computerItem.target = self + computerItem.image = computerIcon + menu.addItem(computerItem) + + return menu + } + + @objc private func openPathComponent(_ sender: NSMenuItem) { + guard let url = sender.representedObject as? URL else { return } + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: url.path) + } + + @objc private func openComputer(_ sender: NSMenuItem) { + // Open "Computer" view in Finder (shows all volumes) + NSWorkspace.shared.open(URL(fileURLWithPath: "/", isDirectory: true)) + } + + private func restoreWindowMovableStateIfNeeded() { + guard didArmWindowDragSuppression || previousWindowMovableState != nil else { return } + let targetWindow = suppressedWindow ?? window + let depthAfter = endWindowDragSuppression(window: targetWindow) + restoreWindowDragging(window: targetWindow, previousMovableState: previousWindowMovableState) + self.previousWindowMovableState = nil + self.suppressedWindow = nil + self.didArmWindowDragSuppression = false + #if DEBUG + let nowMovable = targetWindow.map { String($0.isMovable) } ?? "nil" + dlog("folder.dragSuppression restore depth=\(depthAfter) nowMovable=\(nowMovable)") + #endif + } + + private func maybeDisableWindowDraggingEarly(trigger: String) { + guard !didArmWindowDragSuppression else { return } + guard let eventType = NSApp.currentEvent?.type, + eventType == .leftMouseDown || eventType == .leftMouseDragged else { + return + } + guard let currentWindow = window else { return } + + didArmWindowDragSuppression = true + suppressedWindow = currentWindow + let suppressionDepth = beginWindowDragSuppression(window: currentWindow) ?? 0 + if currentWindow.isMovable { + previousWindowMovableState = temporarilyDisableWindowDragging(window: currentWindow) + } else { + previousWindowMovableState = nil + } + #if DEBUG + let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" + let nowMovable = String(currentWindow.isMovable) + dlog( + "folder.dragSuppression trigger=\(trigger) event=\(eventType) depth=\(suppressionDepth) wasMovable=\(wasMovable) nowMovable=\(nowMovable)" + ) + #endif + } +} + +func temporarilyDisableWindowDragging(window: NSWindow?) -> Bool? { + guard let window else { return nil } + let wasMovable = window.isMovable + if wasMovable { + window.isMovable = false + } + return wasMovable +} + +func restoreWindowDragging(window: NSWindow?, previousMovableState: Bool?) { + guard let window, let previousMovableState else { return } + window.isMovable = previousMovableState +} + +/// Wrapper view that tries NSGlassEffectView (macOS 26+) when available or requested +private struct SidebarVisualEffectBackground: NSViewRepresentable { + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + let state: NSVisualEffectView.State + let opacity: Double + let tintColor: NSColor? + let cornerRadius: CGFloat + let preferLiquidGlass: Bool + + init( + material: NSVisualEffectView.Material = .hudWindow, + blendingMode: NSVisualEffectView.BlendingMode = .behindWindow, + state: NSVisualEffectView.State = .active, + opacity: Double = 1.0, + tintColor: NSColor? = nil, + cornerRadius: CGFloat = 0, + preferLiquidGlass: Bool = false + ) { + self.material = material + self.blendingMode = blendingMode + self.state = state + self.opacity = opacity + self.tintColor = tintColor + self.cornerRadius = cornerRadius + self.preferLiquidGlass = preferLiquidGlass + } + + static var liquidGlassAvailable: Bool { + NSClassFromString("NSGlassEffectView") != nil + } + + func makeNSView(context: Context) -> NSView { + // Try NSGlassEffectView if preferred or if we want to test availability + if preferLiquidGlass, let glassClass = NSClassFromString("NSGlassEffectView") as? NSView.Type { + let glass = glassClass.init(frame: .zero) + glass.autoresizingMask = [.width, .height] + glass.wantsLayer = true + return glass + } + + // Use NSVisualEffectView + let view = NSVisualEffectView() + view.autoresizingMask = [.width, .height] + view.wantsLayer = true + view.layerContentsRedrawPolicy = .onSetNeedsDisplay + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + // Configure based on view type + if nsView.className == "NSGlassEffectView" { + // NSGlassEffectView configuration via private API + nsView.alphaValue = max(0.0, min(1.0, opacity)) + nsView.layer?.cornerRadius = cornerRadius + nsView.layer?.masksToBounds = cornerRadius > 0 + + // Try to set tint color via private selector + if let color = tintColor { + let selector = NSSelectorFromString("setTintColor:") + if nsView.responds(to: selector) { + nsView.perform(selector, with: color) + } + } + } else if let visualEffect = nsView as? NSVisualEffectView { + // NSVisualEffectView configuration + visualEffect.material = material + visualEffect.blendingMode = blendingMode + visualEffect.state = state + visualEffect.alphaValue = max(0.0, min(1.0, opacity)) + visualEffect.layer?.cornerRadius = cornerRadius + visualEffect.layer?.masksToBounds = cornerRadius > 0 + visualEffect.needsDisplay = true + } + } +} + +/// Reads the leading inset required to clear traffic lights + left titlebar accessories. +final class TitlebarLeadingInsetPassthroughView: NSView { + override var mouseDownCanMoveWindow: Bool { false } + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + +struct TitlebarLeadingInsetReader: NSViewRepresentable { + @Binding var inset: CGFloat + + func makeNSView(context: Context) -> NSView { + let view = TitlebarLeadingInsetPassthroughView() + view.setFrameSize(.zero) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + DispatchQueue.main.async { + guard let window = nsView.window else { return } + // 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 + } + leading += 0 + if leading != inset { + inset = leading + } + } + } +} + +/// 1px trailing border on the sidebar, derived from the terminal chrome background +/// using the same logic as bonsplit's TabBarColors.nsColorSeparator: +/// dark bg → lighten RGB by 0.16 at 0.36 alpha; light bg → darken by 0.12 at 0.26 alpha. +struct SidebarTrailingBorder: View { + @AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = false + @State private var separatorColor: NSColor = chromeSeparatorColor() + + var body: some View { + if matchTerminalBackground { + Rectangle() + .fill(Color(nsColor: separatorColor)) + .frame(width: 1) + .ignoresSafeArea() + .onAppear { + separatorColor = Self.chromeSeparatorColor() + } + .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in + separatorColor = Self.chromeSeparatorColor() + } + } + } + + /// Replicates bonsplit TabBarColors.nsColorSeparator derivation from chrome background. + private static func chromeSeparatorColor() -> NSColor { + let chrome = GhosttyBackgroundTheme.currentColor() + let srgb = chrome.usingColorSpace(.sRGB) ?? chrome + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + srgb.getRed(&r, green: &g, blue: &b, alpha: &a) + let luminance = 0.299 * r + 0.587 * g + 0.114 * b + let isLight = luminance > 0.5 + let amount: CGFloat = isLight ? -0.12 : 0.16 + let alpha: CGFloat = isLight ? 0.26 : 0.36 + return NSColor( + red: min(1.0, max(0.0, r + amount)), + green: min(1.0, max(0.0, g + amount)), + blue: min(1.0, max(0.0, b + amount)), + alpha: alpha + ) + } +} + +/// Sidebar background that uses the same technique as TitlebarLayerBackground: +/// fully opaque layer color + layer-level opacity. This matches how the terminal's +/// Metal surface composites its background. +private struct SidebarTerminalBackgroundView: NSViewRepresentable { + let backgroundColor: NSColor + let 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) + } +} + +struct SidebarBackdrop: View { + @AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = false + @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity + @AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex + @AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String? + @AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String? + @AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue + @AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue + @AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue + @AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0 + @AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 1.0 + @Environment(\.colorScheme) private var colorScheme + @State private var terminalBackgroundColor: NSColor = GhosttyBackgroundTheme.currentColor() + + var body: some View { + let cornerRadius = CGFloat(max(0, sidebarCornerRadius)) + + if matchTerminalBackground { + // The terminal background is provided by a single CALayer, so + // the sidebar uses the configured opacity directly. + let alpha = CGFloat(GhosttyApp.shared.defaultBackgroundOpacity) + return AnyView( + SidebarTerminalBackgroundView( + backgroundColor: GhosttyApp.shared.defaultBackgroundColor, + opacity: alpha + ) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in + terminalBackgroundColor = GhosttyBackgroundTheme.currentColor() + } + ) + } + + let materialOption = SidebarMaterialOption(rawValue: sidebarMaterial) + let blendingMode = SidebarBlendModeOption(rawValue: sidebarBlendMode)?.mode ?? .behindWindow + let state = SidebarStateOption(rawValue: sidebarState)?.state ?? .active + let resolvedHex: String = { + if colorScheme == .dark, let dark = sidebarTintHexDark { + return dark + } else if colorScheme == .light, let light = sidebarTintHexLight { + return light + } + return sidebarTintHex + }() + let tintColor = (NSColor(hex: resolvedHex) ?? NSColor(hex: sidebarTintHex) ?? .black).withAlphaComponent(sidebarTintOpacity) + let useLiquidGlass = materialOption?.usesLiquidGlass ?? false + let useWindowLevelGlass = useLiquidGlass && blendingMode == .behindWindow + + return AnyView( + ZStack { + if let material = materialOption?.material { + // When using liquidGlass + behindWindow, window handles glass + tint + // Sidebar is fully transparent + if !useWindowLevelGlass { + SidebarVisualEffectBackground( + material: material, + blendingMode: blendingMode, + state: state, + opacity: sidebarBlurOpacity, + tintColor: tintColor, + cornerRadius: cornerRadius, + preferLiquidGlass: useLiquidGlass + ) + // Tint overlay for NSVisualEffectView fallback + if !useLiquidGlass { + Color(nsColor: tintColor) + } + } + } + // When material is none or useWindowLevelGlass, render nothing + } + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + ) + } +} + +enum SidebarMaterialOption: String, CaseIterable, Identifiable { + case none + case liquidGlass // macOS 26+ NSGlassEffectView + case sidebar + case hudWindow + case menu + case popover + case underWindowBackground + case windowBackground + case contentBackground + case fullScreenUI + case sheet + case headerView + case toolTip + + var id: String { rawValue } + + var title: String { + switch self { + case .none: return String(localized: "settings.material.none", defaultValue: "None") + case .liquidGlass: return String(localized: "settings.material.liquidGlass", defaultValue: "Liquid Glass (macOS 26+)") + case .sidebar: return String(localized: "settings.material.sidebar", defaultValue: "Sidebar") + case .hudWindow: return String(localized: "settings.material.hudWindow", defaultValue: "HUD Window") + case .menu: return String(localized: "settings.material.menu", defaultValue: "Menu") + case .popover: return String(localized: "settings.material.popover", defaultValue: "Popover") + case .underWindowBackground: return String(localized: "settings.material.underWindow", defaultValue: "Under Window") + case .windowBackground: return String(localized: "settings.material.windowBackground", defaultValue: "Window Background") + case .contentBackground: return String(localized: "settings.material.contentBackground", defaultValue: "Content Background") + case .fullScreenUI: return String(localized: "settings.material.fullScreenUI", defaultValue: "Full Screen UI") + case .sheet: return String(localized: "settings.material.sheet", defaultValue: "Sheet") + case .headerView: return String(localized: "settings.material.headerView", defaultValue: "Header View") + case .toolTip: return String(localized: "settings.material.toolTip", defaultValue: "Tool Tip") + } + } + + /// Returns true if this option should use NSGlassEffectView (macOS 26+) + var usesLiquidGlass: Bool { + self == .liquidGlass + } + + var material: NSVisualEffectView.Material? { + switch self { + case .none: return nil + case .liquidGlass: return .underWindowBackground // Fallback material + case .sidebar: return .sidebar + case .hudWindow: return .hudWindow + case .menu: return .menu + case .popover: return .popover + case .underWindowBackground: return .underWindowBackground + case .windowBackground: return .windowBackground + case .contentBackground: return .contentBackground + case .fullScreenUI: return .fullScreenUI + case .sheet: return .sheet + case .headerView: return .headerView + case .toolTip: return .toolTip + } + } +} + +enum SidebarBlendModeOption: String, CaseIterable, Identifiable { + case behindWindow + case withinWindow + + var id: String { rawValue } + + var title: String { + switch self { + case .behindWindow: return String(localized: "settings.blendMode.behindWindow", defaultValue: "Behind Window") + case .withinWindow: return String(localized: "settings.blendMode.withinWindow", defaultValue: "Within Window") + } + } + + var mode: NSVisualEffectView.BlendingMode { + switch self { + case .behindWindow: return .behindWindow + case .withinWindow: return .withinWindow + } + } +} + +enum SidebarStateOption: String, CaseIterable, Identifiable { + case active + case inactive + case followWindow + + var id: String { rawValue } + + var title: String { + switch self { + case .active: return String(localized: "settings.state.active", defaultValue: "Active") + case .inactive: return String(localized: "settings.state.inactive", defaultValue: "Inactive") + case .followWindow: return String(localized: "settings.state.followWindow", defaultValue: "Follow Window") + } + } + + var state: NSVisualEffectView.State { + switch self { + case .active: return .active + case .inactive: return .inactive + case .followWindow: return .followsWindowActiveState + } + } +} + +enum SidebarTintDefaults { + static let hex = "#000000" + static let opacity = 0.18 +} + +enum SidebarPresetOption: String, CaseIterable, Identifiable { + case nativeSidebar + case glassBehind + case softBlur + case popoverGlass + case hudGlass + case underWindow + + var id: String { rawValue } + + var title: String { + switch self { + case .nativeSidebar: return String(localized: "settings.preset.nativeSidebar", defaultValue: "Native Sidebar") + case .glassBehind: return String(localized: "settings.preset.raycastGray", defaultValue: "Raycast Gray") + case .softBlur: return String(localized: "settings.preset.softBlur", defaultValue: "Soft Blur") + case .popoverGlass: return String(localized: "settings.preset.popoverGlass", defaultValue: "Popover Glass") + case .hudGlass: return String(localized: "settings.preset.hudGlass", defaultValue: "HUD Glass") + case .underWindow: return String(localized: "settings.preset.underWindow", defaultValue: "Under Window") + } + } + + var material: SidebarMaterialOption { + switch self { + case .nativeSidebar: return .sidebar + case .glassBehind: return .sidebar + case .softBlur: return .sidebar + case .popoverGlass: return .popover + case .hudGlass: return .hudWindow + case .underWindow: return .underWindowBackground + } + } + + var blendMode: SidebarBlendModeOption { + switch self { + case .nativeSidebar: return .withinWindow + case .glassBehind: return .behindWindow + case .softBlur: return .behindWindow + case .popoverGlass: return .behindWindow + case .hudGlass: return .withinWindow + case .underWindow: return .withinWindow + } + } + + var state: SidebarStateOption { + switch self { + case .nativeSidebar: return .followWindow + case .glassBehind: return .active + case .softBlur: return .active + case .popoverGlass: return .active + case .hudGlass: return .active + case .underWindow: return .followWindow + } + } + + var tintHex: String { + switch self { + case .nativeSidebar: return "#000000" + case .glassBehind: return "#000000" + case .softBlur: return "#000000" + case .popoverGlass: return "#000000" + case .hudGlass: return "#000000" + case .underWindow: return "#000000" + } + } + + var tintOpacity: Double { + switch self { + case .nativeSidebar: return 0.18 + case .glassBehind: return 0.36 + case .softBlur: return 0.28 + case .popoverGlass: return 0.10 + case .hudGlass: return 0.62 + case .underWindow: return 0.14 + } + } + + var cornerRadius: Double { + switch self { + case .nativeSidebar: return 0.0 + case .glassBehind: return 0.0 + case .softBlur: return 0.0 + case .popoverGlass: return 10.0 + case .hudGlass: return 10.0 + case .underWindow: return 6.0 + } + } + + var blurOpacity: Double { + switch self { + case .nativeSidebar: return 1.0 + case .glassBehind: return 0.6 + case .softBlur: return 0.45 + case .popoverGlass: return 0.9 + case .hudGlass: return 0.98 + case .underWindow: return 0.9 + } + } +} + +extension NSColor { + func hexString(includeAlpha: Bool = false) -> String { + let color = usingColorSpace(.sRGB) ?? self + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + let redByte = min(255, max(0, Int((red * 255).rounded()))) + let greenByte = min(255, max(0, Int((green * 255).rounded()))) + let blueByte = min(255, max(0, Int((blue * 255).rounded()))) + if includeAlpha { + let alphaByte = min(255, max(0, Int((alpha * 255).rounded()))) + return String(format: "#%02X%02X%02X%02X", redByte, greenByte, blueByte, alphaByte) + } + return String(format: "#%02X%02X%02X", redByte, greenByte, blueByte) + } +} diff --git a/Sources/SurfacePool.swift b/Sources/SurfacePool.swift deleted file mode 100644 index 3c29fadb4c6..00000000000 --- a/Sources/SurfacePool.swift +++ /dev/null @@ -1,264 +0,0 @@ -import AppKit -import Bonsplit -import Foundation - -/// Maintains a small pool of pre-warmed terminal surfaces so new tabs open instantly. -/// -/// Each pooled surface lives in a hidden offscreen `NSWindow` with a running shell process. -/// When `TabManager.addWorkspace()` needs a new terminal, it claims a pooled surface instead -/// of cold-starting one, eliminating the visible blank-screen delay. -@MainActor -final class SurfacePool { - static let shared = SurfacePool() - - // MARK: - Configuration - - private let maxPoolSize = 1 - - // MARK: - State - - enum EntryState { - case warming - case ready - } - - struct PoolEntry { - let surface: TerminalSurface - let offscreenWindow: NSWindow - var state: EntryState - let createdAt: Date - } - - /// Keyed by surface.id - private var entries: [UUID: PoolEntry] = [:] - - /// Whether the pool is enabled. Reads user default, defaulting to false. - /// Default-off: claiming races the pre-warmed shell — `patchClaimedSurface` types - /// into the pty before the shell reaches a prompt, and the trailing - /// `clear && printf '\e[3J'` can wipe the screen with no prompt redraw, - /// leaving new tabs blank. Re-enable only once claim waits for a shell-ready signal. - var isEnabled: Bool { - UserDefaults.standard.object(forKey: "programaSurfacePoolEnabled") as? Bool ?? false - } - - #if DEBUG - private(set) var claimCount = 0 - private(set) var missCount = 0 - #endif - - private init() {} - - // MARK: - Public API - - /// Pre-warm a surface if the pool isn't full. - func warmIfNeeded() { - guard isEnabled else { return } - guard GhosttyApp.shared.app != nil else { return } - guard entries.count < maxPoolSize else { return } - -#if DEBUG - dlog("pool.warm.start count=\(entries.count) max=\(maxPoolSize)") -#endif - - let entry = createPoolEntry() - entries[entry.surface.id] = entry - -#if DEBUG - dlog("pool.warm.done surface=\(entry.surface.id.uuidString.prefix(8)) count=\(entries.count)") -#endif - } - - /// Claim a pre-warmed surface for use in a real workspace. - /// - /// Returns `nil` if the pool is empty, not ready, or incompatible with the request. - /// The caller should fall back to the normal cold-start path. - func claim( - workspaceId: UUID, - portOrdinal: Int, - workingDirectory: String?, - configTemplate: ProgramaSurfaceConfigTemplate? - ) -> ClaimedSurface? { - guard isEnabled else { return nil } - - // Skip pool for non-default font sizes — the surface would need a resize - if let template = configTemplate, template.fontSize > 0 { - // fontSize 0 means "use default", which is what the pool uses. - // Skip pool if a non-default size was explicitly requested. -#if DEBUG - dlog("pool.claim.skip reason=fontSizeMismatch requested=\(template.fontSize)") - missCount += 1 -#endif - return nil - } - - guard let entry = entries.first(where: { $0.value.state == .ready }) else { -#if DEBUG - dlog("pool.claim.miss count=\(entries.count) warming=\(entries.values.filter { $0.state == .warming }.count)") - missCount += 1 -#endif - return nil - } - - let poolEntry = entry.value - entries.removeValue(forKey: entry.key) - -#if DEBUG - let warmMs = Date().timeIntervalSince(poolEntry.createdAt) * 1000 - dlog("pool.claim surface=\(poolEntry.surface.id.uuidString.prefix(8)) warmMs=\(String(format: "%.0f", warmMs))") - claimCount += 1 -#endif - - // Detach from offscreen window - poolEntry.surface.hostedView.removeFromSuperview() - poolEntry.offscreenWindow.contentView = nil - poolEntry.offscreenWindow.orderOut(nil) - - // Update workspace identity - poolEntry.surface.updateWorkspaceId(workspaceId) - - // Patch environment variables and working directory via pty - patchClaimedSurface( - poolEntry.surface, - workspaceId: workspaceId, - portOrdinal: portOrdinal, - workingDirectory: workingDirectory - ) - - // Replenish the pool on the next tick - DispatchQueue.main.async { [weak self] in - self?.warmIfNeeded() - } - - return ClaimedSurface( - surface: poolEntry.surface, - panel: TerminalPanel(workspaceId: workspaceId, surface: poolEntry.surface) - ) - } - - /// Tear down all pooled surfaces (app termination, memory pressure, etc.) - func teardownAll() { -#if DEBUG - dlog("pool.teardown count=\(entries.count)") -#endif - for (_, entry) in entries { - entry.surface.hostedView.removeFromSuperview() - entry.offscreenWindow.contentView = nil - entry.offscreenWindow.orderOut(nil) - entry.offscreenWindow.close() - } - entries.removeAll() - } - - // MARK: - Types - - struct ClaimedSurface { - let surface: TerminalSurface - let panel: TerminalPanel - } - - // MARK: - Private - - private func createPoolEntry() -> PoolEntry { - // Create surface with placeholder workspace ID — patched on claim - let placeholderWorkspaceId = UUID() - let surface = TerminalSurface( - tabId: placeholderWorkspaceId, - context: GHOSTTY_SURFACE_CONTEXT_TAB, - configTemplate: nil - ) - - // Create a hidden offscreen window to host the surface. - // ghostty_surface_new requires view.window != nil to trigger. - let offscreenWindow = NSWindow( - contentRect: NSRect(x: -10000, y: -10000, width: 800, height: 600), - styleMask: [.borderless], - backing: .buffered, - defer: false - ) - offscreenWindow.isReleasedWhenClosed = false - offscreenWindow.contentView = surface.hostedView - - // Moving the hosted view into the window triggers viewDidMoveToWindow - // on the GhosttyNSView, which calls attachToView → createSurface. - // However, the portal system's viewDidMoveToWindow also fires. We need - // the surface to be created but NOT registered with any portal. - // The hostedView is a GhosttySurfaceScrollView containing the GhosttyNSView. - // Setting it as contentView puts it in the window hierarchy, satisfying - // the view.window != nil guard in attachToView → createSurface. - - // Mark as ready once the surface exists (created synchronously in attachToView). - // If the surface wasn't created (e.g. Ghostty app not ready), stay in warming. - let state: EntryState = surface.surface != nil ? .ready : .warming - - if state == .warming { - // Surface creation was deferred. Try triggering it explicitly. - surface.requestBackgroundSurfaceStartIfNeeded() - - // Check again after a tick - DispatchQueue.main.async { [weak self] in - guard let self else { return } - guard var entry = self.entries[surface.id] else { return } - if surface.surface != nil && entry.state == .warming { - entry.state = .ready - self.entries[surface.id] = entry -#if DEBUG - let warmMs = Date().timeIntervalSince(entry.createdAt) * 1000 - dlog("pool.entry.ready surface=\(surface.id.uuidString.prefix(8)) warmMs=\(String(format: "%.0f", warmMs))") -#endif - } - } - } - -#if DEBUG - dlog("pool.entry.create surface=\(surface.id.uuidString.prefix(8)) state=\(state) hasSurface=\(surface.surface != nil)") -#endif - - return PoolEntry( - surface: surface, - offscreenWindow: offscreenWindow, - state: state, - createdAt: Date() - ) - } - - /// Inject updated environment variables and cd to the target directory via pty input. - private func patchClaimedSurface( - _ surface: TerminalSurface, - workspaceId: UUID, - portOrdinal: Int, - workingDirectory: String? - ) { - var commands: [String] = [] - - // Update per-surface/workspace env vars - commands.append("export PROGRAMA_SURFACE_ID=\(surface.id.uuidString)") - commands.append("export PROGRAMA_PANEL_ID=\(surface.id.uuidString)") - commands.append("export PROGRAMA_WORKSPACE_ID=\(workspaceId.uuidString)") - commands.append("export PROGRAMA_TAB_ID=\(workspaceId.uuidString)") - - // Update port range - let portBase = TerminalSurface.sessionPortBase - let portRange = TerminalSurface.sessionPortRangeSize - let startPort = portBase + portOrdinal * portRange - commands.append("export PROGRAMA_PORT=\(startPort)") - commands.append("export PROGRAMA_PORT_END=\(startPort + portRange - 1)") - - // cd to target directory if different from home - let homeDir = FileManager.default.homeDirectoryForCurrentUser.path - if let dir = workingDirectory, !dir.isEmpty, dir != homeDir { - commands.append("cd \(shellQuote(dir))") - } - - // Clear screen + scrollback to hide the pre-warm prompt/zshrc output - commands.append("clear && printf '\\e[3J'") - - let combined = commands.joined(separator: "; ") - surface.sendInput(combined + "\n") - } - - /// Shell-quote a string for safe interpolation into a shell command. - private func shellQuote(_ s: String) -> String { - "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" - } -} - diff --git a/Sources/TabItemView.swift b/Sources/TabItemView.swift new file mode 100644 index 00000000000..3194f78a2fc --- /dev/null +++ b/Sources/TabItemView.swift @@ -0,0 +1,1965 @@ +// TabItemView + its exclusive helper-view cluster, extracted from ContentView.swift (nuclear-review #94.2). +// Pure move — behavior-identical relocation. Do NOT touch TabItemView's Equatable +// conformance, its precomputed let parameters, or the .equatable() call site in +// VerticalTabsSidebar (ContentView.swift) — see CLAUDE.md typing-latency-sensitive paths. +// +// Access-level widening: TabItemView was `private struct` (file-private to the old +// ContentView.swift); widened to internal (default) so VerticalTabsSidebar, which stays +// in ContentView.swift, can still construct it. No other behavior change. + +import AppKit +import Bonsplit +import Combine +import SwiftUI + + +// PERF: TabItemView is Equatable so SwiftUI skips body re-evaluation when +// the parent rebuilds with unchanged values. Without this, every TabManager +// or NotificationStore publish causes ALL tab items to re-evaluate (~18% of +// main thread during typing). If you add new properties, update == below. +// Reactive workspace state inside the row must not rely on parent diffs alone: +// `.equatable()` can otherwise leave sidebar badges/details stale until an +// unrelated parent change sneaks through. Keep the workspace reference plain +// and bridge only sidebar-visible workspace changes into local state. +// Do NOT add @EnvironmentObject or new @Binding without updating ==. +// Do NOT remove .equatable() from the ForEach call site in VerticalTabsSidebar. +struct TabItemView: View, Equatable { + private static let workspaceObservationCoalesceInterval: RunLoop.SchedulerTimeType.Stride = .milliseconds(40) + + // Closures, Bindings, and object references are excluded from == + // because they're recreated every parent eval but don't affect rendering. + nonisolated static func == (lhs: TabItemView, rhs: TabItemView) -> Bool { + lhs.tab === rhs.tab && + lhs.index == rhs.index && + lhs.isActive == rhs.isActive && + lhs.workspaceShortcutDigit == rhs.workspaceShortcutDigit && + lhs.workspaceShortcutModifierSymbol == rhs.workspaceShortcutModifierSymbol && + lhs.canCloseWorkspace == rhs.canCloseWorkspace && + lhs.accessibilityWorkspaceCount == rhs.accessibilityWorkspaceCount && + lhs.unreadCount == rhs.unreadCount && + lhs.latestNotificationText == rhs.latestNotificationText && + lhs.rowSpacing == rhs.rowSpacing && + lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints && + lhs.contextMenuWorkspaceIds == rhs.contextMenuWorkspaceIds && + lhs.remoteContextMenuWorkspaceIds == rhs.remoteContextMenuWorkspaceIds && + lhs.allRemoteContextMenuTargetsConnecting == rhs.allRemoteContextMenuTargetsConnecting && + lhs.allRemoteContextMenuTargetsDisconnected == rhs.allRemoteContextMenuTargetsDisconnected && + lhs.settings == rhs.settings + } + + // Use plain references instead of @EnvironmentObject to avoid subscribing + // to ALL changes on these objects. Body reads use precomputed parameters; + // action handlers use the plain references without triggering re-evaluation. + let tabManager: TabManager + let notificationStore: TerminalNotificationStore + @Environment(\.colorScheme) private var colorScheme + let tab: Tab + let index: Int + let isActive: Bool + let workspaceShortcutDigit: Int? + let workspaceShortcutModifierSymbol: String + let canCloseWorkspace: Bool + let accessibilityWorkspaceCount: Int + let unreadCount: Int + let latestNotificationText: String? + let rowSpacing: CGFloat + let setSelectionToTabs: () -> Void + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + let showsModifierShortcutHints: Bool + let dragAutoScrollController: SidebarDragAutoScrollController + @Binding var draggedTabId: UUID? + @Binding var dropIndicator: SidebarDropIndicator? + let contextMenuWorkspaceIds: [UUID] + let remoteContextMenuWorkspaceIds: [UUID] + let allRemoteContextMenuTargetsConnecting: Bool + let allRemoteContextMenuTargetsDisconnected: Bool + let settings: SidebarTabItemSettingsSnapshot + @State private var workspaceObservationGeneration: UInt64 = 0 + @State private var isHovering = false + @State private var rowHeight: CGFloat = 1 + // Cached results of the expensive bonsplit tree walk + branch/dir/PR snapshot. + // Updated only by the debounced publisher, onAppear, and settings changes — + // NOT by the immediate publisher (title keystrokes). This prevents the tree + // walk from running on every keystroke in single-panel workspaces. + @State private var cachedOrderedPanelIds: [UUID]? = nil + @State private var cachedBranchDirectoryLines: [VerticalBranchDirectoryLine] = [] + @State private var cachedPullRequestRows: [PullRequestDisplay] = [] + + var isMultiSelected: Bool { + selectedTabIds.contains(tab.id) + } + + private var isBeingDragged: Bool { + draggedTabId == tab.id + } + + private var sidebarShortcutHintXOffset: Double { + settings.sidebarShortcutHintXOffset + } + + private var sidebarShortcutHintYOffset: Double { + settings.sidebarShortcutHintYOffset + } + + private var alwaysShowShortcutHints: Bool { + settings.alwaysShowShortcutHints + } + + private var sidebarShowGitBranch: Bool { + settings.showsGitBranch + } + + private var sidebarBranchVerticalLayout: Bool { + settings.usesVerticalBranchLayout + } + + private var sidebarShowGitBranchIcon: Bool { + settings.showsGitBranchIcon + } + + private var sidebarShowSSH: Bool { + settings.showsSSH + } + + private var activeTabIndicatorStyle: SidebarActiveTabIndicatorStyle { + settings.activeTabIndicatorStyle + } + + private var sidebarSelectionColorHex: String? { + settings.selectionColorHex + } + + private var sidebarNotificationBadgeColorHex: String? { + settings.notificationBadgeColorHex + } + + private var openSidebarPullRequestLinksInProgramaBrowser: Bool { + settings.openPullRequestLinksInProgramaBrowser + } + + private var openSidebarPortLinksInProgramaBrowser: Bool { + settings.openPortLinksInProgramaBrowser + } + + private var titleFontWeight: Font.Weight { + .semibold + } + + private var showsLeadingRail: Bool { + explicitRailColor != nil + } + + private var activeBorderLineWidth: CGFloat { + switch activeTabIndicatorStyle { + case .leftRail: + return 0 + case .solidFill: + return isActive ? 1.5 : 0 + } + } + + private var activeBorderColor: Color { + guard isActive else { return .clear } + switch activeTabIndicatorStyle { + case .leftRail: + return .clear + case .solidFill: + return Color.primary.opacity(0.5) + } + } + + private var usesInvertedActiveForeground: Bool { + isActive + } + + private var activePrimaryTextColor: Color { + usesInvertedActiveForeground + ? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 1.0)) + : .primary + } + + private func activeSecondaryColor(_ opacity: Double = 0.75) -> Color { + usesInvertedActiveForeground + ? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat(opacity))) + : .secondary + } + + private var activeUnreadBadgeFillColor: Color { + if let hex = sidebarNotificationBadgeColorHex, let nsColor = NSColor(hex: hex) { + return Color(nsColor: nsColor) + } + return usesInvertedActiveForeground ? Color.white.opacity(0.25) : programaAccentColor() + } + + private var activeProgressTrackColor: Color { + usesInvertedActiveForeground ? Color.white.opacity(0.15) : Color.secondary.opacity(0.2) + } + + private var activeProgressFillColor: Color { + usesInvertedActiveForeground ? Color.white.opacity(0.8) : programaAccentColor() + } + + private var shortcutHintEmphasis: Double { + usesInvertedActiveForeground ? 1.0 : 0.9 + } + + private var showCloseButton: Bool { + isHovering && canCloseWorkspace && !(showsModifierShortcutHints || alwaysShowShortcutHints) + } + + private var workspaceShortcutLabel: String? { + guard let workspaceShortcutDigit else { return nil } + return "\(workspaceShortcutModifierSymbol)\(workspaceShortcutDigit)" + } + + private var showsWorkspaceShortcutHint: Bool { + (showsModifierShortcutHints || alwaysShowShortcutHints) && workspaceShortcutLabel != nil + } + + private var trailingAccessoryWidth: CGFloat { + SidebarTrailingAccessoryWidthPolicy.width( + canCloseWorkspace: canCloseWorkspace, + showsWorkspaceShortcutHint: showsWorkspaceShortcutHint, + workspaceShortcutLabel: workspaceShortcutLabel, + debugXOffset: sidebarShortcutHintXOffset + ) + } + + private var remoteWorkspaceSidebarText: String? { + guard tab.hasActiveRemoteTerminalSessions else { return nil } + let trimmedTarget = tab.remoteDisplayTarget?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmedTarget, !trimmedTarget.isEmpty { + return trimmedTarget + } + return String(localized: "sidebar.remote.subtitleFallback", defaultValue: "SSH workspace") + } + + private var copyableSidebarSSHError: String? { + let fallbackTarget = tab.remoteDisplayTarget ?? String( + localized: "sidebar.remote.help.targetFallback", + defaultValue: "remote host" + ) + let trimmedDetail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) + if tab.remoteConnectionState == .error, let trimmedDetail, !trimmedDetail.isEmpty { + let entry = SidebarRemoteErrorCopyEntry( + workspaceTitle: tab.title, + target: fallbackTarget, + detail: trimmedDetail + ) + return SidebarRemoteErrorCopySupport.clipboardText(for: [entry]) + } + if let statusValue = tab.statusEntries["remote.error"]?.value + .trimmingCharacters(in: .whitespacesAndNewlines), + !statusValue.isEmpty { + let entry = SidebarRemoteErrorCopyEntry( + workspaceTitle: tab.title, + target: fallbackTarget, + detail: statusValue + ) + return SidebarRemoteErrorCopySupport.clipboardText(for: [entry]) + } + return nil + } + + private var remoteConnectionStatusText: String { + switch tab.remoteConnectionState { + case .connected: + return String(localized: "remote.status.connected", defaultValue: "Connected") + case .connecting: + return String(localized: "remote.status.connecting", defaultValue: "Connecting") + case .error: + return String(localized: "remote.status.error", defaultValue: "Error") + case .disconnected: + return String(localized: "remote.status.disconnected", defaultValue: "Disconnected") + } + } + + @ViewBuilder + private var remoteWorkspaceSection: some View { + if sidebarShowSSH, let remoteWorkspaceSidebarText { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(remoteWorkspaceSidebarText) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.8)) + .lineLimit(1) + .truncationMode(.middle) + + Spacer(minLength: 0) + + Text(remoteConnectionStatusText) + .font(.system(size: 9, weight: .medium)) + .foregroundColor(activeSecondaryColor(0.58)) + .lineLimit(1) + } + } + .padding(.top, latestNotificationText == nil ? 1 : 2) + .safeHelp(remoteStateHelpText) + } + } + + private func copyTextToPasteboard(_ text: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + } + + private var visibleAuxiliaryDetails: SidebarWorkspaceAuxiliaryDetailVisibility { + settings.visibleAuxiliaryDetails + } + + var body: some View { + 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 = 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.") + let moveUpActionText = String(localized: "sidebar.workspace.moveUpAction", defaultValue: "Move Up") + let moveDownActionText = String(localized: "sidebar.workspace.moveDownAction", defaultValue: "Move Down") + let latestNotificationSubtitle = latestNotificationText + let effectiveSubtitle = latestNotificationSubtitle + let detailVisibility = visibleAuxiliaryDetails + // Read from cache — updated only by the debounced publisher, onAppear, + // and settings changes. Title-keystroke re-renders skip this tree walk. + let orderedPanelIds: [UUID]? = cachedOrderedPanelIds + let branchDirectoryLines: [VerticalBranchDirectoryLine] = cachedBranchDirectoryLines + let pullRequestRows: [PullRequestDisplay] = cachedPullRequestRows + 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 branchLinesContainBranch = sidebarShowGitBranch && branchDirectoryLines.contains { $0.branch != nil } + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + if unreadCount > 0 { + ZStack { + Circle() + .fill(activeUnreadBadgeFillColor) + Text("\(unreadCount)") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.white) + } + .frame(width: 16, height: 16) + } + + if tab.isPinned { + Image(systemName: "pin.fill") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(activeSecondaryColor(0.8)) + .safeHelp(protectedWorkspaceTooltip) + } + + Text(tab.title) + .font(.system(size: 12.5, weight: titleFontWeight)) + .foregroundColor(activePrimaryTextColor) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) + + Spacer(minLength: 0) + + ZStack(alignment: .trailing) { + Button(action: { + #if DEBUG + dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=button") + #endif + tabManager.closeWorkspaceWithConfirmation(tab) + }) { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .medium)) + .foregroundColor(activeSecondaryColor(0.7)) + } + .buttonStyle(.plain) + .safeHelp(closeButtonTooltip) + .frame(width: SidebarTrailingAccessoryWidthPolicy.closeButtonWidth, height: 16, alignment: .center) + .opacity(showCloseButton && !showsWorkspaceShortcutHint ? 1 : 0) + .allowsHitTesting(showCloseButton && !showsWorkspaceShortcutHint) + + if showsWorkspaceShortcutHint, let workspaceShortcutLabel { + Text(workspaceShortcutLabel) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundColor(activePrimaryTextColor) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(ShortcutHintPillBackground(emphasis: shortcutHintEmphasis)) + .offset( + x: ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset), + y: ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset) + ) + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.14), value: showsModifierShortcutHints || alwaysShowShortcutHints) + .frame(width: trailingAccessoryWidth, height: 16, alignment: .trailing) + } + + if let description = tab.customDescription { + SidebarWorkspaceDescriptionText( + markdown: description, + isActive: usesInvertedActiveForeground + ) + .id(description) + } + + if let subtitle = effectiveSubtitle { + Text(subtitle) + .font(.system(size: 10)) + .foregroundColor(activeSecondaryColor(0.8)) + .lineLimit(2) + .truncationMode(.tail) + .multilineTextAlignment(.leading) + } + + remoteWorkspaceSection + + if detailVisibility.showsMetadata { + let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder() + let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder() + if !metadataEntries.isEmpty { + SidebarMetadataRows( + entries: metadataEntries, + isActive: usesInvertedActiveForeground, + onFocus: { updateSelection() } + ) + .transition(.opacity.combined(with: .move(edge: .top))) + } + if !metadataBlocks.isEmpty { + SidebarMetadataMarkdownBlocks( + blocks: metadataBlocks, + isActive: usesInvertedActiveForeground, + onFocus: { updateSelection() } + ) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + + // Latest log entry + if detailVisibility.showsLog, let latestLog = tab.logEntries.last { + HStack(spacing: 4) { + Image(systemName: logLevelIcon(latestLog.level)) + .font(.system(size: 8)) + .foregroundColor(logLevelColor(latestLog.level, isActive: usesInvertedActiveForeground)) + Text(latestLog.message) + .font(.system(size: 10)) + .foregroundColor(activeSecondaryColor(0.8)) + .lineLimit(1) + .truncationMode(.tail) + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + + // Progress bar + if detailVisibility.showsProgress, let progress = tab.progress { + VStack(alignment: .leading, spacing: 2) { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(activeProgressTrackColor) + Capsule() + .fill(activeProgressFillColor) + .frame(width: max(0, geo.size.width * CGFloat(progress.value))) + } + } + .frame(height: 3) + + if let label = progress.label { + Text(label) + .font(.system(size: 9)) + .foregroundColor(activeSecondaryColor(0.6)) + .lineLimit(1) + } + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + + // Branch + directory row + if detailVisibility.showsBranchDirectory { + if sidebarBranchVerticalLayout { + if !branchDirectoryLines.isEmpty { + HStack(alignment: .top, spacing: 3) { + if sidebarShowGitBranchIcon, branchLinesContainBranch { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 9)) + .foregroundColor(activeSecondaryColor(0.6)) + } + VStack(alignment: .leading, spacing: 1) { + ForEach(Array(branchDirectoryLines.enumerated()), id: \.offset) { _, line in + HStack(spacing: 3) { + if let branch = line.branch { + Text(branch) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.75)) + .lineLimit(1) + .truncationMode(.tail) + } + if line.branch != nil, line.directory != nil { + Image(systemName: "circle.fill") + .font(.system(size: 3)) + .foregroundColor(activeSecondaryColor(0.6)) + .padding(.horizontal, 1) + } + if let directory = line.directory { + Text(directory) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.75)) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + } + } + } + } else if let dirRow = compactBranchDirectoryRow { + HStack(spacing: 3) { + if sidebarShowGitBranchIcon, compactGitBranchSummaryText != nil { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 9)) + .foregroundColor(activeSecondaryColor(0.6)) + } + Text(dirRow) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.75)) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + + // Pull request rows + if detailVisibility.showsPullRequests, !pullRequestRows.isEmpty { + VStack(alignment: .leading, spacing: 1) { + ForEach(pullRequestRows) { pullRequest in + Button(action: { + openPullRequestLink(pullRequest.url) + }) { + HStack(spacing: 4) { + PullRequestStatusIcon( + status: pullRequest.status, + color: pullRequestForegroundColor + ) + Text("\(pullRequest.label) #\(pullRequest.number)") + .underline() + .lineLimit(1) + .truncationMode(.tail) + Text(pullRequestStatusLabel(pullRequest.status, checks: pullRequest.checks)) + .lineLimit(1) + Spacer(minLength: 0) + } + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(pullRequestForegroundColor) + } + .buttonStyle(.plain) + .safeHelp(String(localized: "sidebar.pullRequest.openTooltip", defaultValue: "Open \(pullRequest.label) #\(pullRequest.number)")) + } + } + } + + // Ports row + if detailVisibility.showsPorts, !tab.listeningPorts.isEmpty { + HStack(spacing: 4) { + ForEach(tab.listeningPorts, id: \.self) { port in + Button(action: { + openPortLink(port) + }) { + Text(String(localized: "sidebar.port.label", defaultValue: ":\(port)")) + .underline() + } + .buttonStyle(.plain) + .safeHelp(String(localized: "sidebar.port.openTooltip", defaultValue: "Open localhost:\(port)")) + } + Spacer(minLength: 0) + } + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.75)) + .lineLimit(1) + } + } + .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( + RoundedRectangle(cornerRadius: 6) + .fill(backgroundColor) + .overlay { + RoundedRectangle(cornerRadius: 6) + .strokeBorder(activeBorderColor, lineWidth: activeBorderLineWidth) + } + .overlay(alignment: .leading) { + if showsLeadingRail { + Capsule(style: .continuous) + .fill(railColor) + .frame(width: 3) + .padding(.leading, 4) + .padding(.vertical, 5) + .offset(x: -1) + } + } + ) + .padding(.horizontal, 6) + .background { + GeometryReader { proxy in + Color.clear + .onAppear { + rowHeight = max(proxy.size.height, 1) + } + .onChange(of: proxy.size.height) { newHeight in + rowHeight = max(newHeight, 1) + } + } + } + .contentShape(Rectangle()) + .opacity(isBeingDragged ? 0.6 : 1) + .overlay { + MiddleClickCapture { + #if DEBUG + dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=middleClick") + #endif + tabManager.closeWorkspaceWithConfirmation(tab) + } + } + .overlay(alignment: .top) { + if showsCenteredTopDropIndicator { + Rectangle() + .fill(programaAccentColor()) + .frame(height: 2) + .padding(.horizontal, 8) + .offset(y: index == 0 ? 0 : -(rowSpacing / 2)) + } + } + .onReceive( + tab.sidebarImmediateObservationPublisher + .receive(on: RunLoop.main) + ) { _ in +#if DEBUG + let description = tab.customDescription ?? "" + dlog( + "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + + "source=immediate " + + "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + + "descLen=\((description as NSString).length) " + + "desc=\"\(debugCommandPaletteTextPreview(description))\"" + ) +#endif + workspaceObservationGeneration &+= 1 + } + .onReceive( + tab.sidebarObservationPublisher + .receive(on: RunLoop.main) + // Prompt-time sidebar telemetry can arrive as a short burst + // (pwd, branch, PR, shell state). Coalesce that burst so the + // row redraws once with the settled state instead of blinking. + .debounce(for: Self.workspaceObservationCoalesceInterval, scheduler: RunLoop.main) + ) { _ in +#if DEBUG + let description = tab.customDescription ?? "" + dlog( + "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + + "source=debounced " + + "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + + "descLen=\((description as NSString).length) " + + "desc=\"\(debugCommandPaletteTextPreview(description))\"" + ) +#endif + // Refresh expensive caches (tree walk, branch/dir/PR) before + // signalling a redraw. The immediate publisher intentionally does + // NOT call this so that title keystrokes skip the tree walk. + recomputeSidebarDetailCache() + workspaceObservationGeneration &+= 1 + } + .onDrag { + #if DEBUG + dlog("sidebar.onDrag tab=\(tab.id.uuidString.prefix(5))") + #endif + draggedTabId = tab.id + dropIndicator = nil + return SidebarTabDragPayload.provider(for: tab.id) + } + .internalOnlyTabDrag() + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate( + targetTabId: tab.id, + tabManager: tabManager, + draggedTabId: $draggedTabId, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + targetRowHeight: rowHeight, + dragAutoScrollController: dragAutoScrollController, + dropIndicator: $dropIndicator + )) + .onDrop(of: BonsplitTabDragPayload.dropContentTypes, delegate: SidebarBonsplitTabDropDelegate( + targetWorkspaceId: tab.id, + tabManager: tabManager, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex + )) + .onTapGesture { + updateSelection() + } + .onHover { hovering in + isHovering = hovering + } + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(accessibilityTitle)) + .accessibilityHint(Text(accessibilityHintText)) + .accessibilityAction(named: Text(moveUpActionText)) { + moveBy(-1) + } + .accessibilityAction(named: Text(moveDownActionText)) { + moveBy(1) + } + .onAppear { + // Prime the cache so branch/dir/PR rows appear immediately, + // before the first debounced publisher fires. + recomputeSidebarDetailCache() + } + .onChange(of: visibleAuxiliaryDetails) { _ in + // Toggling branch/PR columns changes which data we need to cache. + recomputeSidebarDetailCache() + } + .contextMenu { workspaceContextMenu } + } + + private func contextMenuLabel(multi: String, single: String, isMulti: Bool) -> String { + isMulti ? multi : single + } + + private func remoteContextMenuWorkspaces() -> [Workspace] { + guard !remoteContextMenuWorkspaceIds.isEmpty else { return [] } + return remoteContextMenuWorkspaceIds.compactMap { workspaceId in + tabManager.tabs.first(where: { $0.id == workspaceId }) + } + } + + @ViewBuilder + private var workspaceContextMenu: some View { + let targetIds = contextMenuWorkspaceIds + let isMulti = targetIds.count > 1 + let tabColorPalette = WorkspaceTabColorSettings.palette() + let shouldPin = !tab.isPinned + let reconnectLabel = contextMenuLabel( + multi: String(localized: "contextMenu.reconnectWorkspaces", defaultValue: "Reconnect Workspaces"), + single: String(localized: "contextMenu.reconnectWorkspace", defaultValue: "Reconnect Workspace"), + isMulti: isMulti) + let disconnectLabel = contextMenuLabel( + multi: String(localized: "contextMenu.disconnectWorkspaces", defaultValue: "Disconnect Workspaces"), + single: String(localized: "contextMenu.disconnectWorkspace", defaultValue: "Disconnect Workspace"), + isMulti: isMulti) + let pinLabel = shouldPin + ? contextMenuLabel( + multi: String(localized: "contextMenu.pinWorkspaces", defaultValue: "Pin Workspaces"), + single: String(localized: "contextMenu.pinWorkspace", defaultValue: "Pin Workspace"), + isMulti: isMulti) + : contextMenuLabel( + multi: String(localized: "contextMenu.unpinWorkspaces", defaultValue: "Unpin Workspaces"), + single: String(localized: "contextMenu.unpinWorkspace", defaultValue: "Unpin Workspace"), + isMulti: isMulti) + let closeLabel = contextMenuLabel( + multi: String(localized: "contextMenu.closeWorkspaces", defaultValue: "Close Workspaces"), + single: String(localized: "contextMenu.closeWorkspace", defaultValue: "Close Workspace"), + isMulti: isMulti) + let markReadLabel = contextMenuLabel( + multi: String(localized: "contextMenu.markWorkspacesRead", defaultValue: "Mark Workspaces as Read"), + single: String(localized: "contextMenu.markWorkspaceRead", defaultValue: "Mark Workspace as Read"), + isMulti: isMulti) + let markUnreadLabel = contextMenuLabel( + multi: String(localized: "contextMenu.markWorkspacesUnread", defaultValue: "Mark Workspaces as Unread"), + single: String(localized: "contextMenu.markWorkspaceUnread", defaultValue: "Mark Workspace as Unread"), + isMulti: isMulti) + let clearLatestNotificationLabel = contextMenuLabel( + multi: String(localized: "contextMenu.clearLatestNotifications", defaultValue: "Clear Latest Notifications"), + single: String(localized: "contextMenu.clearLatestNotification", defaultValue: "Clear Latest Notification"), + isMulti: isMulti) + let renameWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .renameWorkspace) + let editWorkspaceDescriptionShortcut = KeyboardShortcutSettings.shortcut(for: .editWorkspaceDescription) + let closeWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .closeWorkspace) + Button(pinLabel) { + for id in targetIds { + if let tab = tabManager.tabs.first(where: { $0.id == id }) { + tabManager.setPinned(tab, pinned: shouldPin) + } + } + syncSelectionAfterMutation() + } + + if let key = renameWorkspaceShortcut.keyEquivalent { + Button(String(localized: "contextMenu.renameWorkspace", defaultValue: "Rename Workspace…")) { + promptRename() + } + .keyboardShortcut(key, modifiers: renameWorkspaceShortcut.eventModifiers) + } else { + Button(String(localized: "contextMenu.renameWorkspace", defaultValue: "Rename Workspace…")) { + promptRename() + } + } + + if tab.hasCustomTitle { + Button(String(localized: "contextMenu.removeCustomWorkspaceName", defaultValue: "Remove Custom Workspace Name")) { + tabManager.clearCustomTitle(tabId: tab.id) + } + } + + if !isMulti { + if let key = editWorkspaceDescriptionShortcut.keyEquivalent { + Button(String(localized: "contextMenu.editWorkspaceDescription", defaultValue: "Edit Workspace Description…")) { + beginWorkspaceDescriptionEditFromContextMenu() + } + .keyboardShortcut(key, modifiers: editWorkspaceDescriptionShortcut.eventModifiers) + } else { + Button(String(localized: "contextMenu.editWorkspaceDescription", defaultValue: "Edit Workspace Description…")) { + beginWorkspaceDescriptionEditFromContextMenu() + } + } + + if tab.hasCustomDescription { + Button(String(localized: "contextMenu.clearWorkspaceDescription", defaultValue: "Clear Workspace Description")) { + tabManager.clearCustomDescription(tabId: tab.id) + } + } + } + + if !remoteContextMenuWorkspaceIds.isEmpty { + Divider() + + Button(reconnectLabel) { + for workspace in remoteContextMenuWorkspaces() { + workspace.reconnectRemoteConnection() + } + } + .disabled(allRemoteContextMenuTargetsConnecting) + + Button(disconnectLabel) { + for workspace in remoteContextMenuWorkspaces() { + workspace.disconnectRemoteConnection(clearConfiguration: false) + } + } + .disabled(allRemoteContextMenuTargetsDisconnected) + } + + Menu(String(localized: "contextMenu.workspaceColor", defaultValue: "Workspace Color")) { + if tab.customColor != nil { + Button { + applyTabColor(nil, targetIds: targetIds) + } label: { + Label(String(localized: "contextMenu.clearColor", defaultValue: "Clear Color"), systemImage: "xmark.circle") + } + } + + Button { + promptCustomColor(targetIds: targetIds) + } label: { + Label(String(localized: "contextMenu.chooseCustomColor", defaultValue: "Choose Custom Color…"), systemImage: "paintpalette") + } + + if !tabColorPalette.isEmpty { + Divider() + } + + ForEach(tabColorPalette, id: \.id) { entry in + Button { + applyTabColor(entry.hex, targetIds: targetIds) + } label: { + Label { + Text(entry.name) + } icon: { + Image(nsImage: coloredCircleImage(color: tabColorSwatchColor(for: entry.hex))) + } + } + } + } + + if let copyableSidebarSSHError { + Button(String(localized: "contextMenu.copySshError", defaultValue: "Copy SSH Error")) { + copyTextToPasteboard(copyableSidebarSSHError) + } + } + + Divider() + + Button(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")) { + moveBy(-1) + } + .disabled(index == 0) + + Button(String(localized: "contextMenu.moveDown", defaultValue: "Move Down")) { + moveBy(1) + } + .disabled(index >= tabManager.tabs.count - 1) + + Button(String(localized: "contextMenu.moveToTop", defaultValue: "Move to Top")) { + tabManager.moveTabsToTop(Set(targetIds)) + syncSelectionAfterMutation() + } + .disabled(targetIds.isEmpty) + + let referenceWindowId = AppDelegate.shared?.windowId(for: tabManager) + let windowMoveTargets = AppDelegate.shared?.windowMoveTargets(referenceWindowId: referenceWindowId) ?? [] + let moveMenuTitle = targetIds.count > 1 + ? String(localized: "contextMenu.moveWorkspacesToWindow", defaultValue: "Move Workspaces to Window") + : String(localized: "contextMenu.moveWorkspaceToWindow", defaultValue: "Move Workspace to Window") + Menu(moveMenuTitle) { + Button(String(localized: "contextMenu.newWindow", defaultValue: "New Window")) { + moveWorkspacesToNewWindow(targetIds) + } + .disabled(targetIds.isEmpty) + + if !windowMoveTargets.isEmpty { + Divider() + } + + ForEach(windowMoveTargets) { target in + Button(target.label) { + moveWorkspaces(targetIds, toWindow: target.windowId) + } + .disabled(target.isCurrentWindow || targetIds.isEmpty) + } + } + .disabled(targetIds.isEmpty) + + Divider() + + if let key = closeWorkspaceShortcut.keyEquivalent { + Button(closeLabel) { + closeTabs(targetIds, allowPinned: true) + } + .keyboardShortcut(key, modifiers: closeWorkspaceShortcut.eventModifiers) + .disabled(targetIds.isEmpty) + } else { + Button(closeLabel) { + closeTabs(targetIds, allowPinned: true) + } + .disabled(targetIds.isEmpty) + } + + Button(String(localized: "contextMenu.closeOtherWorkspaces", defaultValue: "Close Other Workspaces")) { + closeOtherTabs(targetIds) + } + .disabled(tabManager.tabs.count <= 1 || targetIds.count == tabManager.tabs.count) + + Button(String(localized: "contextMenu.closeWorkspacesBelow", defaultValue: "Close Workspaces Below")) { + closeTabsBelow(tabId: tab.id) + } + .disabled(index >= tabManager.tabs.count - 1) + + Button(String(localized: "contextMenu.closeWorkspacesAbove", defaultValue: "Close Workspaces Above")) { + closeTabsAbove(tabId: tab.id) + } + .disabled(index == 0) + + Divider() + + Button(markReadLabel) { + markTabsRead(targetIds) + } + .disabled(!hasUnreadNotifications(in: targetIds)) + + Button(markUnreadLabel) { + markTabsUnread(targetIds) + } + .disabled(!hasReadNotifications(in: targetIds)) + + Button(clearLatestNotificationLabel) { + clearLatestNotifications(targetIds) + } + .disabled(!hasLatestNotifications(in: targetIds)) + } + + private var selectionBackgroundColor: NSColor { + if let hex = sidebarSelectionColorHex, let parsed = NSColor(hex: hex) { + return parsed + } + return programaAccentNSColor(for: colorScheme) + } + + private var backgroundColor: Color { + switch activeTabIndicatorStyle { + case .leftRail: + if isActive { return Color(nsColor: selectionBackgroundColor) } + if isMultiSelected { return programaAccentColor().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 programaAccentColor().opacity(0.25) } + return Color.clear + } + } + + private var railColor: Color { + explicitRailColor ?? .clear + } + + private var explicitRailColor: Color? { + guard activeTabIndicatorStyle == .leftRail, + let custom = resolvedCustomTabColor else { + return nil + } + 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 { + WorkspaceTabColorSettings.displayNSColor( + hex: hex, + colorScheme: colorScheme, + forceBright: activeTabIndicatorStyle == .leftRail + ) ?? NSColor(hex: hex) ?? .gray + } + + private var showsCenteredTopDropIndicator: Bool { + guard draggedTabId != nil, let indicator = dropIndicator else { return false } + if indicator.tabId == tab.id && indicator.edge == .top { + return true + } + + guard indicator.edge == .bottom, + let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == tab.id }), + currentIndex > 0 + else { + return false + } + return tabManager.tabs[currentIndex - 1].id == indicator.tabId + } + + private var accessibilityTitle: String { + String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(accessibilityWorkspaceCount)") + } + + private func moveBy(_ delta: Int) { + let targetIndex = index + delta + guard targetIndex >= 0, targetIndex < tabManager.tabs.count else { return } + guard tabManager.reorderWorkspace(tabId: tab.id, toIndex: targetIndex) else { return } + selectedTabIds = [tab.id] + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == tab.id } + tabManager.selectTab(tab) + setSelectionToTabs() + } + + private func updateSelection() { + #if DEBUG + let mods = NSEvent.modifierFlags + var modStr = "" + if mods.contains(.command) { modStr += "cmd " } + if mods.contains(.shift) { modStr += "shift " } + if mods.contains(.option) { modStr += "opt " } + if mods.contains(.control) { modStr += "ctrl " } + 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) + let isShift = modifiers.contains(.shift) + let wasSelected = tabManager.selectedTabId == tab.id + + if isShift, let lastIndex = lastSidebarSelectionIndex { + let lower = min(lastIndex, index) + let upper = max(lastIndex, index) + let rangeIds = tabManager.tabs[lower...upper].map { $0.id } + if isCommand { + selectedTabIds.formUnion(rangeIds) + } else { + selectedTabIds = Set(rangeIds) + } + } else if isCommand { + if selectedTabIds.contains(tab.id) { + selectedTabIds.remove(tab.id) + } else { + selectedTabIds.insert(tab.id) + } + } else { + selectedTabIds = [tab.id] + } + + lastSidebarSelectionIndex = index + tabManager.selectTab(tab) + if wasSelected, !isCommand, !isShift { + tabManager.dismissNotificationOnDirectInteraction( + tabId: tab.id, + surfaceId: tabManager.focusedSurfaceId(for: tab.id) + ) + } + setSelectionToTabs() + } + + private func closeTabs(_ targetIds: [UUID], allowPinned: Bool) { + tabManager.closeWorkspacesWithConfirmation(targetIds, allowPinned: allowPinned) + syncSelectionAfterMutation() + } + + private func closeOtherTabs(_ targetIds: [UUID]) { + let keepIds = Set(targetIds) + let idsToClose = tabManager.tabs.compactMap { keepIds.contains($0.id) ? nil : $0.id } + closeTabs(idsToClose, allowPinned: true) + } + + private func closeTabsBelow(tabId: UUID) { + guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } + let idsToClose = tabManager.tabs.suffix(from: anchorIndex + 1).map { $0.id } + closeTabs(idsToClose, allowPinned: true) + } + + private func closeTabsAbove(tabId: UUID) { + guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } + let idsToClose = tabManager.tabs.prefix(upTo: anchorIndex).map { $0.id } + closeTabs(idsToClose, allowPinned: true) + } + + private func markTabsRead(_ targetIds: [UUID]) { + for id in targetIds { + notificationStore.markRead(forTabId: id) + } + } + + private func markTabsUnread(_ targetIds: [UUID]) { + for id in targetIds { + notificationStore.markUnread(forTabId: id) + } + } + + private func clearLatestNotifications(_ targetIds: [UUID]) { + for id in targetIds { + notificationStore.clearLatestNotification(forTabId: id) + } + } + + private func hasUnreadNotifications(in targetIds: [UUID]) -> Bool { + let targetSet = Set(targetIds) + return notificationStore.notifications.contains { targetSet.contains($0.tabId) && !$0.isRead } + } + + private func hasReadNotifications(in targetIds: [UUID]) -> Bool { + let targetSet = Set(targetIds) + return notificationStore.notifications.contains { targetSet.contains($0.tabId) && $0.isRead } + } + + private func hasLatestNotifications(in targetIds: [UUID]) -> Bool { + targetIds.contains { notificationStore.latestNotification(forTabId: $0) != nil } + } + + private func syncSelectionAfterMutation() { + let existingIds = Set(tabManager.tabs.map { $0.id }) + selectedTabIds = selectedTabIds.filter { existingIds.contains($0) } + if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { + selectedTabIds = [selectedId] + } + if let selectedId = tabManager.selectedTabId { + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } + } + + private var remoteStateHelpText: String { + let target = tab.remoteDisplayTarget ?? String( + localized: "sidebar.remote.help.targetFallback", + defaultValue: "remote host" + ) + let detail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) + switch tab.remoteConnectionState { + case .connected: + return String( + format: String( + localized: "sidebar.remote.help.connected", + defaultValue: "SSH connected to %@" + ), + locale: .current, + target + ) + case .connecting: + return String( + format: String( + localized: "sidebar.remote.help.connecting", + defaultValue: "SSH connecting to %@" + ), + locale: .current, + target + ) + case .error: + if let detail, !detail.isEmpty { + return String( + format: String( + localized: "sidebar.remote.help.errorWithDetail", + defaultValue: "SSH error for %@: %@" + ), + locale: .current, + target, + detail + ) + } + return String( + format: String( + localized: "sidebar.remote.help.error", + defaultValue: "SSH error for %@" + ), + locale: .current, + target + ) + case .disconnected: + return String( + format: String( + localized: "sidebar.remote.help.disconnected", + defaultValue: "SSH disconnected from %@" + ), + locale: .current, + target + ) + } + } + private func moveWorkspaces(_ workspaceIds: [UUID], toWindow windowId: UUID) { + guard let app = AppDelegate.shared else { return } + let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } + guard !orderedWorkspaceIds.isEmpty else { return } + + for (index, workspaceId) in orderedWorkspaceIds.enumerated() { + let shouldFocus = index == orderedWorkspaceIds.count - 1 + _ = app.moveWorkspaceToWindow(workspaceId: workspaceId, windowId: windowId, focus: shouldFocus) + } + + selectedTabIds.subtract(orderedWorkspaceIds) + syncSelectionAfterMutation() + } + + private func moveWorkspacesToNewWindow(_ workspaceIds: [UUID]) { + guard let app = AppDelegate.shared else { return } + let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } + guard let firstWorkspaceId = orderedWorkspaceIds.first else { return } + + let shouldFocusImmediately = orderedWorkspaceIds.count == 1 + guard let newWindowId = app.moveWorkspaceToNewWindow(workspaceId: firstWorkspaceId, focus: shouldFocusImmediately) else { + return + } + + if orderedWorkspaceIds.count > 1 { + for workspaceId in orderedWorkspaceIds.dropFirst() { + _ = app.moveWorkspaceToWindow(workspaceId: workspaceId, windowId: newWindowId, focus: false) + } + if let finalWorkspaceId = orderedWorkspaceIds.last { + _ = app.moveWorkspaceToWindow(workspaceId: finalWorkspaceId, windowId: newWindowId, focus: true) + } + } + + selectedTabIds.subtract(orderedWorkspaceIds) + syncSelectionAfterMutation() + } + + // latestNotificationText is now passed as a parameter from the parent view + // to avoid subscribing to notificationStore changes in every TabItemView. + + private func branchDirectoryRow( + gitSummary: String?, + directorySummary: String? + ) -> String? { + var parts: [String] = [] + + if let gitSummary { + parts.append(gitSummary) + } + + if let directorySummary { + parts.append(directorySummary) + } + + let result = parts.joined(separator: " · ") + return result.isEmpty ? nil : result + } + + private func gitBranchSummaryText(orderedPanelIds: [UUID]) -> String? { + let lines = gitBranchSummaryLines(orderedPanelIds: orderedPanelIds) + guard !lines.isEmpty else { return nil } + return lines.joined(separator: " | ") + } + + private func gitBranchSummaryLines(orderedPanelIds: [UUID]) -> [String] { + tab.sidebarGitBranchesInDisplayOrder(orderedPanelIds: orderedPanelIds).map { branch in + "\(branch.branch)\(branch.isDirty ? "*" : "")" + } + } + + private struct VerticalBranchDirectoryLine { + let branch: String? + let directory: String? + } + + /// Recomputes the expensive sidebar detail caches (bonsplit tree walk, + /// branch/directory lines, PR snapshot) and writes them into @State. + /// Must be called only from the debounced publisher, onAppear, and + /// settings-change handlers — never from the immediate (title) publisher. + private func recomputeSidebarDetailCache() { + let detail = visibleAuxiliaryDetails + let needsDetail = detail.showsBranchDirectory || detail.showsPullRequests + let ids: [UUID]? = needsDetail ? tab.sidebarOrderedPanelIds() : nil + cachedOrderedPanelIds = ids + cachedBranchDirectoryLines = { + guard detail.showsBranchDirectory, sidebarBranchVerticalLayout, let ids else { return [] } + return verticalBranchDirectoryLines(orderedPanelIds: ids) + }() + cachedPullRequestRows = { + guard detail.showsPullRequests, let ids else { return [] } + return pullRequestDisplays(orderedPanelIds: ids) + }() + } + + private func verticalBranchDirectoryLines(orderedPanelIds: [UUID]) -> [VerticalBranchDirectoryLine] { + let entries = tab.sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: orderedPanelIds) + let home = SidebarPathFormatter.homeDirectoryPath + return entries.compactMap { entry in + let branchText: String? = { + guard sidebarShowGitBranch, let branch = entry.branch else { return nil } + return "\(branch)\(entry.isDirty ? "*" : "")" + }() + + let directoryText: String? = { + guard let directory = entry.directory else { return nil } + let shortened = SidebarPathFormatter.shortenedPath(directory, homeDirectoryPath: home) + return shortened.isEmpty ? nil : shortened + }() + + switch (branchText, directoryText) { + case let (branch?, directory?): + return VerticalBranchDirectoryLine(branch: branch, directory: directory) + case let (branch?, nil): + return VerticalBranchDirectoryLine(branch: branch, directory: nil) + case let (nil, directory?): + return VerticalBranchDirectoryLine(branch: nil, directory: directory) + default: + return nil + } + } + } + + private func directorySummaryText(orderedPanelIds: [UUID]) -> String? { + let home = SidebarPathFormatter.homeDirectoryPath + let entries = tab.sidebarDirectoriesInDisplayOrder(orderedPanelIds: orderedPanelIds).compactMap { directory in + let shortened = SidebarPathFormatter.shortenedPath(directory, homeDirectoryPath: home) + return shortened.isEmpty ? nil : shortened + } + return entries.isEmpty ? nil : entries.joined(separator: " | ") + } + + private struct PullRequestDisplay: Identifiable { + let id: String + let number: Int + let label: String + let url: URL + let status: SidebarPullRequestStatus + let checks: SidebarPullRequestChecksStatus? + } + + private func pullRequestDisplays(orderedPanelIds: [UUID]) -> [PullRequestDisplay] { + tab.sidebarPullRequestsInDisplayOrder(orderedPanelIds: orderedPanelIds).map { pullRequest in + PullRequestDisplay( + id: "\(pullRequest.label.lowercased())#\(pullRequest.number)|\(pullRequest.url.absoluteString)", + number: pullRequest.number, + label: pullRequest.label, + url: pullRequest.url, + status: pullRequest.status, + checks: pullRequest.checks + ) + } + } + + private var pullRequestForegroundColor: Color { + isActive ? .white.opacity(0.75) : .secondary + } + + private func openPullRequestLink(_ url: URL) { + updateSelection() + if openSidebarPullRequestLinksInProgramaBrowser { + if tabManager.openBrowser( + inWorkspace: tab.id, + url: url, + preferSplitRight: true, + insertAtEnd: true + ) == nil { + NSWorkspace.shared.open(url) + } + return + } + NSWorkspace.shared.open(url) + } + + private func openPortLink(_ port: Int) { + guard let url = URL(string: "http://localhost:\(port)") else { return } + updateSelection() + if openSidebarPortLinksInProgramaBrowser { + if tabManager.openBrowser( + inWorkspace: tab.id, + url: url, + preferSplitRight: true, + insertAtEnd: true + ) == nil { + NSWorkspace.shared.open(url) + } + return + } + NSWorkspace.shared.open(url) + } + + private func pullRequestStatusLabel( + _ status: SidebarPullRequestStatus, + checks _: SidebarPullRequestChecksStatus? + ) -> String { + switch status { + case .open: return String(localized: "sidebar.pullRequest.statusOpen", defaultValue: "open") + case .merged: return String(localized: "sidebar.pullRequest.statusMerged", defaultValue: "merged") + case .closed: return String(localized: "sidebar.pullRequest.statusClosed", defaultValue: "closed") + } + } + + private func logLevelIcon(_ level: SidebarLogLevel) -> String { + switch level { + case .info: return "circle.fill" + case .progress: return "arrowtriangle.right.fill" + case .success: return "checkmark.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .error: return "xmark.circle.fill" + } + } + + private func logLevelColor(_ level: SidebarLogLevel, isActive: Bool) -> Color { + if isActive { + switch level { + case .info: + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.5)) + case .progress: + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.8)) + case .success: + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) + case .warning: + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) + case .error: + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) + } + } + switch level { + case .info: return .secondary + case .progress: return .blue + case .success: return .green + case .warning: return .orange + case .error: return .red + } + } + + private func shortenPath(_ path: String, home: String) -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return path } + if trimmed == home { + return "~" + } + if trimmed.hasPrefix(home + "/") { + return "~" + trimmed.dropFirst(home.count) + } + return trimmed + } + + private struct PullRequestStatusIcon: View { + let status: SidebarPullRequestStatus + let color: Color + private static let frameSize: CGFloat = 12 + + var body: some View { + switch status { + case .open: + PullRequestOpenIcon(color: color) + case .merged: + PullRequestMergedIcon(color: color) + case .closed: + Image(systemName: "xmark.circle") + .font(.system(size: 7, weight: .regular)) + .foregroundColor(color) + .frame(width: Self.frameSize, height: Self.frameSize) + } + } + } + + private struct PullRequestOpenIcon: View { + let color: Color + private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round) + private static let nodeDiameter: CGFloat = 3.0 + private static let frameSize: CGFloat = 13 + + var body: some View { + ZStack { + Path { path in + path.move(to: CGPoint(x: 3.0, y: 4.8)) + path.addLine(to: CGPoint(x: 3.0, y: 9.2)) + + path.move(to: CGPoint(x: 4.8, y: 3.0)) + path.addLine(to: CGPoint(x: 9.4, y: 3.0)) + path.addLine(to: CGPoint(x: 11.0, y: 4.6)) + path.addLine(to: CGPoint(x: 11.0, y: 9.2)) + } + .stroke(color, style: Self.stroke) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 3.0, y: 3.0) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 3.0, y: 11.0) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 11.0, y: 11.0) + } + .frame(width: Self.frameSize, height: Self.frameSize) + } + } + + private struct PullRequestMergedIcon: View { + let color: Color + private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round) + private static let nodeDiameter: CGFloat = 3.0 + private static let frameSize: CGFloat = 13 + + var body: some View { + ZStack { + Path { path in + path.move(to: CGPoint(x: 4.6, y: 4.6)) + path.addLine(to: CGPoint(x: 7.1, y: 7.0)) + path.addLine(to: CGPoint(x: 9.2, y: 7.0)) + + path.move(to: CGPoint(x: 4.6, y: 9.4)) + path.addLine(to: CGPoint(x: 7.1, y: 7.0)) + } + .stroke(color, style: Self.stroke) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 3.0, y: 3.0) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 3.0, y: 11.0) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 11.0, y: 7.0) + } + .frame(width: Self.frameSize, height: Self.frameSize) + } + } + + private func applyTabColor(_ hex: String?, targetIds: [UUID]) { + for targetId in targetIds { + tabManager.setTabColor(tabId: targetId, color: hex) + } + } + + private func promptCustomColor(targetIds: [UUID]) { + let alert = NSAlert() + alert.messageText = String(localized: "alert.customColor.title", defaultValue: "Custom Workspace Color") + alert.informativeText = String(localized: "alert.customColor.message", defaultValue: "Enter a hex color in the format #RRGGBB.") + + let seed = tab.customColor ?? WorkspaceTabColorSettings.customPaletteEntries().first?.hex ?? "" + let input = NSTextField(string: seed) + input.placeholderString = "#1565C0" + input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) + alert.accessoryView = input + alert.addButton(withTitle: String(localized: "alert.customColor.apply", defaultValue: "Apply")) + alert.addButton(withTitle: String(localized: "alert.customColor.cancel", defaultValue: "Cancel")) + + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) + } + + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { return } + guard let normalized = WorkspaceTabColorSettings.addCustomColor(input.stringValue) else { + showInvalidColorAlert(input.stringValue) + return + } + applyTabColor(normalized, targetIds: targetIds) + } + + private func showInvalidColorAlert(_ value: String) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(localized: "alert.invalidColor.title", defaultValue: "Invalid Color") + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + alert.informativeText = String(localized: "alert.invalidColor.emptyMessage", defaultValue: "Enter a hex color in the format #RRGGBB.") + } else { + alert.informativeText = String(localized: "alert.invalidColor.invalidMessage", defaultValue: "\"\(trimmed)\" is not a valid hex color. Use #RRGGBB.") + } + alert.addButton(withTitle: String(localized: "alert.invalidColor.ok", defaultValue: "OK")) + _ = alert.runModal() + } + + private func promptRename() { + let alert = NSAlert() + alert.messageText = String(localized: "alert.renameWorkspace.title", defaultValue: "Rename Workspace") + alert.informativeText = String(localized: "alert.renameWorkspace.message", defaultValue: "Enter a custom name for this workspace.") + let input = NSTextField(string: tab.customTitle ?? tab.title) + input.placeholderString = String(localized: "alert.renameWorkspace.placeholder", defaultValue: "Workspace name") + input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) + alert.accessoryView = input + alert.addButton(withTitle: String(localized: "alert.renameWorkspace.rename", defaultValue: "Rename")) + alert.addButton(withTitle: String(localized: "alert.renameWorkspace.cancel", defaultValue: "Cancel")) + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) + } + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { return } + tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue) + } + + private func beginWorkspaceDescriptionEditFromContextMenu() { + selectedTabIds = [tab.id] + lastSidebarSelectionIndex = index + tabManager.selectTab(tab) + setSelectionToTabs() + _ = AppDelegate.shared?.requestEditWorkspaceDescriptionViaCommandPalette() + } +} + +private struct SidebarWorkspaceDescriptionText: View { + let markdown: String + let isActive: Bool + + var body: some View { + let renderedMarkdown = SidebarMarkdownRenderer.renderWorkspaceDescription(markdown) + Group { + if let renderedMarkdown { + Text(renderedMarkdown) + } else { + Text(markdown) + } + } + .font(.system(size: 10.5)) + .foregroundColor(foregroundColor) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("SidebarWorkspaceDescriptionText") + .accessibilityLabel(accessibilityText(renderedMarkdown: renderedMarkdown)) + .onAppear { +#if DEBUG + let newlineCount = markdown.reduce(into: 0) { count, character in + if character == "\n" { count += 1 } + } + dlog( + "sidebar.description.render workspaceState=appear " + + "len=\((markdown as NSString).length) " + + "newlines=\(newlineCount) " + + "text=\"\(debugCommandPaletteTextPreview(markdown))\"" + ) +#endif + } + .onChange(of: markdown) { newValue in +#if DEBUG + let newlineCount = newValue.reduce(into: 0) { count, character in + if character == "\n" { count += 1 } + } + dlog( + "sidebar.description.render workspaceState=change " + + "len=\((newValue as NSString).length) " + + "newlines=\(newlineCount) " + + "text=\"\(debugCommandPaletteTextPreview(newValue))\"" + ) +#endif + } + } + + private var foregroundColor: Color { + isActive ? .white.opacity(0.84) : .secondary.opacity(0.95) + } + + private func accessibilityText(renderedMarkdown: AttributedString?) -> String { + if let renderedMarkdown { + return String(renderedMarkdown.characters) + } + return markdown + } +} + +enum SidebarMarkdownRenderer { + static func renderWorkspaceDescription(_ markdown: String) -> AttributedString? { + try? AttributedString( + markdown: markdown, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) + } +} + +private struct SidebarMetadataRows: View { + let entries: [SidebarStatusEntry] + let isActive: Bool + let onFocus: () -> Void + + @State private var isExpanded: Bool = false + private let collapsedEntryLimit = 3 + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + ForEach(visibleEntries, id: \.key) { entry in + SidebarMetadataEntryRow(entry: entry, isActive: isActive, onFocus: onFocus) + } + + if shouldShowToggle { + Button(isExpanded ? String(localized: "sidebar.metadata.showLess", defaultValue: "Show less") : String(localized: "sidebar.metadata.showMore", defaultValue: "Show more")) { + onFocus() + withAnimation(.easeInOut(duration: 0.15)) { + isExpanded.toggle() + } + } + .buttonStyle(.plain) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(isActive ? activeSecondaryTextColor : .secondary.opacity(0.9)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .safeHelp(helpText) + } + + private var activeSecondaryTextColor: Color { + Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.65)) + } + + private var visibleEntries: [SidebarStatusEntry] { + guard !isExpanded, entries.count > collapsedEntryLimit else { return entries } + return Array(entries.prefix(collapsedEntryLimit)) + } + + private var helpText: String { + entries.map { entry in + let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? entry.key : trimmed + } + .joined(separator: "\n") + } + + private var shouldShowToggle: Bool { + entries.count > collapsedEntryLimit + } +} + +private struct SidebarMetadataEntryRow: View { + let entry: SidebarStatusEntry + let isActive: Bool + let onFocus: () -> Void + + var body: some View { + Group { + if let url = entry.url { + Button { + onFocus() + NSWorkspace.shared.open(url) + } label: { + rowContent(underlined: true) + } + .buttonStyle(.plain) + .safeHelp(url.absoluteString) + } else { + rowContent(underlined: false) + .contentShape(Rectangle()) + .onTapGesture { onFocus() } + } + } + } + + @ViewBuilder + private func rowContent(underlined: Bool) -> some View { + HStack(spacing: 4) { + if let icon = iconView { + icon + .foregroundColor(foregroundColor.opacity(0.95)) + } + metadataText(underlined: underlined) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 0) + } + .font(.system(size: 10)) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var foregroundColor: Color { + if isActive, + let raw = entry.color, + Color(hex: raw) != nil { + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.95)) + } + if let raw = entry.color, let explicit = Color(hex: raw) { + return explicit + } + return isActive ? .white.opacity(0.8) : .secondary + } + + private var iconView: AnyView? { + guard let iconRaw = entry.icon?.trimmingCharacters(in: .whitespacesAndNewlines), + !iconRaw.isEmpty else { + return nil + } + if iconRaw.hasPrefix("emoji:") { + let value = String(iconRaw.dropFirst("emoji:".count)) + guard !value.isEmpty else { return nil } + return AnyView(Text(value).font(.system(size: 9))) + } + if iconRaw.hasPrefix("text:") { + let value = String(iconRaw.dropFirst("text:".count)) + guard !value.isEmpty else { return nil } + return AnyView(Text(value).font(.system(size: 8, weight: .semibold))) + } + let symbolName: String + if iconRaw.hasPrefix("sf:") { + symbolName = String(iconRaw.dropFirst("sf:".count)) + } else { + symbolName = iconRaw + } + guard !symbolName.isEmpty else { return nil } + return AnyView(Image(systemName: symbolName).font(.system(size: 8, weight: .medium))) + } + + @ViewBuilder + private func metadataText(underlined: Bool) -> some View { + let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) + let display = trimmed.isEmpty ? entry.key : trimmed + if entry.format == .markdown, + let attributed = try? AttributedString( + markdown: display, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) { + Text(attributed) + .underline(underlined) + .foregroundColor(foregroundColor) + } else { + Text(display) + .underline(underlined) + .foregroundColor(foregroundColor) + } + } +} + +private struct SidebarMetadataMarkdownBlocks: View { + let blocks: [SidebarMetadataBlock] + let isActive: Bool + let onFocus: () -> Void + + @State private var isExpanded: Bool = false + private let collapsedBlockLimit = 1 + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + ForEach(visibleBlocks, id: \.key) { block in + SidebarMetadataMarkdownBlockRow( + block: block, + isActive: isActive, + onFocus: onFocus + ) + } + + if shouldShowToggle { + Button(isExpanded ? String(localized: "sidebar.metadata.showLessDetails", defaultValue: "Show less details") : String(localized: "sidebar.metadata.showMoreDetails", defaultValue: "Show more details")) { + onFocus() + withAnimation(.easeInOut(duration: 0.15)) { + isExpanded.toggle() + } + } + .buttonStyle(.plain) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(isActive ? .white.opacity(0.65) : .secondary.opacity(0.9)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + private var visibleBlocks: [SidebarMetadataBlock] { + guard !isExpanded, blocks.count > collapsedBlockLimit else { return blocks } + return Array(blocks.prefix(collapsedBlockLimit)) + } + + private var shouldShowToggle: Bool { + blocks.count > collapsedBlockLimit + } +} + +private struct SidebarMetadataMarkdownBlockRow: View { + let block: SidebarMetadataBlock + let isActive: Bool + let onFocus: () -> Void + + @State private var renderedMarkdown: AttributedString? + + var body: some View { + Group { + if let renderedMarkdown { + Text(renderedMarkdown) + .foregroundColor(foregroundColor) + } else { + Text(block.markdown) + .foregroundColor(foregroundColor) + } + } + .font(.system(size: 10)) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .contentShape(Rectangle()) + .onTapGesture { onFocus() } + .onAppear(perform: renderMarkdown) + .onChange(of: block.markdown) { _ in + renderMarkdown() + } + } + + private var foregroundColor: Color { + isActive ? .white.opacity(0.8) : .secondary + } + + private func renderMarkdown() { + renderedMarkdown = try? AttributedString( + markdown: block.markdown, + options: .init(interpretedSyntax: .full) + ) + } +} + diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index cae341e10c8..38c71f09f93 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1091,36 +1091,14 @@ class TabManager: ObservableObject { let ordinal = Self.nextPortOrdinal Self.nextPortOrdinal += 1 - // Try to claim a pre-warmed surface from the pool for instant tab creation. - // Fall back to normal cold-start when the pool is empty or the request is - // incompatible (custom command, custom env, etc.). - let canUsePool = initialTerminalCommand == nil - && initialTerminalEnvironment.isEmpty - let newWorkspace: Workspace - if canUsePool, - let claimed = SurfacePool.shared.claim( - workspaceId: UUID(), - portOrdinal: ordinal, - workingDirectory: workingDirectory, - configTemplate: inheritedConfig - ) { - newWorkspace = Workspace( - claimedPanel: claimed.panel, - title: title ?? "Terminal \(nextTabCount)", - workingDirectory: workingDirectory, - portOrdinal: ordinal, - configTemplate: inheritedConfig - ) - } else { - newWorkspace = makeWorkspaceForCreation( - title: title ?? "Terminal \(nextTabCount)", - workingDirectory: workingDirectory, - portOrdinal: ordinal, - configTemplate: inheritedConfig, - initialTerminalCommand: initialTerminalCommand, - initialTerminalEnvironment: initialTerminalEnvironment - ) - } + let newWorkspace = makeWorkspaceForCreation( + title: title ?? "Terminal \(nextTabCount)", + workingDirectory: workingDirectory, + portOrdinal: ordinal, + configTemplate: inheritedConfig, + initialTerminalCommand: initialTerminalCommand, + initialTerminalEnvironment: initialTerminalEnvironment + ) newWorkspace.owningTabManager = self if title != nil { newWorkspace.setCustomTitle(title) diff --git a/Sources/TerminalDirectoryOpener.swift b/Sources/TerminalDirectoryOpener.swift new file mode 100644 index 00000000000..f40f4ef0e85 --- /dev/null +++ b/Sources/TerminalDirectoryOpener.swift @@ -0,0 +1,297 @@ +import AppKit +import SwiftUI +import Bonsplit +import CoreServices +import UserNotifications +import WebKit +import Combine +import ObjectiveC.runtime +import Darwin + +enum FinderServicePathResolver { + private static func canonicalDirectoryPath(_ path: String) -> String { + guard path.count > 1 else { return path } + var canonical = path + while canonical.count > 1 && canonical.hasSuffix("/") { + canonical.removeLast() + } + return canonical + } + + private static func normalizedComparisonURL(_ url: URL) -> URL { + url.standardizedFileURL.resolvingSymlinksInPath() + } + + private static func isSameOrDescendant(_ url: URL, of rootURL: URL) -> Bool { + let urlPathComponents = normalizedComparisonURL(url).pathComponents + let rootPathComponents = normalizedComparisonURL(rootURL).pathComponents + guard urlPathComponents.count >= rootPathComponents.count else { return false } + return Array(urlPathComponents.prefix(rootPathComponents.count)) == rootPathComponents + } + + private static func resolvedDirectoryURL(from url: URL) -> URL { + let standardized = url.standardizedFileURL + if standardized.hasDirectoryPath { + return standardized + } + if let resourceValues = try? standardized.resourceValues(forKeys: [.isDirectoryKey]), + resourceValues.isDirectory == true { + return standardized + } + return standardized.deletingLastPathComponent() + } + + static func orderedUniqueDirectories( + from pathURLs: [URL], + excludingDescendantsOf excludedRootURLs: [URL] = [] + ) -> [String] { + var seen: Set = [] + var directories: [String] = [] + + for url in pathURLs { + let directoryURL = resolvedDirectoryURL(from: url) + guard !excludedRootURLs.contains(where: { isSameOrDescendant(directoryURL, of: $0) }) else { + continue + } + let path = canonicalDirectoryPath(directoryURL.path(percentEncoded: false)) + guard !path.isEmpty else { continue } + if seen.insert(path).inserted { + directories.append(path) + } + } + + return directories + } +} + +enum TerminalDirectoryOpenTarget: String, CaseIterable { + case androidStudio + case antigravity + case cursor + case finder + case ghostty + case intellij + case iterm2 + case terminal + case tower + case vscode + case vscodeInline + case warp + case windsurf + case xcode + case zed + + struct DetectionEnvironment { + let homeDirectoryPath: String + let fileExistsAtPath: (String) -> Bool + let isExecutableFileAtPath: (String) -> Bool + let applicationPathForName: (String) -> String? + + static let live = DetectionEnvironment( + homeDirectoryPath: FileManager.default.homeDirectoryForCurrentUser.path, + fileExistsAtPath: { FileManager.default.fileExists(atPath: $0) }, + isExecutableFileAtPath: { FileManager.default.isExecutableFile(atPath: $0) }, + applicationPathForName: { NSWorkspace.shared.fullPath(forApplication: $0) } + ) + } + + static var commandPaletteShortcutTargets: [Self] { + Array(allCases) + } + + static func availableTargets(in environment: DetectionEnvironment = .live) -> Set { + Set(commandPaletteShortcutTargets.filter { $0.isAvailable(in: environment) }) + } + + var commandPaletteCommandId: String { + "palette.terminalOpenDirectory.\(rawValue)" + } + + var commandPaletteTitle: String { + switch self { + case .androidStudio: + return String(localized: "menu.openInAndroidStudio", defaultValue: "Open Current Directory in Android Studio") + case .antigravity: + return String(localized: "menu.openInAntigravity", defaultValue: "Open Current Directory in Antigravity") + case .cursor: + return String(localized: "menu.openInCursor", defaultValue: "Open Current Directory in Cursor") + case .finder: + return String(localized: "menu.openInFinder", defaultValue: "Open Current Directory in Finder") + case .ghostty: + return String(localized: "menu.openInGhostty", defaultValue: "Open Current Directory in Ghostty") + case .intellij: + return String(localized: "menu.openInIntelliJ", defaultValue: "Open Current Directory in IntelliJ IDEA") + case .iterm2: + return String(localized: "menu.openInITerm2", defaultValue: "Open Current Directory in iTerm2") + case .terminal: + return String(localized: "menu.openInTerminal", defaultValue: "Open Current Directory in Terminal") + case .tower: + return String(localized: "menu.openInTower", defaultValue: "Open Current Directory in Tower") + case .vscode: + return String(localized: "menu.openInVSCodeDesktop", defaultValue: "Open Current Directory in VS Code") + case .vscodeInline: + return String(localized: "menu.openInVSCode", defaultValue: "Open Current Directory in VS Code (Inline)") + case .warp: + return String(localized: "menu.openInWarp", defaultValue: "Open Current Directory in Warp") + case .windsurf: + return String(localized: "menu.openInWindsurf", defaultValue: "Open Current Directory in Windsurf") + case .xcode: + return String(localized: "menu.openInXcode", defaultValue: "Open Current Directory in Xcode") + case .zed: + return String(localized: "menu.openInZed", defaultValue: "Open Current Directory in Zed") + } + } + + var commandPaletteKeywords: [String] { + let common = ["terminal", "directory", "open", "ide"] + switch self { + case .androidStudio: + return common + ["android", "studio"] + case .antigravity: + return common + ["antigravity"] + case .cursor: + return common + ["cursor"] + case .finder: + return common + ["finder", "file", "manager", "reveal"] + case .ghostty: + return common + ["ghostty", "terminal", "shell"] + case .intellij: + return common + ["intellij", "idea", "jetbrains"] + case .iterm2: + return common + ["iterm", "iterm2", "terminal", "shell"] + case .terminal: + return common + ["terminal", "shell"] + case .tower: + return common + ["tower", "git", "client"] + case .vscode: + return common + ["vs", "code", "visual", "studio", "desktop", "app"] + case .vscodeInline: + return common + ["vs", "code", "visual", "studio", "inline", "browser", "serve-web"] + case .warp: + return common + ["warp", "terminal", "shell"] + case .windsurf: + return common + ["windsurf"] + case .xcode: + return common + ["xcode", "apple"] + case .zed: + return common + ["zed"] + } + } + + func isAvailable(in environment: DetectionEnvironment = .live) -> Bool { + guard let applicationPath = applicationPath(in: environment) else { return false } + guard self == .vscodeInline else { return true } + return VSCodeCLILaunchConfigurationBuilder.launchConfiguration( + vscodeApplicationURL: URL(fileURLWithPath: applicationPath, isDirectory: true), + isExecutableAtPath: environment.isExecutableFileAtPath + ) != nil + } + + func applicationURL(in environment: DetectionEnvironment = .live) -> URL? { + guard let path = applicationPath(in: environment) else { return nil } + return URL(fileURLWithPath: path, isDirectory: true) + } + + private func applicationPath(in environment: DetectionEnvironment) -> String? { + for path in expandedCandidatePaths(in: environment) where environment.fileExistsAtPath(path) { + return path + } + + // Fall back to LaunchServices so apps outside the standard bundle paths + // still appear in the command palette. + for applicationName in applicationSearchNames { + guard let resolvedPath = environment.applicationPathForName(applicationName), + environment.fileExistsAtPath(resolvedPath) else { + continue + } + return resolvedPath + } + + return nil + } + + private func expandedCandidatePaths(in environment: DetectionEnvironment) -> [String] { + let globalPrefix = "/Applications/" + let userPrefix = "\(environment.homeDirectoryPath)/Applications/" + var expanded: [String] = [] + + for candidate in applicationBundlePathCandidates { + expanded.append(candidate) + if candidate.hasPrefix(globalPrefix) { + let suffix = String(candidate.dropFirst(globalPrefix.count)) + expanded.append(userPrefix + suffix) + } + } + + return uniquePreservingOrder(expanded) + } + + private var applicationSearchNames: [String] { + uniquePreservingOrder( + applicationBundlePathCandidates.map { + URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent + } + ) + } + + private var applicationBundlePathCandidates: [String] { + switch self { + case .androidStudio: + return ["/Applications/Android Studio.app"] + case .antigravity: + return ["/Applications/Antigravity.app"] + case .cursor: + return [ + "/Applications/Cursor.app", + "/Applications/Cursor Preview.app", + "/Applications/Cursor Nightly.app", + ] + case .finder: + return ["/System/Library/CoreServices/Finder.app"] + case .ghostty: + return ["/Applications/Ghostty.app"] + case .intellij: + return ["/Applications/IntelliJ IDEA.app"] + case .iterm2: + return [ + "/Applications/iTerm.app", + "/Applications/iTerm2.app", + ] + case .terminal: + return ["/System/Applications/Utilities/Terminal.app"] + case .tower: + return ["/Applications/Tower.app"] + case .vscode: + return [ + "/Applications/Visual Studio Code.app", + "/Applications/Code.app", + ] + case .vscodeInline: + return [ + "/Applications/Visual Studio Code.app", + "/Applications/Code.app", + ] + case .warp: + return ["/Applications/Warp.app"] + case .windsurf: + return ["/Applications/Windsurf.app"] + case .xcode: + return ["/Applications/Xcode.app"] + case .zed: + return [ + "/Applications/Zed.app", + "/Applications/Zed Preview.app", + "/Applications/Zed Nightly.app", + ] + } + } + + private func uniquePreservingOrder(_ paths: [String]) -> [String] { + var seen: Set = [] + var deduped: [String] = [] + for path in paths where seen.insert(path).inserted { + deduped.append(path) + } + return deduped + } +} diff --git a/Sources/TerminalSSHSessionDetector.swift b/Sources/TerminalSSHSessionDetector.swift index 6bd75803fa1..ab10f864716 100644 --- a/Sources/TerminalSSHSessionDetector.swift +++ b/Sources/TerminalSSHSessionDetector.swift @@ -84,12 +84,10 @@ struct DetectedSSHSession: Equatable { _ fileURLs: [URL], operation: TerminalImageTransferOperation ) throws -> [String] { - guard !fileURLs.isEmpty else { return [] } - - var uploadedRemotePaths: [String] = [] - do { - for localURL in fileURLs { - try operation.throwIfCancelled() + try performSCPUploadWithCancelCleanup( + items: fileURLs, + checkCancelled: { try operation.throwIfCancelled() }, + performUpload: { localURL, record in let normalizedLocalURL = localURL.standardizedFileURL guard normalizedLocalURL.isFileURL else { throw NSError(domain: "programa.detected-ssh.drop", code: 1, userInfo: [ @@ -112,25 +110,16 @@ struct DetectedSSHSession: Equatable { ]) } - uploadedRemotePaths.append(remotePath) - } - - return uploadedRemotePaths - } catch { - cleanupUploadedRemotePaths(uploadedRemotePaths) - throw error - } + record(remotePath) + }, + cleanup: { cleanupUploadedRemotePaths($0) } + ) } private func scpArguments(localPath: String, remotePath: String) -> [String] { - var args: [String] = [ - "-q", - "-o", "ConnectTimeout=6", - "-o", "ServerAliveInterval=20", - "-o", "ServerAliveCountMax=2", - "-o", "BatchMode=yes", - "-o", "ControlMaster=no", - ] + var args: [String] = ["-q"] + + RemoteSSHConnectionPolicy.keepaliveArguments + + RemoteSSHConnectionPolicy.batchModeArguments if useIPv4 { args.append("-4") @@ -157,12 +146,10 @@ struct DetectedSSHSession: Equatable { } if let controlPath, !controlPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - !Self.hasSSHOptionKey(sshOptions, key: "ControlPath") { + !RemoteSSHConnectionPolicy.hasOptionKey(sshOptions, key: "ControlPath") { args += ["-o", "ControlPath=\(controlPath)"] } - if !Self.hasSSHOptionKey(sshOptions, key: "StrictHostKeyChecking") { - args += ["-o", "StrictHostKeyChecking=accept-new"] - } + args += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: sshOptions) for option in sshOptions { args += ["-o", option] } @@ -172,14 +159,9 @@ struct DetectedSSHSession: Equatable { } private func sshArguments(command: String) -> [String] { - var args: [String] = [ - "-T", - "-o", "ConnectTimeout=6", - "-o", "ServerAliveInterval=20", - "-o", "ServerAliveCountMax=2", - "-o", "BatchMode=yes", - "-o", "ControlMaster=no", - ] + var args: [String] = ["-T"] + + RemoteSSHConnectionPolicy.keepaliveArguments + + RemoteSSHConnectionPolicy.batchModeArguments if useIPv4 { args.append("-4") @@ -206,12 +188,10 @@ struct DetectedSSHSession: Equatable { } if let controlPath, !controlPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - !Self.hasSSHOptionKey(sshOptions, key: "ControlPath") { + !RemoteSSHConnectionPolicy.hasOptionKey(sshOptions, key: "ControlPath") { args += ["-o", "ControlPath=\(controlPath)"] } - if !Self.hasSSHOptionKey(sshOptions, key: "StrictHostKeyChecking") { - args += ["-o", "StrictHostKeyChecking=accept-new"] - } + args += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: sshOptions) for option in sshOptions { args += ["-o", option] } @@ -222,8 +202,8 @@ struct DetectedSSHSession: Equatable { private func cleanupUploadedRemotePaths(_ remotePaths: [String]) { guard !remotePaths.isEmpty else { return } - let cleanupScript = "rm -f -- " + remotePaths.map(Self.shellSingleQuoted).joined(separator: " ") - let cleanupCommand = "sh -c \(Self.shellSingleQuoted(cleanupScript))" + let cleanupScript = "rm -f -- " + remotePaths.map(RemoteSSHConnectionPolicy.shellSingleQuoted).joined(separator: " ") + let cleanupCommand = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(cleanupScript))" _ = try? Self.runProcess( executable: "/usr/bin/ssh", arguments: sshArguments(command: cleanupCommand), @@ -331,21 +311,6 @@ struct DetectedSSHSession: Equatable { .first(where: { !$0.isEmpty }) } - private static func hasSSHOptionKey(_ options: [String], key: String) -> Bool { - let loweredKey = key.lowercased() - return options.contains { optionKey($0) == loweredKey } - } - - private static func optionKey(_ option: String) -> String? { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) - .first - .map(String.init)? - .lowercased() - } - private static func scpRemoteDestination(_ destination: String) -> String { let trimmedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedDestination.isEmpty else { return destination } @@ -380,10 +345,6 @@ struct DetectedSSHSession: Equatable { !trimmedHost.hasSuffix("]") } - private static func shellSingleQuoted(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" - } - #if DEBUG func scpArgumentsForTesting(localPath: String, remotePath: String) -> [String] { scpArguments(localPath: localPath, remotePath: remotePath) @@ -727,7 +688,7 @@ enum TerminalSSHSessionDetector { ) -> Bool { let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return false } - let key = sshOptionKey(trimmed) + let key = RemoteSSHConnectionPolicy.optionKey(trimmed) let value = sshOptionValue(trimmed) switch key { @@ -780,16 +741,6 @@ enum TerminalSSHSessionDetector { return "\(loginName)@\(trimmedDestination)" } - private static func sshOptionKey(_ option: String) -> String? { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) - .first - .map(String.init)? - .lowercased() - } - private static func sshOptionValue(_ option: String) -> String? { let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 3446b720b3e..ba40e6b7115 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -1183,48 +1183,78 @@ final class WindowTerminalPortal: HostedViewPortalRegistry { return true } - private func synchronizeHostedView(withId hostedId: ObjectIdentifier) { - guard ensureInstalled() else { return } - guard var entry = entriesByHostedId[hostedId] else { return } - guard let hostedView = entry.hostedView else { - entriesByHostedId.removeValue(forKey: hostedId) - return - } - guard let anchorView = entry.anchorView, let window else { - if entry.visibleInUI { - let shouldPreserveVisibleOnTransient = !hostedView.isHidden && - scheduleTransientRecoveryRetryIfNeeded( - forHostedId: hostedId, - entry: &entry, - hostedView: hostedView, - reason: "missingAnchorOrWindow" - ) - if shouldPreserveVisibleOnTransient { + /// Hide `hostedView` for a `synchronizeHostedView` entry that can't be positioned this + /// pass (missing anchor/window, anchor moved to a different window, or host bounds not + /// yet laid out), scheduling a transient-recovery retry so a genuinely-transient geometry + /// hiccup doesn't get stuck hidden. Falls back to a full `synchronizeAllHostedViews` when + /// transient recovery is disabled (production builds), so that path isn't left with no + /// recovery at all. + /// + /// Returns `true` if the caller should return immediately because the current visible + /// frame was preserved pending a transient-recovery retry (i.e. `hostedView.isHidden` + /// was deliberately left unchanged this pass). + @discardableResult + private func hideHostedViewSchedulingRecovery( + hostedId: ObjectIdentifier, + entry: inout Entry, + hostedView: GhosttySurfaceScrollView, + reason: String + ) -> Bool { + if entry.visibleInUI { + let shouldPreserveVisibleOnTransient = !hostedView.isHidden && + scheduleTransientRecoveryRetryIfNeeded( + forHostedId: hostedId, + entry: &entry, + hostedView: hostedView, + reason: reason + ) + if shouldPreserveVisibleOnTransient { #if DEBUG - dlog( - "portal.hidden.deferKeep hosted=\(portalDebugToken(hostedView)) " + - "reason=missingAnchorOrWindow frame=\(portalDebugFrame(hostedView.frame))" - ) + dlog( + "portal.hidden.deferKeep hosted=\(portalDebugToken(hostedView)) " + + "reason=\(reason) frame=\(portalDebugFrame(hostedView.frame))" + ) #endif - return - } - } else { - resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry) + return true } + } else { + resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry) + } #if DEBUG - if !hostedView.isHidden { - dlog("portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 reason=missingAnchorOrWindow") - } + if !hostedView.isHidden { + dlog("portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 reason=\(reason)") + } #endif - hostedView.isHidden = true - if entry.visibleInUI { + hostedView.isHidden = true + if entry.visibleInUI { + if Self.transientRecoveryEnabled { _ = scheduleTransientRecoveryRetryIfNeeded( forHostedId: hostedId, entry: &entry, hostedView: hostedView, - reason: "missingAnchorOrWindow" + reason: reason ) + } else { + scheduleDeferredFullSynchronizeAll() } + } + return false + } + + private func synchronizeHostedView(withId hostedId: ObjectIdentifier) { + guard ensureInstalled() else { return } + guard var entry = entriesByHostedId[hostedId] else { return } + guard let hostedView = entry.hostedView else { + entriesByHostedId.removeValue(forKey: hostedId) + return + } + guard let anchorView = entry.anchorView, let window else { + _ = hideHostedViewSchedulingRecovery( + hostedId: hostedId, + entry: &entry, + hostedView: hostedView, + reason: "missingAnchorOrWindow" + ) return } guard anchorView.window === window else { @@ -1236,35 +1266,12 @@ final class WindowTerminalPortal: HostedViewPortalRegistry { ) } #endif - if entry.visibleInUI { - let shouldPreserveVisibleOnTransient = !hostedView.isHidden && - scheduleTransientRecoveryRetryIfNeeded( - forHostedId: hostedId, - entry: &entry, - hostedView: hostedView, - reason: "anchorWindowMismatch" - ) - if shouldPreserveVisibleOnTransient { -#if DEBUG - dlog( - "portal.hidden.deferKeep hosted=\(portalDebugToken(hostedView)) " + - "reason=anchorWindowMismatch frame=\(portalDebugFrame(hostedView.frame))" - ) -#endif - return - } - } else { - resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry) - } - hostedView.isHidden = true - if entry.visibleInUI { - _ = scheduleTransientRecoveryRetryIfNeeded( - forHostedId: hostedId, - entry: &entry, - hostedView: hostedView, - reason: "anchorWindowMismatch" - ) - } + _ = hideHostedViewSchedulingRecovery( + hostedId: hostedId, + entry: &entry, + hostedView: hostedView, + reason: "anchorWindowMismatch" + ) return } @@ -1290,39 +1297,12 @@ final class WindowTerminalPortal: HostedViewPortalRegistry { "anchor=\(portalDebugFrame(frameInHost)) visibleInUI=\(entry.visibleInUI ? 1 : 0)" ) #endif - if entry.visibleInUI { - let shouldPreserveVisibleOnTransient = !hostedView.isHidden && - scheduleTransientRecoveryRetryIfNeeded( - forHostedId: hostedId, - entry: &entry, - hostedView: hostedView, - reason: "hostBoundsNotReady" - ) - if shouldPreserveVisibleOnTransient { -#if DEBUG - dlog( - "portal.hidden.deferKeep hosted=\(portalDebugToken(hostedView)) " + - "reason=hostBoundsNotReady frame=\(portalDebugFrame(hostedView.frame))" - ) -#endif - return - } - } else { - resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry) - } - hostedView.isHidden = true - if entry.visibleInUI { - if Self.transientRecoveryEnabled { - _ = scheduleTransientRecoveryRetryIfNeeded( - forHostedId: hostedId, - entry: &entry, - hostedView: hostedView, - reason: "hostBoundsNotReady" - ) - } else { - scheduleDeferredFullSynchronizeAll() - } - } + _ = hideHostedViewSchedulingRecovery( + hostedId: hostedId, + entry: &entry, + hostedView: hostedView, + reason: "hostBoundsNotReady" + ) return } let hasFiniteFrame = diff --git a/Sources/TypingProfiler.swift b/Sources/TypingProfiler.swift new file mode 100644 index 00000000000..a6406fa107c --- /dev/null +++ b/Sources/TypingProfiler.swift @@ -0,0 +1,354 @@ +import AppKit +import SwiftUI +import Bonsplit +import CoreServices +import UserNotifications +import WebKit +import Combine +import ObjectiveC.runtime +import Darwin + +#if DEBUG +enum ProgramaTypingTiming { + static let isEnabled: Bool = { + let environment = ProcessInfo.processInfo.environment + if environment["PROGRAMA_TYPING_TIMING_LOGS"] == "1" || environment["PROGRAMA_KEY_LATENCY_PROBE"] == "1" { + return true + } + let defaults = UserDefaults.standard + return defaults.bool(forKey: "programaTypingTimingLogs") || defaults.bool(forKey: "programaKeyLatencyProbe") + }() + static let isVerboseProbeEnabled: Bool = { + let environment = ProcessInfo.processInfo.environment + if environment["PROGRAMA_KEY_LATENCY_PROBE"] == "1" { + return true + } + return UserDefaults.standard.bool(forKey: "programaKeyLatencyProbe") + }() + private static let delayLogThresholdMs: Double = 6.0 + private static let durationLogThresholdMs: Double = 1.0 + + @inline(__always) + static func start() -> TimeInterval? { + guard isEnabled else { return nil } + return ProcessInfo.processInfo.systemUptime + } + + @inline(__always) + static func logEventDelay(path: String, event: NSEvent) { + guard isEnabled else { return } + 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 } + dlog("typing.delay path=\(path) delayMs=\(format(delayMs)) \(eventFields(event))") + } + + @inline(__always) + static func logDuration(path: String, startedAt: TimeInterval?, event: NSEvent? = nil, extra: String? = nil) { + ProgramaMainThreadTurnProfiler.endMeasure(path, startedAt: startedAt) + guard let startedAt else { return } + let elapsedMs = max(0, (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0) + let delayMs: Double? = { + guard let event, event.timestamp > 0 else { return nil } + return max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000.0) + }() + guard shouldLog(delayMs: delayMs, elapsedMs: elapsedMs) else { return } + var line = "typing.timing path=\(path) elapsedMs=\(format(elapsedMs))" + if let event { + line += " \(eventFields(event))" + if let delayMs { + line += " delayMs=\(format(delayMs))" + } + } + if let extra, !extra.isEmpty { + line += " \(extra)" + } + dlog(line) + } + + @inline(__always) + static func logBreakdown( + path: String, + totalMs: Double, + event: NSEvent? = nil, + thresholdMs: Double = 2.0, + parts: [(String, Double)], + extra: String? = nil + ) { + guard isEnabled else { return } + let delayMs: Double? = { + guard let event, event.timestamp > 0 else { return nil } + return max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000.0) + }() + let hasSlowPart = parts.contains { $0.1 >= thresholdMs } + guard isVerboseProbeEnabled || totalMs >= thresholdMs || hasSlowPart || (delayMs ?? 0) >= delayLogThresholdMs else { + return + } + var line = "typing.phase path=\(path) totalMs=\(format(totalMs))" + if let event { + line += " \(eventFields(event))" + } + if let delayMs { + line += " delayMs=\(format(delayMs))" + } + for (name, value) in parts where isVerboseProbeEnabled || value >= 0.05 { + line += " \(name)=\(format(value))" + } + if let extra, !extra.isEmpty { + line += " \(extra)" + } + 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)" + } + + @inline(__always) + private static func shouldLog(delayMs: Double?, elapsedMs: Double?) -> Bool { + if isVerboseProbeEnabled { + return true + } + if let delayMs, delayMs >= delayLogThresholdMs { + return true + } + if let elapsedMs, elapsedMs >= durationLogThresholdMs { + return true + } + return false + } + + @inline(__always) + private static func format(_ value: Double) -> String { + String(format: "%.2f", value) + } +} + +final class ProgramaMainRunLoopStallMonitor { + static let shared = ProgramaMainRunLoopStallMonitor() + + private let thresholdMs: Double = 8.0 + private var observer: CFRunLoopObserver? + private var installed = false + private var lastActivity: CFRunLoopActivity? + private var lastTimestamp: TimeInterval? + + private init() {} + + func installIfNeeded() { + guard ProgramaTypingTiming.isEnabled else { return } + guard !installed else { return } + + var context = CFRunLoopObserverContext( + version: 0, + info: Unmanaged.passUnretained(self).toOpaque(), + retain: nil, + release: nil, + copyDescription: nil + ) + + observer = CFRunLoopObserverCreate( + kCFAllocatorDefault, + CFRunLoopActivity.allActivities.rawValue, + true, + CFIndex.max, + { _, activity, info in + guard let info else { return } + let monitor = Unmanaged.fromOpaque(info).takeUnretainedValue() + monitor.handle(activity: activity) + }, + &context + ) + + guard let observer else { return } + CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes) + installed = true + } + + private func handle(activity: CFRunLoopActivity) { + let now = ProcessInfo.processInfo.systemUptime + defer { + lastActivity = activity + lastTimestamp = now + } + + guard let lastActivity, let lastTimestamp else { return } + let elapsedMs = max(0, (now - lastTimestamp) * 1000.0) + guard elapsedMs >= thresholdMs else { return } + if lastActivity == .beforeWaiting && activity == .afterWaiting { + return + } + + let mode = CFRunLoopCopyCurrentMode(CFRunLoopGetMain()).map { String(describing: $0) } ?? "nil" + let firstResponder = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let currentEvent = NSApp.currentEvent.map { + "eventType=\($0.type.rawValue) keyCode=\($0.keyCode) mods=\($0.modifierFlags.rawValue)" + } ?? "event=nil" + dlog( + "runloop.stall gapMs=\(String(format: "%.2f", elapsedMs)) prev=\(label(for: lastActivity)) " + + "next=\(label(for: activity)) mode=\(mode) firstResponder=\(firstResponder) \(currentEvent)" + ) + } + + private func label(for activity: CFRunLoopActivity) -> String { + switch activity { + case .entry: + return "entry" + case .beforeTimers: + return "beforeTimers" + case .beforeSources: + return "beforeSources" + case .beforeWaiting: + return "beforeWaiting" + case .afterWaiting: + return "afterWaiting" + case .exit: + return "exit" + default: + return "unknown(\(activity.rawValue))" + } + } +} + +final class ProgramaMainThreadTurnProfiler { + static let shared = ProgramaMainThreadTurnProfiler() + + private struct BucketStats { + var count: Int = 0 + var totalMs: Double = 0 + var maxMs: Double = 0 + } + + private let trackedThresholdMs: Double = 3.0 + private let countThreshold: Int = 16 + private var observer: CFRunLoopObserver? + private var installed = false + private var turnStart: TimeInterval? + private var buckets: [String: BucketStats] = [:] + + private init() {} + + @inline(__always) + static func endMeasure(_ bucket: String, startedAt: TimeInterval?) { + guard let startedAt, ProgramaTypingTiming.isEnabled, Thread.isMainThread else { return } + let elapsedMs = max(0, (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0) + shared.record(bucket: bucket, elapsedMs: elapsedMs, count: 1) + } + + func installIfNeeded() { + guard ProgramaTypingTiming.isEnabled else { return } + guard !installed else { return } + + var context = CFRunLoopObserverContext( + version: 0, + info: Unmanaged.passUnretained(self).toOpaque(), + retain: nil, + release: nil, + copyDescription: nil + ) + + observer = CFRunLoopObserverCreate( + kCFAllocatorDefault, + CFRunLoopActivity.allActivities.rawValue, + true, + CFIndex.max, + { _, activity, info in + guard let info else { return } + let profiler = Unmanaged.fromOpaque(info).takeUnretainedValue() + profiler.handle(activity: activity) + }, + &context + ) + + guard let observer else { return } + CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes) + installed = true + } + + private func handle(activity: CFRunLoopActivity) { + let now = ProcessInfo.processInfo.systemUptime + switch activity { + case .entry, .afterWaiting: + turnStart = now + buckets.removeAll(keepingCapacity: true) + case .beforeWaiting, .exit: + flushTurn(at: now, nextActivity: activity) + default: + break + } + } + + private func record(bucket: String, elapsedMs: Double, count: Int) { + if turnStart == nil { + turnStart = ProcessInfo.processInfo.systemUptime + } + var stats = buckets[bucket, default: BucketStats()] + stats.count += count + stats.totalMs += elapsedMs + stats.maxMs = max(stats.maxMs, elapsedMs) + buckets[bucket] = stats + } + + private func flushTurn(at now: TimeInterval, nextActivity: CFRunLoopActivity) { + defer { + turnStart = nil + buckets.removeAll(keepingCapacity: true) + } + + guard let turnStart else { return } + guard !buckets.isEmpty else { return } + + let turnMs = max(0, (now - turnStart) * 1000.0) + let trackedMs = buckets.values.reduce(0) { $0 + $1.totalMs } + let totalCount = buckets.values.reduce(0) { $0 + $1.count } + guard trackedMs >= trackedThresholdMs || totalCount >= countThreshold else { return } + + let mode = CFRunLoopCopyCurrentMode(CFRunLoopGetMain()).map { String(describing: $0) } ?? "nil" + let firstResponder = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let eventSummary = NSApp.currentEvent.map { + "eventType=\($0.type.rawValue) keyCode=\($0.keyCode) mods=\($0.modifierFlags.rawValue)" + } ?? "event=nil" + let bucketSummary = buckets + .sorted { + if abs($0.value.totalMs - $1.value.totalMs) > 0.01 { + return $0.value.totalMs > $1.value.totalMs + } + return $0.value.count > $1.value.count + } + .prefix(8) + .map { key, value in + if value.totalMs > 0.05 || value.maxMs > 0.05 { + return "\(key)=\(value.count)/\(String(format: "%.2f", value.totalMs))/\(String(format: "%.2f", value.maxMs))" + } + return "\(key)=\(value.count)" + } + .joined(separator: " ") + + 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)" + ) + } + + private func label(for activity: CFRunLoopActivity) -> String { + switch activity { + case .entry: + return "entry" + case .beforeTimers: + return "beforeTimers" + case .beforeSources: + return "beforeSources" + case .beforeWaiting: + return "beforeWaiting" + case .afterWaiting: + return "afterWaiting" + case .exit: + return "exit" + default: + return "unknown(\(activity.rawValue))" + } + } +} +#endif diff --git a/Sources/VSCodeIntegration.swift b/Sources/VSCodeIntegration.swift new file mode 100644 index 00000000000..0b5bb6cdc7a --- /dev/null +++ b/Sources/VSCodeIntegration.swift @@ -0,0 +1,593 @@ +import AppKit +import SwiftUI +import Bonsplit +import CoreServices +import UserNotifications +import WebKit +import Combine +import ObjectiveC.runtime +import Darwin + +enum VSCodeServeWebURLBuilder { + static func extractWebUIURL(from output: String) -> URL? { + let prefix = "Web UI available at " + for line in output.split(whereSeparator: \.isNewline).reversed() { + guard let range = line.range(of: prefix) else { continue } + let rawURL = line[range.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines) + guard !rawURL.isEmpty, let url = URL(string: rawURL) else { continue } + return url + } + return nil + } + + static func openFolderURL(baseWebUIURL: URL, directoryPath: String) -> URL? { + var components = URLComponents(url: baseWebUIURL, resolvingAgainstBaseURL: false) + var queryItems = components?.queryItems ?? [] + queryItems.removeAll { $0.name == "folder" } + queryItems.append(URLQueryItem(name: "folder", value: directoryPath)) + components?.queryItems = queryItems + return components?.url + } +} + +struct VSCodeCLILaunchConfiguration { + let executableURL: URL + let argumentsPrefix: [String] + let environment: [String: String] +} + +enum VSCodeCLILaunchConfigurationBuilder { + static func launchConfiguration( + vscodeApplicationURL: URL, + baseEnvironment: [String: String] = ProcessInfo.processInfo.environment, + isExecutableAtPath: (String) -> Bool = { FileManager.default.isExecutableFile(atPath: $0) } + ) -> VSCodeCLILaunchConfiguration? { + let contentsURL = vscodeApplicationURL.appendingPathComponent("Contents", isDirectory: true) + let codeTunnelURL = contentsURL.appendingPathComponent("Resources/app/bin/code-tunnel", isDirectory: false) + guard isExecutableAtPath(codeTunnelURL.path) else { return nil } + + var environment = baseEnvironment + environment["ELECTRON_RUN_AS_NODE"] = "1" + environment.removeValue(forKey: "VSCODE_NODE_OPTIONS") + environment.removeValue(forKey: "VSCODE_NODE_REPL_EXTERNAL_MODULE") + if let nodeOptions = environment["NODE_OPTIONS"] { + environment["VSCODE_NODE_OPTIONS"] = nodeOptions + } + if let nodeReplExternalModule = environment["NODE_REPL_EXTERNAL_MODULE"] { + environment["VSCODE_NODE_REPL_EXTERNAL_MODULE"] = nodeReplExternalModule + } + environment.removeValue(forKey: "NODE_OPTIONS") + environment.removeValue(forKey: "NODE_REPL_EXTERNAL_MODULE") + environment["VSCODE_CLI_USE_FILE_KEYRING"] = "1" + + return VSCodeCLILaunchConfiguration( + executableURL: codeTunnelURL, + argumentsPrefix: [], + environment: environment + ) + } +} + +final class VSCodeServeWebController { + static let shared = VSCodeServeWebController() + private static let serveWebStartupTimeoutSeconds: TimeInterval = 60 + + private let queue = DispatchQueue(label: "programa.vscode.serveWeb") + private let launchQueue = DispatchQueue(label: "programa.vscode.serveWeb.launch") + private let launchProcessOverride: ((URL, UInt64) -> (process: Process, url: URL)?)? + private var serveWebProcess: Process? + private var launchingProcess: Process? + private var connectionTokenFilesByProcessID: [ObjectIdentifier: URL] = [:] + private var serveWebURL: URL? + private var pendingCompletions: [(generation: UInt64, completion: (URL?) -> Void)] = [] + private var isLaunching = false + private var activeLaunchGeneration: UInt64? + private var lifecycleGeneration: UInt64 = 0 +#if DEBUG + private var testingTrackedProcesses: [Process] = [] +#endif + + private init(launchProcessOverride: ((URL, UInt64) -> (process: Process, url: URL)?)? = nil) { + self.launchProcessOverride = launchProcessOverride + } + +#if DEBUG + static func makeForTesting( + launchProcessOverride: @escaping (URL, UInt64) -> (process: Process, url: URL)? + ) -> VSCodeServeWebController { + VSCodeServeWebController(launchProcessOverride: launchProcessOverride) + } + + func trackConnectionTokenFileForTesting( + _ connectionTokenFileURL: URL, + setAsLaunchingProcess: Bool = false, + setAsServeWebProcess: Bool = false + ) { + let process = Process() + queue.sync { + if setAsLaunchingProcess { + self.launchingProcess = process + } + if setAsServeWebProcess { + self.serveWebProcess = process + } + if !setAsLaunchingProcess && !setAsServeWebProcess { + self.testingTrackedProcesses.append(process) + } + self.connectionTokenFilesByProcessID[ObjectIdentifier(process)] = connectionTokenFileURL + } + } +#endif + + func ensureServeWebURL(vscodeApplicationURL: URL, completion: @escaping (URL?) -> Void) { + queue.async { + if let process = self.serveWebProcess, + process.isRunning, + let url = self.serveWebURL { + DispatchQueue.main.async { + completion(url) + } + return + } + + let completionGeneration = self.lifecycleGeneration + self.pendingCompletions.append((generation: completionGeneration, completion: completion)) + guard !self.isLaunching else { return } + + self.isLaunching = true + let launchGeneration = completionGeneration + self.activeLaunchGeneration = launchGeneration + + self.launchQueue.async { + let shouldLaunch = self.queue.sync { + self.lifecycleGeneration == launchGeneration + } + guard shouldLaunch else { + self.queue.async { + guard self.activeLaunchGeneration == launchGeneration else { return } + self.isLaunching = false + self.activeLaunchGeneration = nil + } + return + } + let launchResult = self.launchServeWebProcess( + vscodeApplicationURL: vscodeApplicationURL, + expectedGeneration: launchGeneration + ) + self.queue.async { + guard self.activeLaunchGeneration == launchGeneration else { + if let process = launchResult?.process, process.isRunning { + process.terminate() + } + return + } + self.isLaunching = false + self.activeLaunchGeneration = nil + + guard self.lifecycleGeneration == launchGeneration else { + if let launchedProcess = launchResult?.process, + self.launchingProcess === launchedProcess { + self.launchingProcess = nil + } + if let process = launchResult?.process, process.isRunning { + process.terminate() + } + return + } + + if let launchResult { + self.launchingProcess = nil + self.serveWebProcess = launchResult.process + self.serveWebURL = launchResult.url + } else { + self.launchingProcess = nil + self.serveWebProcess = nil + self.serveWebURL = nil + } + + var completions: [(URL?) -> Void] = [] + var remaining: [(generation: UInt64, completion: (URL?) -> Void)] = [] + for pending in self.pendingCompletions { + if pending.generation == launchGeneration { + completions.append(pending.completion) + } else { + remaining.append(pending) + } + } + self.pendingCompletions = remaining + let resolvedURL = self.serveWebURL + DispatchQueue.main.async { + completions.forEach { $0(resolvedURL) } + } + } + } + } + } + + func stop() { + let (processes, tokenFileURLs, completions): ([Process], [URL], [(URL?) -> Void]) = queue.sync { + self.lifecycleGeneration &+= 1 + self.isLaunching = false + self.activeLaunchGeneration = nil + var processes: [Process] = [] + if let process = self.serveWebProcess { + processes.append(process) + } + if let process = self.launchingProcess, + !processes.contains(where: { $0 === process }) { + processes.append(process) + } + self.serveWebProcess = nil + self.launchingProcess = nil +#if DEBUG + self.testingTrackedProcesses.removeAll() +#endif + var tokenFileURLs = processes.compactMap { + self.connectionTokenFilesByProcessID.removeValue(forKey: ObjectIdentifier($0)) + } + tokenFileURLs.append(contentsOf: self.connectionTokenFilesByProcessID.values) + self.connectionTokenFilesByProcessID.removeAll() + self.serveWebURL = nil + let completions = self.pendingCompletions.map(\.completion) + self.pendingCompletions.removeAll() + return (processes, tokenFileURLs, completions) + } + + for tokenFileURL in tokenFileURLs where tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { + Self.removeConnectionTokenFile(at: tokenFileURL) + } + + for process in processes where process.isRunning { + process.terminate() + } + + if !completions.isEmpty { + DispatchQueue.main.async { + completions.forEach { $0(nil) } + } + } + } + + func restart(vscodeApplicationURL: URL, completion: @escaping (URL?) -> Void) { + stop() + ensureServeWebURL(vscodeApplicationURL: vscodeApplicationURL, completion: completion) + } + + private func launchServeWebProcess( + vscodeApplicationURL: URL, + expectedGeneration: UInt64 + ) -> (process: Process, url: URL)? { + if let launchProcessOverride { + return launchProcessOverride(vscodeApplicationURL, expectedGeneration) + } + + guard let launchConfiguration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration( + vscodeApplicationURL: vscodeApplicationURL + ) else { return nil } + + guard let connectionTokenFileURL = Self.makeConnectionTokenFile() else { + return nil + } + + let process = Process() + process.executableURL = launchConfiguration.executableURL + // #21: reuse the port VS Code assigned on a previous run so the embedded browser + // keeps the same URL across restarts. ServeWebPortStore returns the persisted port + // only when it is still bindable, otherwise "0" (OS-assigned) — so a now-occupied + // port falls back gracefully instead of failing the launch. The --server-data-dir + // and persistent connection-token already fix Settings Sync / OAuth auth. + process.arguments = launchConfiguration.argumentsPrefix + [ + "serve-web", + "--accept-server-license-terms", + "--host", "127.0.0.1", + "--port", ServeWebPortStore.portArgument(persistedIn: Self.vscodeServerDataDir), + "--server-data-dir", Self.vscodeServerDataDir?.path ?? NSTemporaryDirectory(), + "--connection-token-file", connectionTokenFileURL.path, + ] + process.environment = launchConfiguration.environment + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + let collector = ServeWebOutputCollector() + let outputReader: (FileHandle) -> Void = { fileHandle in + let data = fileHandle.availableData + guard !data.isEmpty else { return } + collector.append(data) + } + stdoutPipe.fileHandleForReading.readabilityHandler = outputReader + stderrPipe.fileHandleForReading.readabilityHandler = outputReader + + process.terminationHandler = { [weak self] terminatedProcess in + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + Self.drainAvailableOutput(from: stdoutPipe.fileHandleForReading, collector: collector) + Self.drainAvailableOutput(from: stderrPipe.fileHandleForReading, collector: collector) + collector.markProcessExited() + self?.queue.async { + guard let self else { return } + if self.launchingProcess === terminatedProcess { + self.launchingProcess = nil + } + if self.serveWebProcess === terminatedProcess { + self.serveWebProcess = nil + self.serveWebURL = nil + } + if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue( + forKey: ObjectIdentifier(terminatedProcess) + ), tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { + Self.removeConnectionTokenFile(at: tokenFileURL) + } + } + } + + let didStart: Bool = queue.sync { + guard self.lifecycleGeneration == expectedGeneration, + self.activeLaunchGeneration == expectedGeneration else { + return false + } + self.launchingProcess = process + self.connectionTokenFilesByProcessID[ObjectIdentifier(process)] = connectionTokenFileURL + do { + try process.run() + return true + } catch { + if self.launchingProcess === process { + self.launchingProcess = nil + } + if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue( + forKey: ObjectIdentifier(process) + ), tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { + Self.removeConnectionTokenFile(at: tokenFileURL) + } + return false + } + } + guard didStart else { + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + if connectionTokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { + Self.removeConnectionTokenFile(at: connectionTokenFileURL) + } + return nil + } + + guard collector.waitForURL(timeoutSeconds: Self.serveWebStartupTimeoutSeconds), + let serveWebURL = collector.webUIURL else { + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + if process.isRunning { + process.terminate() + } else { + queue.sync { + if self.launchingProcess === process { + self.launchingProcess = nil + } + if self.serveWebProcess === process { + self.serveWebProcess = nil + self.serveWebURL = nil + } + if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue( + forKey: ObjectIdentifier(process) + ), tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { + Self.removeConnectionTokenFile(at: tokenFileURL) + } + } + } + return nil + } + + // #21: remember the assigned port so the next launch can request it again. + if let assignedPort = serveWebURL.port { + ServeWebPortStore.persist(port: assignedPort, in: Self.vscodeServerDataDir) + } + + return (process, serveWebURL) + } + + private static func drainAvailableOutput(from fileHandle: FileHandle, collector: ServeWebOutputCollector) { + while true { + let data = fileHandle.availableData + guard !data.isEmpty else { return } + collector.append(data) + } + } + + /// Stable Application Support directory for VS Code serve-web state. + /// Mirrors the "programa" subdirectory convention used elsewhere in the app. + private static var vscodeServerDataDir: URL? { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) + .first? + .appendingPathComponent("programa", isDirectory: true) + .appendingPathComponent("vscode-server", isDirectory: true) + } + + private static func randomConnectionToken() -> String { + UUID().uuidString.replacingOccurrences(of: "-", with: "") + } + + /// Returns a URL for the connection token file. Prefers a persistent file + /// under Application Support so the token (and the browser's vscode-tkn + /// cookie) survives Programa restarts (issue #21). Falls back to an + /// ephemeral temp-dir file when Application Support is unavailable. + private static func makeConnectionTokenFile() -> URL? { + if let persistentURL = vscodeServerDataDir? + .appendingPathComponent("connection-token", isDirectory: false) { + if let url = makePersistentConnectionTokenFile(at: persistentURL) { + return url + } + } + return makeEphemeralConnectionTokenFile() + } + + private static func makePersistentConnectionTokenFile(at tokenFileURL: URL) -> URL? { + let dir = tokenFileURL.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + // Reuse an existing non-empty token so the browser cookie stays valid. + if let existingData = try? Data(contentsOf: tokenFileURL), !existingData.isEmpty { + return tokenFileURL + } + let token = randomConnectionToken() + guard let tokenData = token.data(using: .utf8) else { return nil } + let fd = open(tokenFileURL.path, O_WRONLY | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR) + guard fd >= 0 else { return nil } + defer { _ = close(fd) } + let wroteAllBytes = tokenData.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return false } + return write(fd, baseAddress, rawBuffer.count) == rawBuffer.count + } + guard wroteAllBytes else { + try? FileManager.default.removeItem(at: tokenFileURL) + return nil + } + return tokenFileURL + } + + private static func makeEphemeralConnectionTokenFile() -> URL? { + let token = randomConnectionToken() + let tokenFileName = "cmux-vscode-token-\(UUID().uuidString)" + let tokenFileURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent(tokenFileName, isDirectory: false) + guard let tokenData = token.data(using: .utf8) else { return nil } + let fileDescriptor = open(tokenFileURL.path, O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR) + guard fileDescriptor >= 0 else { return nil } + defer { _ = close(fileDescriptor) } + let wroteAllBytes = tokenData.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return false } + return write(fileDescriptor, baseAddress, rawBuffer.count) == rawBuffer.count + } + guard wroteAllBytes else { + removeConnectionTokenFile(at: tokenFileURL) + return nil + } + return tokenFileURL + } + + private static func removeConnectionTokenFile(at url: URL) { + try? FileManager.default.removeItem(at: url) + } +} + +final class ServeWebOutputCollector { + private let lock = NSLock() + private let semaphore = DispatchSemaphore(value: 0) + private var outputBuffer = "" + private var resolvedURL: URL? + private var didSignal = false + + var webUIURL: URL? { + lock.lock() + defer { lock.unlock() } + return resolvedURL + } + + func append(_ data: Data) { + guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { return } + lock.lock() + defer { lock.unlock() } + guard resolvedURL == nil else { return } + outputBuffer.append(text) + while let newlineIndex = outputBuffer.firstIndex(where: \.isNewline) { + let line = String(outputBuffer[.. Bool { + if webUIURL != nil { return true } + _ = semaphore.wait(timeout: .now() + timeoutSeconds) + return webUIURL != nil + } +} + +/// Persists the VS Code serve-web port across restarts (#21) so the embedded browser +/// keeps the same URL. The port `code serve-web` assigns is written under the serve-web +/// data dir and reused on the next launch — but only when it is still bindable, so a +/// now-occupied port falls back to an OS-assigned one instead of failing the launch. +enum ServeWebPortStore { + static let fileName = "serve-web-port" + + /// The `--port` argument for `code serve-web`: the persisted port when it is valid and + /// currently free, otherwise "0" (let the OS assign one). `isPortAvailable` is injectable + /// for testing; it defaults to a real loopback bind probe. + static func portArgument( + persistedIn directory: URL?, + isPortAvailable: (Int) -> Bool = ServeWebPortStore.isPortAvailable + ) -> String { + guard let url = portFileURL(in: directory), + let data = try? Data(contentsOf: url), + let raw = String(data: data, encoding: .utf8), + let port = parsePort(raw), + isPortAvailable(port) else { + return "0" + } + return String(port) + } + + /// Records the port VS Code assigned so the next launch can request it again. No-op for + /// out-of-range ports or when the data dir is unavailable. + static func persist(port: Int, in directory: URL?) { + guard isValidPort(port), let url = portFileURL(in: directory) else { return } + let dir = url.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try? Data(String(port).utf8).write(to: url, options: .atomic) + } + + /// Parses a stored port string, returning nil for malformed or out-of-range values. + static func parsePort(_ raw: String) -> Int? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard let port = Int(trimmed), isValidPort(port) else { return nil } + return port + } + + /// True when a TCP socket can bind 127.0.0.1:port right now. + static func isPortAvailable(_ port: Int) -> Bool { + guard isValidPort(port) else { return false } + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return false } + defer { close(fd) } + var reuse: Int32 = 1 + _ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout.size)) + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = in_port_t(UInt16(port)).bigEndian + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + let bound = withUnsafePointer(to: &addr) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + bind(fd, sockaddrPointer, socklen_t(MemoryLayout.size)) + } + } + return bound == 0 + } + + private static func isValidPort(_ port: Int) -> Bool { (1...65535).contains(port) } + + private static func portFileURL(in directory: URL?) -> URL? { + directory?.appendingPathComponent(fileName, isDirectory: false) + } +} diff --git a/Sources/WindowSwizzles.swift b/Sources/WindowSwizzles.swift new file mode 100644 index 00000000000..6e67f8ae87c --- /dev/null +++ b/Sources/WindowSwizzles.swift @@ -0,0 +1,734 @@ +import AppKit +import SwiftUI +import Bonsplit +import CoreServices +import UserNotifications +import WebKit +import Combine +import ObjectiveC.runtime +import Darwin + +#if DEBUG +// Widened from `private` to `internal`: AppDelegate.setWindowFirstResponderGuardTesting(...) +// / .clearWindowFirstResponderGuardTesting() (in AppDelegate.swift) write these directly. Refs #95. +var programaFirstResponderGuardCurrentEventOverride: NSEvent? +var programaFirstResponderGuardHitViewOverride: NSView? +#endif +private var programaFirstResponderGuardCurrentEventContext: NSEvent? +private var programaFirstResponderGuardHitViewContext: NSView? +private var programaFirstResponderGuardContextWindowNumber: Int? +private var programaBrowserReturnForwardingDepth = 0 +private var programaWindowFirstResponderBypassDepth = 0 +private var programaFieldEditorOwningWebViewAssociationKey: UInt8 = 0 + +@discardableResult +func cmuxWithWindowFirstResponderBypass(_ body: () -> T) -> T { + programaWindowFirstResponderBypassDepth += 1 + defer { + programaWindowFirstResponderBypassDepth = max(0, programaWindowFirstResponderBypassDepth - 1) + } + return body() +} + +func programaIsWindowFirstResponderBypassActive() -> Bool { + programaWindowFirstResponderBypassDepth > 0 +} + +private final class ProgramaFieldEditorOwningWebViewBox: NSObject { + weak var webView: ProgramaWebView? + + init(webView: ProgramaWebView?) { + self.webView = webView + } +} + +// Widened from `private extension` to `extension`: AppDelegate.installWindowResponderSwizzles() +// (in AppDelegate.swift) references these @objc methods via #selector(...) for method swizzling. Refs #95. +extension NSApplication { + @objc func programa_applicationSendEvent(_ event: NSEvent) { +#if DEBUG + let typingTimingStart = event.type == .keyDown ? ProgramaTypingTiming.start() : nil + let phaseTotalStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 + if event.type == .keyDown { + ProgramaTypingTiming.logEventDelay(path: "app.sendEvent", event: event) + } + defer { + if event.type == .keyDown { + let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0 + ProgramaTypingTiming.logBreakdown( + path: "app.sendEvent.phase", + totalMs: totalMs, + event: event, + thresholdMs: 1.0, + parts: [("dispatchMs", totalMs)] + ) + ProgramaTypingTiming.logDuration( + path: "app.sendEvent", + startedAt: typingTimingStart, + event: event + ) + } + } +#endif + programa_applicationSendEvent(event) + } +} + +// Widened from `private extension` to `extension`: AppDelegate.installWindowResponderSwizzles() +// (in AppDelegate.swift) references these @objc methods via #selector(...) for method swizzling. Refs #95. +extension NSWindow { + @objc func programa_makeFirstResponder(_ responder: NSResponder?) -> Bool { + if programaIsWindowFirstResponderBypassActive() { +#if DEBUG + dlog( + "focus.guard bypassFirstResponder responder=\(String(describing: responder.map { type(of: $0) })) " + + "window=\(ObjectIdentifier(self))" + ) +#endif + return false + } + + let currentEvent = Self.programaCurrentEvent(for: self) + let responderWebView = responder.flatMap { + Self.programaOwningWebView(for: $0, in: self, event: currentEvent) + } + var pointerInitiatedWebFocus = false + + if AppDelegate.shared?.shouldBlockFirstResponderChangeWhileCommandPaletteVisible( + window: self, + responder: responder + ) == true { +#if DEBUG + dlog( + "focus.guard commandPaletteBlocked responder=\(String(describing: responder.map { type(of: $0) })) " + + "window=\(ObjectIdentifier(self))" + ) +#endif + return false + } + + if let responder, + let webView = responderWebView, + !webView.allowsFirstResponderAcquisitionEffective { + let pointerInitiatedFocus = Self.programaShouldAllowPointerInitiatedWebViewFocus( + window: self, + webView: webView, + event: currentEvent + ) + if pointerInitiatedFocus { + pointerInitiatedWebFocus = true +#if DEBUG + dlog( + "focus.guard allowPointerFirstResponder responder=\(String(describing: type(of: responder))) " + + "window=\(ObjectIdentifier(self)) " + + "web=\(ObjectIdentifier(webView)) " + + "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + + "pointerDepth=\(webView.debugPointerFocusAllowanceDepth) " + + "eventType=\(currentEvent.map { String(describing: $0.type) } ?? "nil")" + ) +#endif + } else { +#if DEBUG + dlog( + "focus.guard blockedFirstResponder responder=\(String(describing: type(of: responder))) " + + "window=\(ObjectIdentifier(self)) " + + "web=\(ObjectIdentifier(webView)) " + + "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + + "pointerDepth=\(webView.debugPointerFocusAllowanceDepth) " + + "eventType=\(currentEvent.map { String(describing: $0.type) } ?? "nil")" + ) +#endif + return false + } + } +#if DEBUG + if let responder, + let webView = responderWebView { + dlog( + "focus.guard allowFirstResponder responder=\(String(describing: type(of: responder))) " + + "window=\(ObjectIdentifier(self)) " + + "web=\(ObjectIdentifier(webView)) " + + "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + + "pointerDepth=\(webView.debugPointerFocusAllowanceDepth)" + ) + } +#endif + let result: Bool + if pointerInitiatedWebFocus, let webView = responderWebView { + // `NSWindow.makeFirstResponder` may run before `ProgramaWebView.mouseDown(with:)`. + // Preserve pointer intent during this synchronous responder change. + result = webView.withPointerFocusAllowance { + programa_makeFirstResponder(responder) + } + } else { + result = programa_makeFirstResponder(responder) + } + if result { + if let fieldEditor = responder as? NSTextView, fieldEditor.isFieldEditor { + Self.programaTrackFieldEditor(fieldEditor, owningWebView: responderWebView) + } else if let fieldEditor = self.firstResponder as? NSTextView, fieldEditor.isFieldEditor { + Self.programaTrackFieldEditor(fieldEditor, owningWebView: responderWebView) + } + } + return result + } + + @objc func programa_sendEvent(_ event: NSEvent) { +#if DEBUG + let typingTimingStart = event.type == .keyDown ? ProgramaTypingTiming.start() : nil + let phaseTotalStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 + var contextSetupMs: Double = 0 + var focusRepairMs: Double = 0 + var folderGuardMs: Double = 0 + var originalDispatchMs: Double = 0 + let typingTimingExtra: String? = { + guard event.type == .keyDown else { return nil } + let responderWebView = self.firstResponder.flatMap { + Self.programaOwningWebView(for: $0, in: self, event: event) + } + let hitWebView = Self.programaHitViewForEventDispatch(in: self, event: event).flatMap { + Self.programaOwningWebView(for: $0) + } + let firstResponderType = self.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + return "browser=\((responderWebView != nil || hitWebView != nil) ? 1 : 0) firstResponder=\(firstResponderType)" + }() + if event.type == .keyDown { + ProgramaTypingTiming.logEventDelay(path: "window.sendEvent", event: event) + } +#endif + // recordTypingActivity must run in all builds so runSessionAutosaveTick + // can honor the typing quiet period in release. + if event.type == .keyDown { + AppDelegate.shared?.recordTypingActivity() + } +#if DEBUG + defer { + if event.type == .keyDown { + let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0 + ProgramaTypingTiming.logBreakdown( + path: "window.sendEvent.phase", + totalMs: totalMs, + event: event, + thresholdMs: 1.0, + parts: [ + ("contextSetupMs", contextSetupMs), + ("focusRepairMs", focusRepairMs), + ("folderGuardMs", folderGuardMs), + ("originalDispatchMs", originalDispatchMs), + ], + extra: typingTimingExtra + ) + ProgramaTypingTiming.logDuration( + path: "window.sendEvent", + startedAt: typingTimingStart, + event: event, + extra: typingTimingExtra + ) + } + } + let contextSetupStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 +#endif + let previousContextEvent = programaFirstResponderGuardCurrentEventContext + let previousContextHitView = programaFirstResponderGuardHitViewContext + let previousContextWindowNumber = programaFirstResponderGuardContextWindowNumber + programaFirstResponderGuardCurrentEventContext = event + programaFirstResponderGuardHitViewContext = Self.programaHitViewForEventDispatch(in: self, event: event) + programaFirstResponderGuardContextWindowNumber = self.windowNumber +#if DEBUG + if event.type == .keyDown { + contextSetupMs = (ProcessInfo.processInfo.systemUptime - contextSetupStart) * 1000.0 + } + let focusRepairStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 +#endif + if event.type == .keyDown { + AppDelegate.shared?.repairFocusedTerminalKeyboardRoutingIfNeeded( + window: self, + event: event + ) + } +#if DEBUG + if event.type == .keyDown { + focusRepairMs = (ProcessInfo.processInfo.systemUptime - focusRepairStart) * 1000.0 + } + let folderGuardStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 +#endif + defer { + programaFirstResponderGuardCurrentEventContext = previousContextEvent + programaFirstResponderGuardHitViewContext = previousContextHitView + programaFirstResponderGuardContextWindowNumber = previousContextWindowNumber + } + + 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 + programa_sendEvent(event) + originalDispatchMs = (ProcessInfo.processInfo.systemUptime - originalDispatchStart) * 1000.0 + return + } +#endif + programa_sendEvent(event) + return + } +#if DEBUG + if event.type == .keyDown { + folderGuardMs = (ProcessInfo.processInfo.systemUptime - folderGuardStart) * 1000.0 + } + let originalDispatchStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 +#endif + + let contentPoint = contentView.convert(event.locationInWindow, from: nil) + let hitView = contentView.hitTest(contentPoint) + let previousMovableState = isMovable + if previousMovableState { + isMovable = false + } + + #if DEBUG + let hitDesc = hitView.map { String(describing: type(of: $0)) } ?? "nil" + dlog("window.sendEvent.folderDown suppress=1 hit=\(hitDesc) wasMovable=\(previousMovableState)") + #endif + + programa_sendEvent(event) +#if DEBUG + if event.type == .keyDown { + originalDispatchMs = (ProcessInfo.processInfo.systemUptime - originalDispatchStart) * 1000.0 + } +#endif + + if previousMovableState { + isMovable = previousMovableState + } + + #if DEBUG + dlog("window.sendEvent.folderDown restore nowMovable=\(isMovable)") + #endif + } + + @objc func programa_performKeyEquivalent(with event: NSEvent) -> Bool { +#if DEBUG + let typingTimingStart = ProgramaTypingTiming.start() + defer { + ProgramaTypingTiming.logDuration( + path: "window.performKeyEquivalent", + startedAt: typingTimingStart, + event: event + ) + } + let frType = self.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog("performKeyEquiv: \(Self.keyDescription(event)) fr=\(frType)") +#endif + + // When the terminal surface is the first responder, prevent SwiftUI's + // hosting view from consuming key events via performKeyEquivalent. + // After a browser panel (WKWebView) has been in the responder chain, + // SwiftUI's internal focus system can get into a broken state where it + // intercepts key events in the content view hierarchy, returns true + // (claiming consumption), but never actually fires the action closure. + // + // For non-Command keys: bypass the view hierarchy entirely and send + // directly to the terminal so arrow keys, Ctrl+N/P, etc. reach keyDown. + // + // For Command keys: bypass the SwiftUI content view hierarchy and + // dispatch directly to the main menu. No SwiftUI view should be handling + // Command shortcuts when the terminal is focused — the local event monitor + // (handleCustomShortcut) already handles app-level shortcuts, and anything + // remaining should be menu items. + let firstResponderGhosttyView = cmuxOwningGhosttyView(for: self.firstResponder) + let firstResponderWebView = self.firstResponder.flatMap { + Self.programaOwningWebView(for: $0, in: self, event: event) + } + let firstResponderHasMarkedText = browserResponderHasMarkedText(self.firstResponder) + 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 + // process it. Cmd-based shortcuts should still work during composition since + // Cmd is never part of IME input sequences. + if ghosttyView.hasMarkedText(), !event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command) { + return programa_performKeyEquivalent(with: event) + } + + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if !flags.contains(.command) { + let result = ghosttyView.performKeyEquivalent(with: event) +#if DEBUG + dlog(" → ghostty direct: \(result)") +#endif + return result + } + + // Preserve Ghostty's terminal font-size shortcuts (Cmd +/−/0) when + // the terminal is focused. Otherwise our browser menu shortcuts can + // consume the event even when no browser panel is focused. + if shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: true, + flags: event.modifierFlags, + chars: event.charactersIgnoringModifiers ?? "", + keyCode: event.keyCode, + literalChars: event.characters + ) { + ghosttyView.keyDown(with: event) +#if DEBUG + dlog("zoom.shortcut stage=window.ghosttyKeyDownDirect event=\(Self.keyDescription(event)) handled=1") +#endif + return true + } + } + + // Web forms rely on Return/Enter flowing through keyDown. If the original + // NSWindow.performKeyEquivalent consumes Enter first, submission never reaches + // WebKit. Route Return/Enter directly to the current first responder and + // mark handled to avoid the AppKit alert sound path. + if shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: event.keyCode, + firstResponderIsBrowser: firstResponderWebView != nil, + firstResponderHasMarkedText: firstResponderHasMarkedText, + flags: event.modifierFlags + ) { + // Forwarding keyDown can re-enter performKeyEquivalent in WebKit/AppKit internals. + // On re-entry, fall back to normal dispatch to avoid an infinite loop. + if programaBrowserReturnForwardingDepth > 0 { +#if DEBUG + dlog(" → browser Return/Enter reentry; using normal dispatch") +#endif + return false + } + programaBrowserReturnForwardingDepth += 1 + defer { programaBrowserReturnForwardingDepth = max(0, programaBrowserReturnForwardingDepth - 1) } +#if DEBUG + dlog(" → browser Return/Enter routed to firstResponder.keyDown") +#endif + self.firstResponder?.keyDown(with: event) + return true + } + + if let firstResponderWebView, + shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst( + event, + responder: self.firstResponder, + owningWebView: firstResponderWebView + ) { + let result = firstResponderWebView.performKeyEquivalent(with: event) +#if DEBUG + if result { + dlog(" → browser find command resolved before window menu path") + } else { + dlog(" → browser find command preflight left unclaimed; suppressing replay") + } +#endif + // The focused web view has already received this Find-family shortcut once. + // Do not fall through into the original NSWindow.performKeyEquivalent path, + // or WebKit can observe the same key equivalent a second time before AppKit + // reaches keyDown/menu fallback. + return true + } + + if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true { +#if DEBUG + dlog(" → consumed by handleBrowserSurfaceKeyEquivalent") +#endif + return true + } + + // When the terminal is focused, skip the full NSWindow.performKeyEquivalent + // (which walks the SwiftUI content view hierarchy) and dispatch Command-key + // events directly to the main menu. This avoids the broken SwiftUI focus path. + if firstResponderGhosttyView != nil, + shouldRouteCommandEquivalentDirectlyToMainMenu(event), + let mainMenu = NSApp.mainMenu { + let consumedByMenu = mainMenu.performKeyEquivalent(with: event) +#if DEBUG + if browserZoomShortcutTraceCandidate( + flags: event.modifierFlags, + chars: event.charactersIgnoringModifiers ?? "", + keyCode: event.keyCode, + literalChars: event.characters + ) { + dlog( + "zoom.shortcut stage=window.mainMenuBypass event=\(Self.keyDescription(event)) " + + "consumed=\(consumedByMenu ? 1 : 0) fr=GhosttyNSView" + ) + } +#endif + if !consumedByMenu { + // Fall through to the original performKeyEquivalent path below. + } else { +#if DEBUG + dlog(" → consumed by mainMenu (bypassed SwiftUI)") +#endif + return true + } + } + + let result = programa_performKeyEquivalent(with: event) +#if DEBUG + if result { dlog(" → consumed by original performKeyEquivalent") } +#endif + return result + } + + static func keyDescription(_ event: NSEvent) -> String { + var parts: [String] = [] + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if flags.contains(.command) { parts.append("Cmd") } + if flags.contains(.shift) { parts.append("Shift") } + if flags.contains(.option) { parts.append("Opt") } + if flags.contains(.control) { parts.append("Ctrl") } + let chars = event.charactersIgnoringModifiers ?? "?" + parts.append("'\(chars)'(\(event.keyCode))") + return parts.joined(separator: "+") + } + + private static func programaOwningWebView(for responder: NSResponder) -> ProgramaWebView? { + if let webView = responder as? ProgramaWebView { + return webView + } + + if let view = responder as? NSView, + let webView = programaOwningWebView(for: view) { + return webView + } + + // NSTextView.delegate is unsafe-unretained in AppKit. Reading it here while + // a responder chain is tearing down can trap with "unowned reference". + var current = responder.nextResponder + while let next = current { + if let webView = next as? ProgramaWebView { + return webView + } + if let view = next as? NSView, + let webView = programaOwningWebView(for: view) { + return webView + } + current = next.nextResponder + } + + return nil + } + + private static func programaOwningWebView( + for responder: NSResponder, + in window: NSWindow, + event: NSEvent? + ) -> ProgramaWebView? { + // Browser find runs in the portal slot alongside the hosted WKWebView. + // Treat its native field editor chain as browser chrome, not as web content, + // so Cmd+F can move first responder into the find field while web focus is suppressed. + if BrowserWindowPortalRegistry.searchOverlayPanelId(for: responder, in: window) != nil { + return nil + } + + if let webView = programaOwningWebView(for: responder) { + return webView + } + + guard let textView = responder as? NSTextView, textView.isFieldEditor else { + return nil + } + + if let event, + let hitWebView = programaPointerHitWebView(in: window, event: event) { + programaTrackFieldEditor(textView, owningWebView: hitWebView) + return hitWebView + } + + return programaTrackedOwningWebView(for: textView) + } + + private static func programaOwningWebView(for view: NSView) -> ProgramaWebView? { + if let webView = view as? ProgramaWebView { + return webView + } + + var current: NSView? = view.superview + while let candidate = current { + if let webView = candidate as? ProgramaWebView { + return webView + } + if String(describing: type(of: candidate)).contains("WindowBrowserSlotView"), + let portalWebView = programaUniqueBrowserWebView(in: candidate) { + // Portal-hosted browser chrome (for example the Cmd+F overlay) is a + // sibling of the hosted WKWebView inside WindowBrowserSlotView, not a + // descendant of it. Allow native text-entry controls in that slot to + // acquire first responder directly, but keep generic sibling views + // associated with the hosted web view so blocked browser focus policy + // still protects inspector/overlay chrome from stray focus changes. + if view === portalWebView || view.isDescendant(of: portalWebView) { + return portalWebView + } + if programaAllowsPortalSlotTextEntryFocus(view) { + return nil + } + return portalWebView + } + current = candidate.superview + } + + return nil + } + + private static func programaAllowsPortalSlotTextEntryFocus(_ view: NSView) -> Bool { + var current: NSView? = view + while let candidate = current { + if let textField = candidate as? NSTextField { + return textField.isEditable || textField.acceptsFirstResponder + } + if let textView = candidate as? NSTextView { + return textView.isEditable || textView.isSelectable || textView.isFieldEditor + } + current = candidate.superview + } + return false + } + + private static func programaUniqueBrowserWebView(in root: NSView) -> ProgramaWebView? { + var stack: [NSView] = [root] + var found: ProgramaWebView? + while let current = stack.popLast() { + if let webView = current as? ProgramaWebView { + if found == nil { + found = webView + } else if found !== webView { + return nil + } + } + stack.append(contentsOf: current.subviews) + } + return found + } + + private static func programaCurrentEvent(for window: NSWindow) -> NSEvent? { +#if DEBUG + if let override = programaFirstResponderGuardCurrentEventOverride { + return override + } +#endif + if programaFirstResponderGuardContextWindowNumber == window.windowNumber { + return programaFirstResponderGuardCurrentEventContext + } + return NSApp.currentEvent + } + + private static func programaHitViewInThemeFrame(in window: NSWindow, event: NSEvent) -> NSView? { + guard let contentView = window.contentView, + let themeFrame = contentView.superview else { + return nil + } + let pointInTheme = themeFrame.convert(event.locationInWindow, from: nil) + return themeFrame.hitTest(pointInTheme) + } + + private static func programaHitViewInContentView(in window: NSWindow, event: NSEvent) -> NSView? { + guard let contentView = window.contentView else { + return nil + } + let pointInContent = contentView.convert(event.locationInWindow, from: nil) + return contentView.hitTest(pointInContent) + } + + private static func programaTopHitViewForEvent(in window: NSWindow, event: NSEvent) -> NSView? { + if let hitInThemeFrame = programaHitViewInThemeFrame(in: window, event: event) { + return hitInThemeFrame + } + return programaHitViewInContentView(in: window, event: event) + } + + private static func programaHitViewForEventDispatch(in window: NSWindow, event: NSEvent) -> NSView? { + if event.windowNumber != 0, event.windowNumber != window.windowNumber { + return nil + } + if let eventWindow = event.window, eventWindow !== window { + return nil + } + return programaTopHitViewForEvent(in: window, event: event) + } + + private static func programaHitViewForCurrentEvent(in window: NSWindow, event: NSEvent) -> NSView? { +#if DEBUG + if let override = programaFirstResponderGuardHitViewOverride { + return override + } +#endif + if programaFirstResponderGuardContextWindowNumber == window.windowNumber, + let contextHitView = programaFirstResponderGuardHitViewContext { + return contextHitView + } + return programaTopHitViewForEvent(in: window, event: event) + } + + private static func programaTrackFieldEditor(_ fieldEditor: NSTextView, owningWebView webView: ProgramaWebView?) { + if let webView { + objc_setAssociatedObject( + fieldEditor, + &programaFieldEditorOwningWebViewAssociationKey, + ProgramaFieldEditorOwningWebViewBox(webView: webView), + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } else { + objc_setAssociatedObject( + fieldEditor, + &programaFieldEditorOwningWebViewAssociationKey, + nil, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + + private static func programaTrackedOwningWebView(for fieldEditor: NSTextView) -> ProgramaWebView? { + guard let box = objc_getAssociatedObject( + fieldEditor, + &programaFieldEditorOwningWebViewAssociationKey + ) as? ProgramaFieldEditorOwningWebViewBox else { + return nil + } + guard let webView = box.webView else { + programaTrackFieldEditor(fieldEditor, owningWebView: nil) + return nil + } + return webView + } + + private static func programaIsPointerDownEvent(_ event: NSEvent) -> Bool { + switch event.type { + case .leftMouseDown, .rightMouseDown, .otherMouseDown: + return true + default: + return false + } + } + + private static func programaPointerHitWebView(in window: NSWindow, event: NSEvent) -> ProgramaWebView? { + guard programaIsPointerDownEvent(event) else { return nil } + if event.windowNumber != 0, event.windowNumber != window.windowNumber { + return nil + } + if let eventWindow = event.window, eventWindow !== window { + return nil + } + if let portalWebView = BrowserWindowPortalRegistry.webViewAtWindowPoint( + event.locationInWindow, + in: window + ) as? ProgramaWebView { + return portalWebView + } + guard let hitView = programaHitViewForCurrentEvent(in: window, event: event) else { + return nil + } + return programaOwningWebView(for: hitView) + } + + private static func programaShouldAllowPointerInitiatedWebViewFocus( + window: NSWindow, + webView: ProgramaWebView, + event: NSEvent? + ) -> Bool { + guard let event, + let hitWebView = programaPointerHitWebView(in: window, event: event) else { + return false + } + return hitWebView === webView + } + +} diff --git a/Sources/Workspace+Bonsplit.swift b/Sources/Workspace+Bonsplit.swift new file mode 100644 index 00000000000..3cfcfe6e7b7 --- /dev/null +++ b/Sources/Workspace+Bonsplit.swift @@ -0,0 +1,1062 @@ +// Extracted from Workspace.swift (nuclear-review #98): BonsplitDelegate conformance. + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +extension Workspace: BonsplitDelegate { + @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, + let manager, + manager.tabs.contains(where: { $0.id == id }) else { + return false + } + return true + } + + @MainActor + private func confirmClosePanel(for tabId: TabID) async -> Bool { + 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 { + return custom + } + if let title = panelTitles[panelId], !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return title + } + if let dir = panelDirectories[panelId], !dir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return (dir as NSString).lastPathComponent + } + return nil + }() + + if let panelName { + alert.informativeText = String(localized: "dialog.closeTab.messageNamed", defaultValue: "This will close \"\(panelName)\".") + } else { + alert.informativeText = String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab.") + } + alert.alertStyle = .warning + alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close")) + alert.addButton(withTitle: String(localized: "dialog.closeTab.cancel", defaultValue: "Cancel")) + + if let closeButton = alert.buttons.first { + closeButton.keyEquivalent = "\r" + closeButton.keyEquivalentModifierMask = [] + alert.window.defaultButtonCell = closeButton.cell as? NSButtonCell + alert.window.initialFirstResponder = closeButton + } + if let cancelButton = alert.buttons.dropFirst().first { + cancelButton.keyEquivalent = "\u{1b}" + } + + // Prefer a sheet if we can find a window, otherwise fall back to modal. + if let window = NSApp.keyWindow ?? NSApp.mainWindow { + return await withCheckedContinuation { continuation in + alert.beginSheetModal(for: window) { response in + continuation.resume(returning: response == .alertFirstButtonReturn) + } + } + } + + return alert.runModal() == .alertFirstButtonReturn + } + + /// 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). + func applyTabSelection( + tabId: TabID, + inPane pane: PaneID, + reassertAppKitFocus: Bool = true, + focusIntent: PanelFocusIntent? = nil, + previousTerminalHostedView: GhosttySurfaceScrollView? = nil + ) { + pendingTabSelection = PendingTabSelectionRequest( + tabId: tabId, + pane: pane, + reassertAppKitFocus: reassertAppKitFocus, + focusIntent: focusIntent, + previousTerminalHostedView: previousTerminalHostedView + ) + guard !isApplyingTabSelection else { return } + isApplyingTabSelection = true + defer { + isApplyingTabSelection = false + pendingTabSelection = nil + } + + var iterations = 0 + while let request = pendingTabSelection { + pendingTabSelection = nil + iterations += 1 + if iterations > 8 { break } + applyTabSelectionNow( + tabId: request.tabId, + inPane: request.pane, + reassertAppKitFocus: request.reassertAppKitFocus, + focusIntent: request.focusIntent, + previousTerminalHostedView: request.previousTerminalHostedView + ) + } + } + + /// 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") + } + } + + private func applyTabSelectionNow( + tabId: TabID, + inPane pane: PaneID, + reassertAppKitFocus: Bool, + focusIntent: PanelFocusIntent?, + previousTerminalHostedView: GhosttySurfaceScrollView? + ) { + 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 } + .map { String($0.uuid.uuidString.prefix(5)) } ?? "nil" + 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 bonsplitController.tabs(inPane: pane).contains(where: { $0.id == tabId }), + bonsplitController.selectedTab(inPane: pane)?.id != tabId { + bonsplitController.selectTab(tabId) + } + } + + let focusedPane: PaneID + let selectedTabId: TabID + if let currentPane = bonsplitController.focusedPaneId, + let currentTabId = bonsplitController.selectedTab(inPane: currentPane)?.id { + focusedPane = currentPane + selectedTabId = currentTabId + } else if bonsplitController.tabs(inPane: pane).contains(where: { $0.id == tabId }) { + focusedPane = pane + selectedTabId = tabId + bonsplitController.focusPane(focusedPane) + bonsplitController.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 { + return + } + let effectiveFocusedPanelId = effectiveSelectedPanelId(inPane: focusedPane) ?? selectedPanelId + guard let panel = panels[effectiveFocusedPanelId] else { + return + } + + if debugStressPreloadSelectionDepth > 0 { + if let terminalPanel = panel as? TerminalPanel { + terminalPanel.requestViewReattach() + scheduleTerminalGeometryReconcile() + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + } + return + } + + if shouldTreatCurrentEventAsExplicitFocusIntent() { + markExplicitFocusIntent(on: effectiveFocusedPanelId) + } + let activationIntent = focusIntent ?? panel.preferredFocusIntentForActivation() + panel.prepareFocusIntentForActivation(activationIntent) + let panelId = effectiveFocusedPanelId + + syncPinnedStateForTab(selectedTabId, panelId: selectedPanelId) + syncUnreadBadgeStateForPanel(selectedPanelId) + + // Unfocus all other panels + for (id, p) in panels where id != effectiveFocusedPanelId { + p.unfocus() + } + + // Explicitly hide browser portals for deselected tabs in this pane. + // Bonsplit'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. + hideBrowserPortalsForDeselectedTabs(inPane: focusedPane, selectedTabId: selectedTabId) + + if let focusWindow = activationWindow(for: panel) { + yieldForeignOwnedFocusIfNeeded( + in: focusWindow, + targetPanelId: panelId, + targetIntent: activationIntent + ) + } + + activatePanel( + panel, + focusIntent: activationIntent, + reassertAppKitFocus: reassertAppKitFocus + ) + let focusIntentAllowsBrowserOmnibarAutofocus = + shouldTreatCurrentEventAsExplicitFocusIntent() || + TerminalController.socketCommandAllowsInAppFocusMutations() + if let browserPanel = panel as? BrowserPanel, + shouldAllowBrowserOmnibarAutofocus(for: activationIntent), + previousFocusedPanelId != panelId || focusIntentAllowsBrowserOmnibarAutofocus { + maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: .standard) + } + if let terminalPanel = panel as? TerminalPanel { + rememberTerminalConfigInheritanceSource(terminalPanel) + } + let isManuallyUnread = manualUnreadPanelIds.contains(panelId) + let markedAt = manualUnreadMarkedAt[panelId] + if Self.shouldClearManualUnread( + previousFocusedPanelId: previousFocusedPanelId, + nextFocusedPanelId: panelId, + isManuallyUnread: isManuallyUnread, + markedAt: markedAt + ) { + triggerFocusFlash(panelId: panelId) + let clearDelay = Self.manualUnreadClearDelayAfterFocusFlash + if clearDelay <= 0 { + clearManualUnread(panelId: panelId) + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + clearDelay) { [weak self] in + self?.clearManualUnread(panelId: panelId) + } + } + } + + // Converge AppKit first responder with bonsplit'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 + dlog( + "focus.split.moveFocus workspace=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) previousExists=\(previousExists) " + + "to=\(panelId.uuidString.prefix(5))" + ) +#endif + terminalPanel.hostedView.moveFocus(from: previousTerminalHostedView) + } +#if DEBUG + 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))" + ) +#endif + terminalPanel.hostedView.ensureFocus(for: id, surfaceId: panelId) + } + + if shouldRestoreFocusIntentAfterActivation(activationIntent) { + _ = panel.restoreFocusIntent(activationIntent) + } + + // Update current directory if this is a terminal + if let dir = panelDirectories[panelId] { + currentDirectory = dir + } + gitBranch = panelGitBranches[panelId] + pullRequest = panelPullRequests[panelId] + + // Post notification + NotificationCenter.default.post( + name: .ghosttyDidFocusSurface, + object: nil, + userInfo: [ + GhosttyNotificationKey.tabId: self.id, + GhosttyNotificationKey.surfaceId: panelId + ] + ) +#if DEBUG + let prevPanelShort = previousFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + 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)) " + + "prevPanel=\(prevPanelShort)" + ) +#endif + } + + private func activatePanel( + _ panel: any Panel, + focusIntent: PanelFocusIntent, + reassertAppKitFocus: Bool + ) { + if let terminalPanel = panel as? TerminalPanel { + let shouldFocusTerminalSurface = shouldMoveTerminalSurfaceFocus(for: focusIntent) + terminalPanel.surface.setFocus(shouldFocusTerminalSurface) + terminalPanel.hostedView.setActive(true) + if reassertAppKitFocus && shouldFocusTerminalSurface { + terminalPanel.focus() + } + return + } + + if let browserPanel = panel as? BrowserPanel { + guard shouldFocusBrowserWebView(for: focusIntent) else { return } + browserPanel.focus() + return + } + + if reassertAppKitFocus { + panel.focus() + } + } + + private func activationWindow(for panel: any Panel) -> NSWindow? { + if let terminalPanel = panel as? TerminalPanel { + return terminalPanel.hostedView.window ?? NSApp.keyWindow ?? NSApp.mainWindow + } + if let browserPanel = panel as? BrowserPanel { + return browserPanel.webView.window ?? browserPanel.portalAnchorView.window ?? NSApp.keyWindow ?? NSApp.mainWindow + } + return NSApp.keyWindow ?? NSApp.mainWindow + } + + private func yieldForeignOwnedFocusIfNeeded( + in window: NSWindow, + targetPanelId: UUID, + targetIntent: PanelFocusIntent + ) { + guard let firstResponder = window.firstResponder else { return } + + for (panelId, panel) in panels where panelId != targetPanelId { + guard let ownedIntent = panel.ownedFocusIntent(for: firstResponder, in: window) else { continue } +#if DEBUG + 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))" + ) +#endif + _ = panel.yieldFocusIntent(ownedIntent, in: window) + return + } + } + + private func shouldMoveTerminalSurfaceFocus(for intent: PanelFocusIntent) -> Bool { + switch intent { + case .terminal(.findField): + return false + default: + return true + } + } + + private func shouldFocusBrowserWebView(for intent: PanelFocusIntent) -> Bool { + switch intent { + case .browser(.addressBar), .browser(.findField): + return false + default: + return true + } + } + + private func shouldAllowBrowserOmnibarAutofocus(for intent: PanelFocusIntent) -> Bool { + switch intent { + case .browser(.webView), .panel: + return true + default: + return false + } + } + + private func shouldRestoreFocusIntentAfterActivation(_ intent: PanelFocusIntent) -> Bool { + switch intent { + case .browser(.addressBar), .browser(.findField), .terminal(.findField): + return true + case .panel, .browser(.webView), .terminal(.surface): + return false + } + } + + func beginNonFocusSplitFocusReassert( + preferredPanelId: UUID, + splitPanelId: UUID + ) -> UInt64 { + nonFocusSplitFocusReassertGeneration &+= 1 + let generation = nonFocusSplitFocusReassertGeneration + pendingNonFocusSplitFocusReassert = PendingNonFocusSplitFocusReassert( + generation: generation, + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId + ) + return generation + } + + 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 + } + + 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 { + case .leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp, + .otherMouseDown, .otherMouseUp, .keyDown, .keyUp, .scrollWheel, + .gesture, .magnify, .rotate, .swipe: + return true + default: + return false + } + } + + 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 recordPostCloseSelection() { + let tabs = controller.tabs(inPane: pane) + guard let idx = tabs.firstIndex(where: { $0.id == tab.id }) else { + postCloseSelectTabId.removeValue(forKey: tab.id) + return + } + + let target: TabID? = { + if idx + 1 < tabs.count { return tabs[idx + 1].id } + if idx > 0 { return tabs[idx - 1].id } + return nil + }() + + if let target { + postCloseSelectTabId[tab.id] = target + } else { + postCloseSelectTabId.removeValue(forKey: tab.id) + } + } + + let explicitUserClose = explicitUserCloseTabIds.remove(tab.id) != nil + + if forceCloseTabIds.contains(tab.id) { + stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane) + recordPostCloseSelection() + return true + } + + if let panelId = panelIdFromSurfaceId(tab.id), + pinnedPanelIds.contains(panelId) { + clearStagedClosedBrowserRestoreSnapshot(for: tab.id) + NSSound.beep() + return false + } + + if explicitUserClose && shouldCloseWorkspaceOnLastSurface(for: tab.id) { + clearStagedClosedBrowserRestoreSnapshot(for: tab.id) + 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) + recordPostCloseSelection() + return true + } + + // If confirmation is required, Bonsplit 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 + } + + pendingCloseConfirmTabIds.insert(tab.id) + let tabId = tab.id + DispatchQueue.main.async { [weak self] in + guard let self else { return } + Task { @MainActor in + defer { self.pendingCloseConfirmTabIds.remove(tabId) } + + // If the tab disappeared while we were scheduling, do nothing. + guard self.panelIdFromSurfaceId(tabId) != nil else { return } + + let confirmed = await self.confirmClosePanel(for: tabId) + guard confirmed else { return } + + self.forceCloseTabIds.insert(tabId) + self.bonsplitController.closeTab(tabId) + } + } + + return false + } + + clearStagedClosedBrowserRestoreSnapshot(for: tab.id) + recordPostCloseSelection() + return true + } + + func splitTabBar(_ controller: BonsplitController, 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() + if !isDetaching { + scheduleFocusReconcile() + } + return + } + + #if DEBUG + NSLog("[Workspace] didCloseTab panelId=\(panelId) remainingPanels=\(panels.count - 1) remainingPanes=\(controller.allPaneIds.count)") + #endif + + let panel = panels[panelId] + let transferredRemoteCleanupConfiguration = transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: panelId) + + if isDetaching, let panel { + let browserPanel = panel as? BrowserPanel + let cachedTitle = panelTitles[panelId] + 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], + cachedTitle: cachedTitle, + customTitle: panelCustomTitles[panelId], + manuallyUnread: manualUnreadPanelIds.contains(panelId), + isRemoteTerminal: activeRemoteTerminalSurfaceIds.contains(panelId), + remoteRelayPort: activeRemoteTerminalSurfaceIds.contains(panelId) + ? remoteConfiguration?.relayPort + : nil, + remoteCleanupConfiguration: transferredRemoteCleanupConfiguration + ) + } else { + if let closedBrowserRestoreSnapshot { + onClosedBrowserPanel?(closedBrowserRestoreSnapshot) + } + panel?.close() + } + + panels.removeValue(forKey: panelId) + untrackRemoteTerminalSurface(panelId) + pendingRemoteTerminalChildExitSurfaceIds.remove(panelId) + surfaceIdToPanelId.removeValue(forKey: tabId) + removeSurfaceMetadata(panelId: panelId) + syncRemotePortScanTTYs() + recomputeListeningPorts() + clearRemoteConfigurationIfWorkspaceBecameLocal() + if !isDetaching, let transferredRemoteCleanupConfiguration { + Self.requestSSHControlMasterCleanupIfNeeded(configuration: transferredRemoteCleanupConfiguration) + } + + // Keep the workspace invariant for normal close paths. + // Detach/move flows intentionally allow a temporary empty workspace so AppDelegate can + // prune the source workspace/window after the tab is attached elsewhere. + if panels.isEmpty { + if isDetaching { + scheduleTerminalGeometryReconcile() + return + } + + let replacement = createReplacementTerminalPanel() + if let replacementTabId = surfaceIdFromPanelId(replacement.id), + let replacementPane = bonsplitController.allPaneIds.first { + bonsplitController.focusPane(replacementPane) + bonsplitController.selectTab(replacementTabId) + applyTabSelection(tabId: replacementTabId, inPane: replacementPane) + } + scheduleTerminalGeometryReconcile() + scheduleFocusReconcile() + return + } + + if let selectTabId, + bonsplitController.allPaneIds.contains(pane), + bonsplitController.tabs(inPane: pane).contains(where: { $0.id == selectTabId }), + bonsplitController.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) + applyTabSelection(tabId: selectTabId, inPane: pane) + } else if let focusedPane = bonsplitController.focusedPaneId, + let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id { + // When closing the last tab in a pane, Bonsplit may focus a different pane and skip + // emitting didSelectTab. Re-apply the focused selection so sidebar state stays in sync. + applyTabSelection(tabId: focusedTabId, inPane: focusedPane) + } + + if bonsplitController.allPaneIds.contains(pane) { + 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 splitTabBar(_ controller: BonsplitController, didMoveTab tab: Bonsplit.Tab, fromPane source: PaneID, toPane destination: PaneID) { +#if DEBUG + let now = ProcessInfo.processInfo.systemUptime + let sincePrev: String + if debugLastDidMoveTabTimestamp > 0 { + sincePrev = String(format: "%.2f", (now - debugLastDidMoveTabTimestamp) * 1000) + } else { + sincePrev = "first" + } + debugLastDidMoveTabTimestamp = now + debugDidMoveTabEventCount += 1 + let movedPanelId = panelIdFromSurfaceId(tab.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 focusedPanelBefore = focusedPanelId?.uuidString.prefix(5) ?? "nil" + 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)" + ) + dlog( + "split.moveTab.state.before idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " + + "destSelected=\(selectedBefore) focusedPane=\(focusedPaneBefore) focusedPanel=\(focusedPanelBefore)" + ) +#endif + applyTabSelection(tabId: tab.id, inPane: destination) +#if DEBUG + let movedPanelIdAfter = panelIdFromSurfaceId(tab.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 focusedPanelAfter = focusedPanelId?.uuidString.prefix(5) ?? "nil" + let movedPanelFocused = (movedPanelIdAfter != nil && movedPanelIdAfter == focusedPanelId) ? 1 : 0 + dlog( + "split.moveTab.state.after idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " + + "destSelected=\(selectedAfter) focusedPane=\(focusedPaneAfter) focusedPanel=\(focusedPanelAfter) " + + "movedFocused=\(movedPanelFocused)" + ) +#endif + normalizePinnedTabs(in: source) + normalizePinnedTabs(in: destination) + scheduleTerminalGeometryReconcile() + if !isDetachingCloseTransaction { + scheduleFocusReconcile() + } + } + + func splitTabBar(_ controller: BonsplitController, didFocusPane pane: PaneID) { + // When a pane is focused, focus its selected tab's panel + guard let tab = controller.selectedTab(inPane: pane) else { return } +#if DEBUG + FocusLogStore.shared.append( + "Workspace.didFocusPane paneId=\(pane.id.uuidString) tabId=\(tab.id) focusedPane=\(controller.focusedPaneId?.id.uuidString ?? "nil")" + ) +#endif + applyTabSelection(tabId: tab.id, inPane: pane) + + // Apply window background for terminal + if let panelId = panelIdFromSurfaceId(tab.id), + let terminalPanel = panels[panelId] as? TerminalPanel { + terminalPanel.applyWindowBackgroundIfActive() + } + } + + /// Canonical per-panel metadata teardown shared by every close path. + /// The single-surface and pane-close paths previously hand-copied this + /// list and drifted (pane close leaked inheritance font points and never + /// cleared notifications). Aggregate recomputes (syncRemotePortScanTTYs, + /// recomputeListeningPorts) stay at the call sites. + private func removeSurfaceMetadata(panelId: UUID) { + 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) + panelSubscriptions.removeValue(forKey: panelId) + panelShellActivityStates.removeValue(forKey: panelId) + surfaceTTYNames.removeValue(forKey: panelId) + surfaceListeningPorts.removeValue(forKey: panelId) + restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: panelId) + if lastTerminalConfigInheritancePanelId == panelId { + lastTerminalConfigInheritancePanelId = nil + } + PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) + AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId) + } + + func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) { + let closedPanelIds = pendingPaneClosePanelIds.removeValue(forKey: paneId.id) ?? [] + let shouldScheduleFocusReconcile = !isDetachingCloseTransaction + + if !closedPanelIds.isEmpty { + for panelId in closedPanelIds { + panels[panelId]?.close() + panels.removeValue(forKey: panelId) + untrackRemoteTerminalSurface(panelId) + pendingRemoteTerminalChildExitSurfaceIds.remove(panelId) + removeSurfaceMetadata(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 { + applyTabSelection(tabId: focusedTabId, inPane: focusedPane) + } else if shouldScheduleFocusReconcile { + scheduleFocusReconcile() + } + } + + scheduleTerminalGeometryReconcile() + if shouldScheduleFocusReconcile { + scheduleFocusReconcile() + } + } + + func splitTabBar(_ controller: BonsplitController, 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), + panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) { + pendingPaneClosePanelIds.removeValue(forKey: pane.id) + return false + } + } + pendingPaneClosePanelIds[pane.id] = tabs.compactMap { panelIdFromSurfaceId($0.id) } + return true + } + + func splitTabBar(_ controller: BonsplitController, didSplitPane originalPane: PaneID, newPane: PaneID, orientation: SplitOrientation) { +#if DEBUG + let panelKindForTab: (TabID) -> String = { tabId in + guard let panelId = self.panelIdFromSurfaceId(tabId), + 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)) + }.joined(separator: ",") + } + let originalSelectedKind = controller.selectedTab(inPane: originalPane).map { panelKindForTab($0.id) } ?? "none" + let newSelectedKind = controller.selectedTab(inPane: newPane).map { panelKindForTab($0.id) } ?? "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) " + + "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 { + continue + } + browserPanel.preparePortalHostReplacementForNextDistinctClaim( + inPane: paneId, + reason: reason + ) + } + } + 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. + 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 DEBUG + 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) " + + "originalKinds=[\(paneKindSummary(originalPane))] newKinds=[\(paneKindSummary(newPane))]" + ) +#endif + if !hasRealSurface { + let placeholderTabs = originalTabs.filter { panelIdFromSurfaceId($0.id) == nil } +#if DEBUG + dlog( + "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 + dlog( + "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) } + +#if DEBUG + 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")" + ) +#endif + + let inheritedConfig = inheritedTerminalConfig( + preferredPanelId: sourcePanelId, + inPane: originalPane + ) + + let newPanel = TerminalPanel( + workspaceId: id, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: inheritedConfig, + portOrdinal: portOrdinal + ) + configureTerminalPanel(newPanel) + panels[newPanel.id] = newPanel + panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) + + guard let newTabId = bonsplitController.createTab( + title: newPanel.displayTitle, + icon: newPanel.displayIcon, + kind: SurfaceKind.terminal, + isDirty: newPanel.isDirty, + isPinned: false, + inPane: newPane + ) else { + panels.removeValue(forKey: newPanel.id) + panelTitles.removeValue(forKey: newPanel.id) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) + return + } + + surfaceIdToPanelId[newTabId] = newPanel.id + normalizePinnedTabs(in: newPane) +#if DEBUG + dlog( + "split.didSplit.autoCreate.done pane=\(newPane.id.uuidString.prefix(5)) " + + "panel=\(newPanel.id.uuidString.prefix(5))" + ) +#endif + + // `createTab` selects the new tab but does not emit didSelectTab; schedule an explicit + // 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) + } + self.scheduleTerminalGeometryReconcile() + self.scheduleFocusReconcile() + } + } + + 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 splitTabBar(_ controller: BonsplitController, didRequestTabContextAction action: TabContextAction, for tab: Bonsplit.Tab, inPane pane: PaneID) { + switch action { + case .rename: + promptRenamePanel(tabId: tab.id) + case .clearName: + guard let panelId = panelIdFromSurfaceId(tab.id) else { return } + setPanelCustomTitle(panelId: panelId, title: nil) + case .closeToLeft: + closeTabs(tabIdsToLeft(of: tab.id, inPane: pane)) + case .closeToRight: + closeTabs(tabIdsToRight(of: tab.id, inPane: pane)) + case .closeOthers: + closeTabs(tabIdsToCloseOthers(of: tab.id, inPane: pane)) + case .move: + promptMovePanel(tabId: tab.id) + case .newTerminalToRight: + createTerminalToRight(of: tab.id, inPane: pane) + case .newBrowserToRight: + createBrowserToRight(of: tab.id, inPane: pane) + case .reload: + guard let panelId = panelIdFromSurfaceId(tab.id), + let browser = browserPanel(for: panelId) else { return } + browser.reload() + case .duplicate: + duplicateBrowserToRight(anchorTabId: tab.id, inPane: pane) + case .togglePin: + guard let panelId = panelIdFromSurfaceId(tab.id) else { return } + let shouldPin = !pinnedPanelIds.contains(panelId) + setPanelPinned(panelId: panelId, pinned: shouldPin) + case .markAsRead: + guard let panelId = panelIdFromSurfaceId(tab.id) else { return } + clearManualUnread(panelId: panelId) + case .markAsUnread: + guard let panelId = panelIdFromSurfaceId(tab.id) else { return } + markPanelUnread(panelId) + case .toggleZoom: + guard let panelId = panelIdFromSurfaceId(tab.id) else { return } + toggleSplitZoom(panelId: panelId) + @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/Workspace+Layout.swift b/Sources/Workspace+Layout.swift new file mode 100644 index 00000000000..93a6bacf0f4 --- /dev/null +++ b/Sources/Workspace+Layout.swift @@ -0,0 +1,230 @@ +// Extracted from Workspace.swift (nuclear-review #98): programa.json custom layout application (applyCustomLayout and its tree/pane helpers). + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +extension Workspace { + + func applyCustomLayout(_ layout: ProgramaLayoutNode, baseCwd: String) { + guard let rootPaneId = bonsplitController.allPaneIds.first else { return } + + var leaves: [(paneId: PaneID, surfaces: [ProgramaSurfaceDefinition])] = [] + buildCustomLayoutTree(layout, inPane: rootPaneId, leaves: &leaves) + + // First leaf reuses the initial terminal created by addWorkspace; + // subsequent leaves were created via newTerminalSplit which also seeds + // a placeholder terminal. + var focusPanelId: UUID? + for leaf in leaves { + populateCustomPane(leaf.paneId, surfaces: leaf.surfaces, baseCwd: baseCwd, focusPanelId: &focusPanelId) + } + + let liveRoot = bonsplitController.treeSnapshot() + applyCustomDividerPositions(configNode: layout, liveNode: liveRoot) + + if let focusPanelId { + focusPanel(focusPanelId) + } + } + + private func buildCustomLayoutTree( + _ node: ProgramaLayoutNode, + inPane paneId: PaneID, + leaves: inout [(paneId: PaneID, surfaces: [ProgramaSurfaceDefinition])] + ) { + switch node { + case .pane(let pane): + leaves.append((paneId: paneId, surfaces: pane.surfaces)) + + case .split(let split): + guard split.children.count == 2 else { + NSLog("[ProgramaConfig] split node requires exactly 2 children, got %d", split.children.count) + leaves.append((paneId: paneId, surfaces: [])) + return + } + + var anchorPanelId = bonsplitController + .tabs(inPane: paneId) + .compactMap { panelIdFromSurfaceId($0.id) } + .first + + if anchorPanelId == nil { + anchorPanelId = newTerminalSurface(inPane: paneId, focus: false)?.id + } + + guard let anchorPanelId, + let newSplitPanel = newTerminalSplit( + from: anchorPanelId, + orientation: split.splitOrientation, + insertFirst: false, + focus: false + ), + let secondPaneId = self.paneId(forPanelId: newSplitPanel.id) else { + leaves.append((paneId: paneId, surfaces: [])) + return + } + + buildCustomLayoutTree(split.children[0], inPane: paneId, leaves: &leaves) + buildCustomLayoutTree(split.children[1], inPane: secondPaneId, leaves: &leaves) + } + } + + private func populateCustomPane( + _ paneId: PaneID, + surfaces: [ProgramaSurfaceDefinition], + baseCwd: String, + focusPanelId: inout UUID? + ) { + let existingPanelIds = bonsplitController + .tabs(inPane: paneId) + .compactMap { panelIdFromSurfaceId($0.id) } + + guard !surfaces.isEmpty else { return } + + let firstSurface = surfaces[0] + if let placeholderPanelId = existingPanelIds.first { + configureExistingSurface( + panelId: placeholderPanelId, + inPane: paneId, + surface: firstSurface, + baseCwd: baseCwd, + focusPanelId: &focusPanelId + ) + } + + for surfaceIndex in 1.. WorkspaceRemoteDaemonManifest? { + WorkspaceRemoteSessionController.remoteDaemonManifest(from: infoDictionary) + } + + nonisolated static func remoteDaemonCachedBinaryURL( + version: String, + goOS: String, + goArch: String, + fileManager: FileManager = .default + ) throws -> URL { + try WorkspaceRemoteSessionController.remoteDaemonCachedBinaryURL( + version: version, + goOS: goOS, + goArch: goArch, + fileManager: fileManager + ) + } + + func sessionSnapshot(includeScrollback: Bool) -> SessionWorkspaceSnapshot { + let tree = bonsplitController.treeSnapshot() + let layout = sessionLayoutSnapshot(from: tree) + + let orderedPanelIds = sidebarOrderedPanelIds() + var seen: Set = [] + var allPanelIds: [UUID] = [] + for panelId in orderedPanelIds where seen.insert(panelId).inserted { + allPanelIds.append(panelId) + } + for panelId in panels.keys.sorted(by: { $0.uuidString < $1.uuidString }) where seen.insert(panelId).inserted { + allPanelIds.append(panelId) + } + + let panelSnapshots = allPanelIds + .prefix(SessionPersistencePolicy.maxPanelsPerWorkspace) + .compactMap { sessionPanelSnapshot(panelId: $0, includeScrollback: includeScrollback) } + + let statusSnapshots = statusEntries.values + .sorted { lhs, rhs in lhs.key < rhs.key } + .map { entry in + SessionStatusEntrySnapshot( + key: entry.key, + value: entry.value, + icon: entry.icon, + color: entry.color, + timestamp: entry.timestamp.timeIntervalSince1970 + ) + } + let logSnapshots = logEntries.map { entry in + SessionLogEntrySnapshot( + message: entry.message, + level: entry.level.rawValue, + source: entry.source, + timestamp: entry.timestamp.timeIntervalSince1970 + ) + } + + let progressSnapshot = progress.map { progress in + SessionProgressSnapshot(value: progress.value, label: progress.label) + } + let gitBranchSnapshot = gitBranch.map { branch in + SessionGitBranchSnapshot(branch: branch.branch, isDirty: branch.isDirty) + } + + return SessionWorkspaceSnapshot( + processTitle: processTitle, + customTitle: customTitle, + customDescription: customDescription, + customColor: customColor, + isPinned: isPinned, + currentDirectory: currentDirectory, + focusedPanelId: focusedPanelId, + layout: layout, + panels: panelSnapshots, + statusEntries: statusSnapshots, + logEntries: logSnapshots, + progress: progressSnapshot, + gitBranch: gitBranchSnapshot + ) + } + + func restoreSessionSnapshot(_ snapshot: SessionWorkspaceSnapshot) { + restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) + + let normalizedCurrentDirectory = snapshot.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + if !normalizedCurrentDirectory.isEmpty { + currentDirectory = normalizedCurrentDirectory + } + + let panelSnapshotsById = Dictionary(uniqueKeysWithValues: snapshot.panels.map { ($0.id, $0) }) + let leafEntries = restoreSessionLayout(snapshot.layout) + var oldToNewPanelIds: [UUID: UUID] = [:] + + for entry in leafEntries { + restorePane( + entry.paneId, + snapshot: entry.snapshot, + panelSnapshotsById: panelSnapshotsById, + oldToNewPanelIds: &oldToNewPanelIds + ) + } + + pruneSurfaceMetadata(validSurfaceIds: Set(panels.keys)) + applySessionDividerPositions(snapshotNode: snapshot.layout, liveNode: bonsplitController.treeSnapshot()) + + applyProcessTitle(snapshot.processTitle) + setCustomTitle(snapshot.customTitle) + setCustomDescription(snapshot.customDescription) + setCustomColor(snapshot.customColor) + isPinned = snapshot.isPinned + + // Status entries and agent PIDs are ephemeral runtime state tied to running + // processes (e.g. claude_code "Running"). Don't restore them across app + // restarts because the processes that set them are gone. + statusEntries.removeAll() + agentPIDs.removeAll() + agentListeningPorts.removeAll() + logEntries = snapshot.logEntries.map { entry in + SidebarLogEntry( + message: entry.message, + level: SidebarLogLevel(rawValue: entry.level) ?? .info, + source: entry.source, + timestamp: Date(timeIntervalSince1970: entry.timestamp) + ) + } + progress = snapshot.progress.map { SidebarProgressState(value: $0.value, label: $0.label) } + gitBranch = snapshot.gitBranch.map { SidebarGitBranchState(branch: $0.branch, isDirty: $0.isDirty) } + + recomputeListeningPorts() + + if let focusedOldPanelId = snapshot.focusedPanelId, + let focusedNewPanelId = oldToNewPanelIds[focusedOldPanelId], + panels[focusedNewPanelId] != nil { + focusPanel(focusedNewPanelId) + } else if let fallbackFocusedPanelId = focusedPanelId, panels[fallbackFocusedPanelId] != nil { + focusPanel(fallbackFocusedPanelId) + } else { + scheduleFocusReconcile() + } + } + + private func sessionLayoutSnapshot(from node: ExternalTreeNode) -> SessionWorkspaceLayoutSnapshot { + switch node { + case .pane(let pane): + let panelIds = sessionPanelIDs(for: pane) + let selectedPanelId = pane.selectedTabId.flatMap(sessionPanelID(forExternalTabIDString:)) + return .pane( + SessionPaneLayoutSnapshot( + panelIds: panelIds, + selectedPanelId: selectedPanelId + ) + ) + case .split(let split): + return .split( + SessionSplitLayoutSnapshot( + orientation: split.orientation.lowercased() == "vertical" ? .vertical : .horizontal, + dividerPosition: split.dividerPosition, + first: sessionLayoutSnapshot(from: split.first), + second: sessionLayoutSnapshot(from: split.second) + ) + ) + } + } + + private func sessionPanelIDs(for pane: ExternalPaneNode) -> [UUID] { + var panelIds: [UUID] = [] + var seen = Set() + for tab in pane.tabs { + guard let panelId = sessionPanelID(forExternalTabIDString: tab.id) else { continue } + if seen.insert(panelId).inserted { + panelIds.append(panelId) + } + } + return panelIds + } + + 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 } + if surfaceUUID == tabUUID { + return panelId + } + } + return nil + } + + private func sessionSurfaceUUID(for surfaceId: TabID) -> UUID? { + struct EncodedSurfaceID: Decodable { + let id: UUID + } + + guard let data = try? JSONEncoder().encode(surfaceId), + let decoded = try? JSONDecoder().decode(EncodedSurfaceID.self, from: data) else { + return nil + } + return decoded.id + } + + private func sessionPanelSnapshot(panelId: UUID, includeScrollback: Bool) -> SessionPanelSnapshot? { + guard let panel = panels[panelId] else { return nil } + + let panelTitle = panelTitle(panelId: panelId) + let customTitle = panelCustomTitles[panelId] + let directory = panelDirectories[panelId] + let isPinned = pinnedPanelIds.contains(panelId) + let isManuallyUnread = manualUnreadPanelIds.contains(panelId) + let branchSnapshot = panelGitBranches[panelId].map { + SessionGitBranchSnapshot(branch: $0.branch, isDirty: $0.isDirty) + } + let listeningPorts: [Int] + if remoteDetectedSurfaceIds.contains(panelId) || isRemoteTerminalSurface(panelId) { + listeningPorts = [] + } else { + listeningPorts = (surfaceListeningPorts[panelId] ?? []).sorted() + } + let ttyName = surfaceTTYNames[panelId] + + let terminalSnapshot: SessionTerminalPanelSnapshot? + let browserSnapshot: SessionBrowserPanelSnapshot? + let markdownSnapshot: SessionMarkdownPanelSnapshot? + switch panel.panelType { + case .terminal: + guard let terminalPanel = panel as? TerminalPanel else { return nil } + let shouldPersistScrollback = terminalPanel.shouldPersistScrollbackForSessionSnapshot() + let capturedScrollback = includeScrollback && shouldPersistScrollback + ? TerminalController.shared.readTerminalTextForSnapshot( + terminalPanel: terminalPanel, + includeScrollback: true, + lineLimit: SessionPersistencePolicy.maxScrollbackLinesPerTerminal + ) + : nil + let resolvedScrollback = terminalSnapshotScrollback( + panelId: panelId, + capturedScrollback: capturedScrollback, + includeScrollback: includeScrollback, + allowFallbackScrollback: shouldPersistScrollback + ) + terminalSnapshot = SessionTerminalPanelSnapshot( + workingDirectory: panelDirectories[panelId], + scrollback: resolvedScrollback + ) + browserSnapshot = nil + markdownSnapshot = nil + case .browser: + guard let browserPanel = panel as? BrowserPanel else { return nil } + terminalSnapshot = nil + let historySnapshot = browserPanel.sessionNavigationHistorySnapshot() + browserSnapshot = SessionBrowserPanelSnapshot( + urlString: browserPanel.preferredURLStringForOmnibar(), + profileID: browserPanel.profileID, + shouldRenderWebView: browserPanel.shouldRenderWebView, + pageZoom: Double(browserPanel.currentPageZoomFactor()), + developerToolsVisible: browserPanel.isDeveloperToolsVisible(), + backHistoryURLStrings: historySnapshot.backHistoryURLStrings, + forwardHistoryURLStrings: historySnapshot.forwardHistoryURLStrings + ) + markdownSnapshot = nil + case .markdown: + guard let markdownPanel = panel as? MarkdownPanel else { return nil } + terminalSnapshot = nil + browserSnapshot = nil + markdownSnapshot = SessionMarkdownPanelSnapshot(filePath: markdownPanel.filePath) + } + + return SessionPanelSnapshot( + id: panelId, + type: panel.panelType, + title: panelTitle, + customTitle: customTitle, + directory: directory, + isPinned: isPinned, + isManuallyUnread: isManuallyUnread, + gitBranch: branchSnapshot, + listeningPorts: listeningPorts, + ttyName: ttyName, + terminal: terminalSnapshot, + browser: browserSnapshot, + markdown: markdownSnapshot + ) + } + + nonisolated static func resolvedSnapshotTerminalScrollback( + capturedScrollback: String?, + fallbackScrollback: String?, + allowFallbackScrollback: Bool = true + ) -> String? { + if let captured = SessionPersistencePolicy.truncatedScrollback(capturedScrollback) { + return captured + } + guard allowFallbackScrollback else { return nil } + return SessionPersistencePolicy.truncatedScrollback(fallbackScrollback) + } + + private func terminalSnapshotScrollback( + panelId: UUID, + capturedScrollback: String?, + includeScrollback: Bool, + allowFallbackScrollback: Bool = true + ) -> String? { + guard includeScrollback else { return nil } + let fallback = allowFallbackScrollback ? restoredTerminalScrollbackByPanelId[panelId] : nil + let resolved = Self.resolvedSnapshotTerminalScrollback( + capturedScrollback: capturedScrollback, + fallbackScrollback: fallback, + allowFallbackScrollback: allowFallbackScrollback + ) + if let resolved { + restoredTerminalScrollbackByPanelId[panelId] = resolved + } else { + restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) + } + return resolved + } + + private func restoreSessionLayout(_ layout: SessionWorkspaceLayoutSnapshot) -> [SessionPaneRestoreEntry] { + guard let rootPaneId = bonsplitController.allPaneIds.first else { + return [] + } + + var leaves: [SessionPaneRestoreEntry] = [] + restoreSessionLayoutNode(layout, inPane: rootPaneId, leaves: &leaves) + return leaves + } + + private func restoreSessionLayoutNode( + _ node: SessionWorkspaceLayoutSnapshot, + inPane paneId: PaneID, + leaves: inout [SessionPaneRestoreEntry] + ) { + switch node { + 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 + + if anchorPanelId == nil { + anchorPanelId = newTerminalSurface(inPane: paneId, focus: false)?.id + } + + guard let anchorPanelId, + let newSplitPanel = newTerminalSplit( + from: anchorPanelId, + orientation: split.orientation.splitOrientation, + insertFirst: false, + focus: false + ), + let secondPaneId = self.paneId(forPanelId: newSplitPanel.id) else { + leaves.append( + SessionPaneRestoreEntry( + paneId: paneId, + snapshot: SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil) + ) + ) + return + } + + restoreSessionLayoutNode(split.first, inPane: paneId, leaves: &leaves) + restoreSessionLayoutNode(split.second, inPane: secondPaneId, leaves: &leaves) + } + } + + private func restorePane( + _ paneId: PaneID, + snapshot: SessionPaneLayoutSnapshot, + panelSnapshotsById: [UUID: SessionPanelSnapshot], + oldToNewPanelIds: inout [UUID: UUID] + ) { + let existingPanelIds = bonsplitController + .tabs(inPane: paneId) + .compactMap { panelIdFromSurfaceId($0.id) } + let desiredOldPanelIds = snapshot.panelIds.filter { panelSnapshotsById[$0] != nil } + + var createdPanelIds: [UUID] = [] + for oldPanelId in desiredOldPanelIds { + guard let panelSnapshot = panelSnapshotsById[oldPanelId] else { continue } + guard let createdPanelId = createPanel(from: panelSnapshot, inPane: paneId) else { continue } + createdPanelIds.append(createdPanelId) + oldToNewPanelIds[oldPanelId] = createdPanelId + } + + guard !createdPanelIds.isEmpty else { return } + + for oldPanelId in existingPanelIds where !createdPanelIds.contains(oldPanelId) { + _ = closePanel(oldPanelId, force: true) + } + + for (index, panelId) in createdPanelIds.enumerated() { + _ = reorderSurface(panelId: panelId, toIndex: index) + } + + let selectedPanelId: UUID? = { + if let selectedOldId = snapshot.selectedPanelId { + return oldToNewPanelIds[selectedOldId] + } + return createdPanelIds.first + }() + + if let selectedPanelId, + let selectedTabId = surfaceIdFromPanelId(selectedPanelId) { + bonsplitController.focusPane(paneId) + bonsplitController.selectTab(selectedTabId) + } + } + + private func createPanel(from snapshot: SessionPanelSnapshot, inPane paneId: PaneID) -> UUID? { + switch snapshot.type { + case .terminal: + let workingDirectory = snapshot.terminal?.workingDirectory ?? snapshot.directory ?? currentDirectory + let replayEnvironment = SessionScrollbackReplayStore.replayEnvironment( + for: snapshot.terminal?.scrollback + ) + guard let terminalPanel = newTerminalSurface( + inPane: paneId, + focus: false, + workingDirectory: workingDirectory, + startupEnvironment: replayEnvironment + ) else { + return nil + } + let fallbackScrollback = SessionPersistencePolicy.truncatedScrollback(snapshot.terminal?.scrollback) + if let fallbackScrollback { + restoredTerminalScrollbackByPanelId[terminalPanel.id] = fallbackScrollback + } else { + restoredTerminalScrollbackByPanelId.removeValue(forKey: terminalPanel.id) + } + applySessionPanelMetadata(snapshot, toPanelId: terminalPanel.id) + return terminalPanel.id + case .browser: + guard let browserPanel = newBrowserSurface( + inPane: paneId, + url: nil, + focus: false, + preferredProfileID: snapshot.browser?.profileID + ) else { + return nil + } + applySessionPanelMetadata(snapshot, toPanelId: browserPanel.id) + return browserPanel.id + case .markdown: + guard let filePath = snapshot.markdown?.filePath, + let markdownPanel = newMarkdownSurface( + inPane: paneId, + filePath: filePath, + focus: false + ) else { + return nil + } + applySessionPanelMetadata(snapshot, toPanelId: markdownPanel.id) + return markdownPanel.id + } + } + + private func applySessionPanelMetadata(_ snapshot: SessionPanelSnapshot, toPanelId panelId: UUID) { + if let title = snapshot.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { + panelTitles[panelId] = title + } + + setPanelCustomTitle(panelId: panelId, title: snapshot.customTitle) + setPanelPinned(panelId: panelId, pinned: snapshot.isPinned) + + if snapshot.isManuallyUnread { + markPanelUnread(panelId) + } else { + clearManualUnread(panelId: panelId) + } + + if let directory = snapshot.directory?.trimmingCharacters(in: .whitespacesAndNewlines), !directory.isEmpty { + updatePanelDirectory(panelId: panelId, directory: directory) + } + + if let branch = snapshot.gitBranch { + panelGitBranches[panelId] = SidebarGitBranchState(branch: branch.branch, isDirty: branch.isDirty) + } else { + panelGitBranches.removeValue(forKey: panelId) + } + + surfaceListeningPorts[panelId] = Array(Set(snapshot.listeningPorts)).sorted() + + if let ttyName = snapshot.ttyName?.trimmingCharacters(in: .whitespacesAndNewlines), !ttyName.isEmpty { + surfaceTTYNames[panelId] = ttyName + } else { + surfaceTTYNames.removeValue(forKey: panelId) + } + syncRemotePortScanTTYs() + + if let browserSnapshot = snapshot.browser, + let browserPanel = browserPanel(for: panelId) { + let pageZoom = CGFloat(max(0.25, min(5.0, browserSnapshot.pageZoom))) + if pageZoom.isFinite { + _ = browserPanel.setPageZoomFactor(pageZoom) + } + + browserPanel.restoreSessionSnapshot(browserSnapshot) + + if browserSnapshot.developerToolsVisible { + _ = browserPanel.showDeveloperTools() + browserPanel.requestDeveloperToolsRefreshAfterNextAttach(reason: "session_restore") + } else { + _ = browserPanel.hideDeveloperTools() + } + } + } + + private func applySessionDividerPositions( + snapshotNode: SessionWorkspaceLayoutSnapshot, + liveNode: ExternalTreeNode + ) { + switch (snapshotNode, liveNode) { + case (.split(let snapshotSplit), .split(let liveSplit)): + if let splitID = UUID(uuidString: liveSplit.id) { + _ = bonsplitController.setDividerPosition( + CGFloat(snapshotSplit.dividerPosition), + forSplit: splitID, + fromExternal: true + ) + } + applySessionDividerPositions(snapshotNode: snapshotSplit.first, liveNode: liveSplit.first) + applySessionDividerPositions(snapshotNode: snapshotSplit.second, liveNode: liveSplit.second) + default: + return + } + } +} diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 5c90353b45b..c631db164b6 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -170,7 +170,7 @@ enum SidebarMetadataFormat: String { case markdown } -private struct SessionPaneRestoreEntry { +struct SessionPaneRestoreEntry { let paneId: PaneID let snapshot: SessionPaneLayoutSnapshot } @@ -226,3019 +226,2268 @@ struct WorkspaceRemoteDaemonManifest: Decodable, Equatable { } } -extension Workspace { - nonisolated static let remoteDaemonManifestInfoKey = WorkspaceRemoteSessionController.remoteDaemonManifestInfoKey +enum SidebarLogLevel: String { + case info + case progress + case success + case warning + case error +} - nonisolated static func remoteDaemonManifest(from infoDictionary: [String: Any]?) -> WorkspaceRemoteDaemonManifest? { - WorkspaceRemoteSessionController.remoteDaemonManifest(from: infoDictionary) - } +struct SidebarLogEntry: Equatable { + let message: String + let level: SidebarLogLevel + let source: String? + let timestamp: Date +} - nonisolated static func remoteDaemonCachedBinaryURL( - version: String, - goOS: String, - goArch: String, - fileManager: FileManager = .default - ) throws -> URL { - try WorkspaceRemoteSessionController.remoteDaemonCachedBinaryURL( - version: version, - goOS: goOS, - goArch: goArch, - fileManager: fileManager - ) +struct SidebarProgressState: Equatable { + let value: Double + let label: String? +} + +struct SidebarGitBranchState: Equatable { + let branch: String + let isDirty: Bool +} + +private struct SidebarPanelObservationState: Equatable { + let panelIds: [UUID] + + init(panels: [UUID: any Panel]) { + panelIds = panels.keys.sorted { $0.uuidString < $1.uuidString } } +} - func sessionSnapshot(includeScrollback: Bool) -> SessionWorkspaceSnapshot { - let tree = bonsplitController.treeSnapshot() - let layout = sessionLayoutSnapshot(from: tree) +enum WorkspaceRemoteConnectionState: String { + case disconnected + case connecting + case connected + case error +} - let orderedPanelIds = sidebarOrderedPanelIds() - var seen: Set = [] - var allPanelIds: [UUID] = [] - for panelId in orderedPanelIds where seen.insert(panelId).inserted { - allPanelIds.append(panelId) - } - for panelId in panels.keys.sorted(by: { $0.uuidString < $1.uuidString }) where seen.insert(panelId).inserted { - allPanelIds.append(panelId) - } - - let panelSnapshots = allPanelIds - .prefix(SessionPersistencePolicy.maxPanelsPerWorkspace) - .compactMap { sessionPanelSnapshot(panelId: $0, includeScrollback: includeScrollback) } - - let statusSnapshots = statusEntries.values - .sorted { lhs, rhs in lhs.key < rhs.key } - .map { entry in - SessionStatusEntrySnapshot( - key: entry.key, - value: entry.value, - icon: entry.icon, - color: entry.color, - timestamp: entry.timestamp.timeIntervalSince1970 - ) - } - let logSnapshots = logEntries.map { entry in - SessionLogEntrySnapshot( - message: entry.message, - level: entry.level.rawValue, - source: entry.source, - timestamp: entry.timestamp.timeIntervalSince1970 - ) - } +enum WorkspaceRemoteDaemonState: String { + case unavailable + case bootstrapping + case ready + case error +} - let progressSnapshot = progress.map { progress in - SessionProgressSnapshot(value: progress.value, label: progress.label) - } - let gitBranchSnapshot = gitBranch.map { branch in - SessionGitBranchSnapshot(branch: branch.branch, isDirty: branch.isDirty) - } +struct WorkspaceRemoteDaemonStatus: Equatable { + var state: WorkspaceRemoteDaemonState = .unavailable + var detail: String? + var version: String? + var name: String? + var capabilities: [String] = [] + var remotePath: String? - return SessionWorkspaceSnapshot( - processTitle: processTitle, - customTitle: customTitle, - customDescription: customDescription, - customColor: customColor, - isPinned: isPinned, - currentDirectory: currentDirectory, - focusedPanelId: focusedPanelId, - layout: layout, - panels: panelSnapshots, - statusEntries: statusSnapshots, - logEntries: logSnapshots, - progress: progressSnapshot, - gitBranch: gitBranchSnapshot - ) + func payload() -> [String: Any] { + [ + "state": state.rawValue, + "detail": detail ?? NSNull(), + "version": version ?? NSNull(), + "name": name ?? NSNull(), + "capabilities": capabilities, + "remote_path": remotePath ?? NSNull(), + ] } +} - func restoreSessionSnapshot(_ snapshot: SessionWorkspaceSnapshot) { - restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) +struct WorkspaceRemoteConfiguration: Equatable { + let destination: String + let port: Int? + let identityFile: String? + let sshOptions: [String] + let localProxyPort: Int? + let relayPort: Int? + let relayID: String? + let relayToken: String? + let localSocketPath: String? + let terminalStartupCommand: String? + let foregroundAuthToken: String? - let normalizedCurrentDirectory = snapshot.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) - if !normalizedCurrentDirectory.isEmpty { - currentDirectory = normalizedCurrentDirectory - } + init( + destination: String, + port: Int?, + identityFile: String?, + sshOptions: [String], + localProxyPort: Int?, + relayPort: Int?, + relayID: String?, + relayToken: String?, + localSocketPath: String?, + terminalStartupCommand: String?, + foregroundAuthToken: String? = nil + ) { + self.destination = destination + self.port = port + self.identityFile = identityFile + self.sshOptions = sshOptions + self.localProxyPort = localProxyPort + self.relayPort = relayPort + self.relayID = relayID + self.relayToken = relayToken + self.localSocketPath = localSocketPath + self.terminalStartupCommand = terminalStartupCommand + self.foregroundAuthToken = foregroundAuthToken + } + + var displayTarget: String { + guard let port else { return destination } + return "\(destination):\(port)" + } - let panelSnapshotsById = Dictionary(uniqueKeysWithValues: snapshot.panels.map { ($0.id, $0) }) - let leafEntries = restoreSessionLayout(snapshot.layout) - var oldToNewPanelIds: [UUID: UUID] = [:] + var proxyBrokerTransportKey: String { + let normalizedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedPort = port.map(String.init) ?? "" + let normalizedIdentity = identityFile?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let normalizedLocalProxyPort = localProxyPort.map(String.init) ?? "" + let normalizedOptions = Self.proxyBrokerSSHOptions(sshOptions).joined(separator: "\u{1f}") + return [normalizedDestination, normalizedPort, normalizedIdentity, normalizedOptions, normalizedLocalProxyPort] + .joined(separator: "\u{1e}") + } - for entry in leafEntries { - restorePane( - entry.paneId, - snapshot: entry.snapshot, - panelSnapshotsById: panelSnapshotsById, - oldToNewPanelIds: &oldToNewPanelIds - ) + private static func proxyBrokerSSHOptions(_ options: [String]) -> [String] { + options.compactMap { option in + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + }.filter { option in + proxyBrokerSSHOptionKey(option) != "controlpath" } + } - pruneSurfaceMetadata(validSurfaceIds: Set(panels.keys)) - applySessionDividerPositions(snapshotNode: snapshot.layout, liveNode: bonsplitController.treeSnapshot()) + private static func proxyBrokerSSHOptionKey(_ option: String) -> String? { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) + .first + .map(String.init)? + .lowercased() + } +} - applyProcessTitle(snapshot.processTitle) - setCustomTitle(snapshot.customTitle) - setCustomDescription(snapshot.customDescription) - setCustomColor(snapshot.customColor) - isPinned = snapshot.isPinned +enum SidebarPullRequestStatus: String { + case open + case merged + case closed +} - // Status entries and agent PIDs are ephemeral runtime state tied to running - // processes (e.g. claude_code "Running"). Don't restore them across app - // restarts because the processes that set them are gone. - statusEntries.removeAll() - agentPIDs.removeAll() - agentListeningPorts.removeAll() - logEntries = snapshot.logEntries.map { entry in - SidebarLogEntry( - message: entry.message, - level: SidebarLogLevel(rawValue: entry.level) ?? .info, - source: entry.source, - timestamp: Date(timeIntervalSince1970: entry.timestamp) - ) - } - progress = snapshot.progress.map { SidebarProgressState(value: $0.value, label: $0.label) } - gitBranch = snapshot.gitBranch.map { SidebarGitBranchState(branch: $0.branch, isDirty: $0.isDirty) } +enum SidebarPullRequestChecksStatus: String { + case pass + case fail + case pending +} - recomputeListeningPorts() +private func normalizedSidebarBranchName(_ branch: String?) -> String? { + guard let branch else { return nil } + let trimmed = branch.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed +} - if let focusedOldPanelId = snapshot.focusedPanelId, - let focusedNewPanelId = oldToNewPanelIds[focusedOldPanelId], - panels[focusedNewPanelId] != nil { - focusPanel(focusedNewPanelId) - } else if let fallbackFocusedPanelId = focusedPanelId, panels[fallbackFocusedPanelId] != nil { - focusPanel(fallbackFocusedPanelId) - } else { - scheduleFocusReconcile() - } - } +struct SidebarPullRequestState: Equatable { + let number: Int + let label: String + let url: URL + let status: SidebarPullRequestStatus + let branch: String? + let checks: SidebarPullRequestChecksStatus? - private func sessionLayoutSnapshot(from node: ExternalTreeNode) -> SessionWorkspaceLayoutSnapshot { - switch node { - case .pane(let pane): - let panelIds = sessionPanelIDs(for: pane) - let selectedPanelId = pane.selectedTabId.flatMap(sessionPanelID(forExternalTabIDString:)) - return .pane( - SessionPaneLayoutSnapshot( - panelIds: panelIds, - selectedPanelId: selectedPanelId - ) - ) - case .split(let split): - return .split( - SessionSplitLayoutSnapshot( - orientation: split.orientation.lowercased() == "vertical" ? .vertical : .horizontal, - dividerPosition: split.dividerPosition, - first: sessionLayoutSnapshot(from: split.first), - second: sessionLayoutSnapshot(from: split.second) - ) - ) - } + init( + number: Int, + label: String, + url: URL, + status: SidebarPullRequestStatus, + branch: String? = nil, + checks: SidebarPullRequestChecksStatus? = nil + ) { + self.number = number + self.label = label + self.url = url + self.status = status + self.branch = normalizedSidebarBranchName(branch) + self.checks = checks } +} - private func sessionPanelIDs(for pane: ExternalPaneNode) -> [UUID] { - var panelIds: [UUID] = [] - var seen = Set() - for tab in pane.tabs { - guard let panelId = sessionPanelID(forExternalTabIDString: tab.id) else { continue } - if seen.insert(panelId).inserted { - panelIds.append(panelId) - } - } - return panelIds +enum SidebarBranchOrdering { + struct BranchEntry: Equatable { + let name: String + let isDirty: Bool } - 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 } - if surfaceUUID == tabUUID { - return panelId - } - } - return nil + struct BranchDirectoryEntry: Equatable { + let branch: String? + let isDirty: Bool + let directory: String? } - private func sessionSurfaceUUID(for surfaceId: TabID) -> UUID? { - struct EncodedSurfaceID: Decodable { - let id: UUID - } + fileprivate static func normalizedDirectory(_ text: String?) -> String? { + guard let text else { return nil } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } - guard let data = try? JSONEncoder().encode(surfaceId), - let decoded = try? JSONDecoder().decode(EncodedSurfaceID.self, from: data) else { + private static func relativePathFromTilde(_ directory: String) -> String? { + let normalized = normalizedDirectory(directory) + switch normalized { + case "~": + return "" + case let path? where path.hasPrefix("~/"): + return String(path.dropFirst(2)) + default: return nil } - return decoded.id } - private func sessionPanelSnapshot(panelId: UUID, includeScrollback: Bool) -> SessionPanelSnapshot? { - guard let panel = panels[panelId] else { return nil } + private static func commonHomeDirectoryPrefix(from absoluteDirectory: String) -> String? { + guard let normalized = normalizedDirectory(absoluteDirectory) else { return nil } + let standardized = NSString(string: normalized).standardizingPath + if standardized == "/root" || standardized.hasPrefix("/root/") { + return "/root" + } - let panelTitle = panelTitle(panelId: panelId) - let customTitle = panelCustomTitles[panelId] - let directory = panelDirectories[panelId] - let isPinned = pinnedPanelIds.contains(panelId) - let isManuallyUnread = manualUnreadPanelIds.contains(panelId) - let branchSnapshot = panelGitBranches[panelId].map { - SessionGitBranchSnapshot(branch: $0.branch, isDirty: $0.isDirty) + let components = NSString(string: standardized).pathComponents + if components.count >= 3, components[0] == "/", components[1] == "Users" { + return NSString.path(withComponents: Array(components.prefix(3))) } - let listeningPorts: [Int] - if remoteDetectedSurfaceIds.contains(panelId) || isRemoteTerminalSurface(panelId) { - listeningPorts = [] - } else { - listeningPorts = (surfaceListeningPorts[panelId] ?? []).sorted() + if components.count >= 3, components[0] == "/", components[1] == "home" { + return NSString.path(withComponents: Array(components.prefix(3))) + } + if components.count >= 4, components[0] == "/", components[1] == "var", components[2] == "home" { + return NSString.path(withComponents: Array(components.prefix(4))) } - let ttyName = surfaceTTYNames[panelId] - let terminalSnapshot: SessionTerminalPanelSnapshot? - let browserSnapshot: SessionBrowserPanelSnapshot? - let markdownSnapshot: SessionMarkdownPanelSnapshot? - switch panel.panelType { - case .terminal: - guard let terminalPanel = panel as? TerminalPanel else { return nil } - let shouldPersistScrollback = terminalPanel.shouldPersistScrollbackForSessionSnapshot() - let capturedScrollback = includeScrollback && shouldPersistScrollback - ? TerminalController.shared.readTerminalTextForSnapshot( - terminalPanel: terminalPanel, - includeScrollback: true, - lineLimit: SessionPersistencePolicy.maxScrollbackLinesPerTerminal - ) - : nil - let resolvedScrollback = terminalSnapshotScrollback( - panelId: panelId, - capturedScrollback: capturedScrollback, - includeScrollback: includeScrollback, - allowFallbackScrollback: shouldPersistScrollback - ) - terminalSnapshot = SessionTerminalPanelSnapshot( - workingDirectory: panelDirectories[panelId], - scrollback: resolvedScrollback - ) - browserSnapshot = nil - markdownSnapshot = nil - case .browser: - guard let browserPanel = panel as? BrowserPanel else { return nil } - terminalSnapshot = nil - let historySnapshot = browserPanel.sessionNavigationHistorySnapshot() - browserSnapshot = SessionBrowserPanelSnapshot( - urlString: browserPanel.preferredURLStringForOmnibar(), - profileID: browserPanel.profileID, - shouldRenderWebView: browserPanel.shouldRenderWebView, - pageZoom: Double(browserPanel.currentPageZoomFactor()), - developerToolsVisible: browserPanel.isDeveloperToolsVisible(), - backHistoryURLStrings: historySnapshot.backHistoryURLStrings, - forwardHistoryURLStrings: historySnapshot.forwardHistoryURLStrings - ) - markdownSnapshot = nil - case .markdown: - guard let markdownPanel = panel as? MarkdownPanel else { return nil } - terminalSnapshot = nil - browserSnapshot = nil - markdownSnapshot = SessionMarkdownPanelSnapshot(filePath: markdownPanel.filePath) - } - - return SessionPanelSnapshot( - id: panelId, - type: panel.panelType, - title: panelTitle, - customTitle: customTitle, - directory: directory, - isPinned: isPinned, - isManuallyUnread: isManuallyUnread, - gitBranch: branchSnapshot, - listeningPorts: listeningPorts, - ttyName: ttyName, - terminal: terminalSnapshot, - browser: browserSnapshot, - markdown: markdownSnapshot - ) + return nil } - nonisolated static func resolvedSnapshotTerminalScrollback( - capturedScrollback: String?, - fallbackScrollback: String?, - allowFallbackScrollback: Bool = true + private static func inferredHomeDirectory( + matchingTildeDirectory tildeDirectory: String, + absoluteDirectory: String ) -> String? { - if let captured = SessionPersistencePolicy.truncatedScrollback(capturedScrollback) { - return captured - } - guard allowFallbackScrollback else { return nil } - return SessionPersistencePolicy.truncatedScrollback(fallbackScrollback) - } - - private func terminalSnapshotScrollback( - panelId: UUID, - capturedScrollback: String?, - includeScrollback: Bool, - allowFallbackScrollback: Bool = true - ) -> String? { - guard includeScrollback else { return nil } - let fallback = allowFallbackScrollback ? restoredTerminalScrollbackByPanelId[panelId] : nil - let resolved = Self.resolvedSnapshotTerminalScrollback( - capturedScrollback: capturedScrollback, - fallbackScrollback: fallback, - allowFallbackScrollback: allowFallbackScrollback - ) - if let resolved { - restoredTerminalScrollbackByPanelId[panelId] = resolved + guard let relativePath = relativePathFromTilde(tildeDirectory), + let normalizedAbsolute = normalizedDirectory(absoluteDirectory) else { return nil } + let standardizedAbsolute = NSString(string: normalizedAbsolute).standardizingPath + let homeDirectory: String + if relativePath.isEmpty { + homeDirectory = standardizedAbsolute } else { - restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) - } - return resolved - } - - private func restoreSessionLayout(_ layout: SessionWorkspaceLayoutSnapshot) -> [SessionPaneRestoreEntry] { - guard let rootPaneId = bonsplitController.allPaneIds.first else { - return [] + let suffix = "/" + relativePath + guard standardizedAbsolute.hasSuffix(suffix) else { return nil } + homeDirectory = String(standardizedAbsolute.dropLast(suffix.count)) } - var leaves: [SessionPaneRestoreEntry] = [] - restoreSessionLayoutNode(layout, inPane: rootPaneId, leaves: &leaves) - return leaves + guard commonHomeDirectoryPrefix(from: homeDirectory) == homeDirectory else { return nil } + return homeDirectory } - private func restoreSessionLayoutNode( - _ node: SessionWorkspaceLayoutSnapshot, - inPane paneId: PaneID, - leaves: inout [SessionPaneRestoreEntry] - ) { - switch node { - 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 - - if anchorPanelId == nil { - anchorPanelId = newTerminalSurface(inPane: paneId, focus: false)?.id - } + fileprivate static func inferredRemoteHomeDirectory( + from directories: [String], + fallbackDirectory: String? + ) -> String? { + let candidates = directories + [fallbackDirectory].compactMap { $0 } + let tildeDirectories = candidates.compactMap { directory -> String? in + guard let normalized = normalizedDirectory(directory), + relativePathFromTilde(normalized) != nil else { return nil } + return normalized + } + let absoluteDirectories = candidates.compactMap { directory -> String? in + guard let normalized = normalizedDirectory(directory), normalized.hasPrefix("/") else { return nil } + return NSString(string: normalized).standardizingPath + } - guard let anchorPanelId, - let newSplitPanel = newTerminalSplit( - from: anchorPanelId, - orientation: split.orientation.splitOrientation, - insertFirst: false, - focus: false - ), - let secondPaneId = self.paneId(forPanelId: newSplitPanel.id) else { - leaves.append( - SessionPaneRestoreEntry( - paneId: paneId, - snapshot: SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil) + let inferredHomes = Set( + tildeDirectories.flatMap { tildeDirectory in + absoluteDirectories.compactMap { absoluteDirectory in + inferredHomeDirectory( + matchingTildeDirectory: tildeDirectory, + absoluteDirectory: absoluteDirectory ) - ) - return + } } + ) - restoreSessionLayoutNode(split.first, inPane: paneId, leaves: &leaves) - restoreSessionLayoutNode(split.second, inPane: secondPaneId, leaves: &leaves) + if inferredHomes.count == 1 { + return inferredHomes.first } - } - - private func restorePane( - _ paneId: PaneID, - snapshot: SessionPaneLayoutSnapshot, - panelSnapshotsById: [UUID: SessionPanelSnapshot], - oldToNewPanelIds: inout [UUID: UUID] - ) { - let existingPanelIds = bonsplitController - .tabs(inPane: paneId) - .compactMap { panelIdFromSurfaceId($0.id) } - let desiredOldPanelIds = snapshot.panelIds.filter { panelSnapshotsById[$0] != nil } - - var createdPanelIds: [UUID] = [] - for oldPanelId in desiredOldPanelIds { - guard let panelSnapshot = panelSnapshotsById[oldPanelId] else { continue } - guard let createdPanelId = createPanel(from: panelSnapshot, inPane: paneId) else { continue } - createdPanelIds.append(createdPanelId) - oldToNewPanelIds[oldPanelId] = createdPanelId + if !inferredHomes.isEmpty { + return nil } - guard !createdPanelIds.isEmpty else { return } - - for oldPanelId in existingPanelIds where !createdPanelIds.contains(oldPanelId) { - _ = closePanel(oldPanelId, force: true) - } + return absoluteDirectories.lazy.compactMap(commonHomeDirectoryPrefix(from:)).first + } - for (index, panelId) in createdPanelIds.enumerated() { - _ = reorderSurface(panelId: panelId, toIndex: index) + private static func expandedTildePath( + _ directory: String, + homeDirectoryForTildeExpansion: String? + ) -> String { + guard let relativePath = relativePathFromTilde(directory), + let homeDirectory = normalizedDirectory(homeDirectoryForTildeExpansion) else { + return directory } - - let selectedPanelId: UUID? = { - if let selectedOldId = snapshot.selectedPanelId { - return oldToNewPanelIds[selectedOldId] - } - return createdPanelIds.first - }() - - if let selectedPanelId, - let selectedTabId = surfaceIdFromPanelId(selectedPanelId) { - bonsplitController.focusPane(paneId) - bonsplitController.selectTab(selectedTabId) + if relativePath.isEmpty { + return homeDirectory } + return NSString(string: homeDirectory).appendingPathComponent(relativePath) } - private func createPanel(from snapshot: SessionPanelSnapshot, inPane paneId: PaneID) -> UUID? { - switch snapshot.type { - case .terminal: - let workingDirectory = snapshot.terminal?.workingDirectory ?? snapshot.directory ?? currentDirectory - let replayEnvironment = SessionScrollbackReplayStore.replayEnvironment( - for: snapshot.terminal?.scrollback - ) - guard let terminalPanel = newTerminalSurface( - inPane: paneId, - focus: false, - workingDirectory: workingDirectory, - startupEnvironment: replayEnvironment - ) else { - return nil - } - let fallbackScrollback = SessionPersistencePolicy.truncatedScrollback(snapshot.terminal?.scrollback) - if let fallbackScrollback { - restoredTerminalScrollbackByPanelId[terminalPanel.id] = fallbackScrollback - } else { - restoredTerminalScrollbackByPanelId.removeValue(forKey: terminalPanel.id) - } - applySessionPanelMetadata(snapshot, toPanelId: terminalPanel.id) - return terminalPanel.id - case .browser: - guard let browserPanel = newBrowserSurface( - inPane: paneId, - url: nil, - focus: false, - preferredProfileID: snapshot.browser?.profileID - ) else { - return nil - } - applySessionPanelMetadata(snapshot, toPanelId: browserPanel.id) - return browserPanel.id - case .markdown: - guard let filePath = snapshot.markdown?.filePath, - let markdownPanel = newMarkdownSurface( - inPane: paneId, - filePath: filePath, - focus: false - ) else { - return nil - } - applySessionPanelMetadata(snapshot, toPanelId: markdownPanel.id) - return markdownPanel.id - } + fileprivate static func canonicalDirectoryKey( + _ directory: String?, + homeDirectoryForTildeExpansion: String? + ) -> String? { + guard let directory = normalizedDirectory(directory) else { return nil } + let expanded = expandedTildePath( + directory, + homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion + ) + let standardized = NSString(string: expanded).standardizingPath + let cleaned = standardized.trimmingCharacters(in: .whitespacesAndNewlines) + return cleaned.isEmpty ? nil : cleaned } - private func applySessionPanelMetadata(_ snapshot: SessionPanelSnapshot, toPanelId panelId: UUID) { - if let title = snapshot.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { - panelTitles[panelId] = title - } - - setPanelCustomTitle(panelId: panelId, title: snapshot.customTitle) - setPanelPinned(panelId: panelId, pinned: snapshot.isPinned) - - if snapshot.isManuallyUnread { - markPanelUnread(panelId) - } else { - clearManualUnread(panelId: panelId) - } + private static func preferredDisplayedDirectory( + existing: String?, + replacement: String?, + homeDirectoryForTildeExpansion: String? + ) -> String? { + guard let replacement = normalizedDirectory(replacement) else { return existing } + guard let existing = normalizedDirectory(existing) else { return replacement } - if let directory = snapshot.directory?.trimmingCharacters(in: .whitespacesAndNewlines), !directory.isEmpty { - updatePanelDirectory(panelId: panelId, directory: directory) + let existingUsesTilde = relativePathFromTilde(existing) != nil + let replacementUsesTilde = relativePathFromTilde(replacement) != nil + if existingUsesTilde != replacementUsesTilde { + return replacementUsesTilde ? existing : replacement } - if let branch = snapshot.gitBranch { - panelGitBranches[panelId] = SidebarGitBranchState(branch: branch.branch, isDirty: branch.isDirty) - } else { - panelGitBranches.removeValue(forKey: panelId) + if canonicalDirectoryKey(existing, homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion) + == canonicalDirectoryKey( + replacement, + homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion + ) { + return existing } - surfaceListeningPorts[panelId] = Array(Set(snapshot.listeningPorts)).sorted() + return replacement + } - if let ttyName = snapshot.ttyName?.trimmingCharacters(in: .whitespacesAndNewlines), !ttyName.isEmpty { - surfaceTTYNames[panelId] = ttyName - } else { - surfaceTTYNames.removeValue(forKey: panelId) + static func orderedPaneIds(tree: ExternalTreeNode) -> [String] { + switch tree { + case .pane(let pane): + return [pane.id] + case .split(let split): + // Bonsplit split order matches visual order for both horizontal and vertical splits. + return orderedPaneIds(tree: split.first) + orderedPaneIds(tree: split.second) } - syncRemotePortScanTTYs() - - if let browserSnapshot = snapshot.browser, - let browserPanel = browserPanel(for: panelId) { - let pageZoom = CGFloat(max(0.25, min(5.0, browserSnapshot.pageZoom))) - if pageZoom.isFinite { - _ = browserPanel.setPageZoomFactor(pageZoom) - } + } - browserPanel.restoreSessionSnapshot(browserSnapshot) + static func orderedPanelIds( + tree: ExternalTreeNode, + paneTabs: [String: [UUID]], + fallbackPanelIds: [UUID] + ) -> [UUID] { + var ordered: [UUID] = [] + var seen: Set = [] - if browserSnapshot.developerToolsVisible { - _ = browserPanel.showDeveloperTools() - browserPanel.requestDeveloperToolsRefreshAfterNextAttach(reason: "session_restore") - } else { - _ = browserPanel.hideDeveloperTools() + for paneId in orderedPaneIds(tree: tree) { + for panelId in paneTabs[paneId] ?? [] { + if seen.insert(panelId).inserted { + ordered.append(panelId) + } } } - } - private func applySessionDividerPositions( - snapshotNode: SessionWorkspaceLayoutSnapshot, - liveNode: ExternalTreeNode - ) { - switch (snapshotNode, liveNode) { - case (.split(let snapshotSplit), .split(let liveSplit)): - if let splitID = UUID(uuidString: liveSplit.id) { - _ = bonsplitController.setDividerPosition( - CGFloat(snapshotSplit.dividerPosition), - forSplit: splitID, - fromExternal: true - ) + for panelId in fallbackPanelIds { + if seen.insert(panelId).inserted { + ordered.append(panelId) } - applySessionDividerPositions(snapshotNode: snapshotSplit.first, liveNode: liveSplit.first) - applySessionDividerPositions(snapshotNode: snapshotSplit.second, liveNode: liveSplit.second) - default: - return } - } -} - -// MARK: - programa.json custom layout -extension Workspace { + return ordered + } - func applyCustomLayout(_ layout: ProgramaLayoutNode, baseCwd: String) { - guard let rootPaneId = bonsplitController.allPaneIds.first else { return } + static func orderedUniqueBranches( + orderedPanelIds: [UUID], + panelBranches: [UUID: SidebarGitBranchState], + fallbackBranch: SidebarGitBranchState? + ) -> [BranchEntry] { + var orderedNames: [String] = [] + var branchDirty: [String: Bool] = [:] - var leaves: [(paneId: PaneID, surfaces: [ProgramaSurfaceDefinition])] = [] - buildCustomLayoutTree(layout, inPane: rootPaneId, leaves: &leaves) + for panelId in orderedPanelIds { + guard let state = panelBranches[panelId] else { continue } + let name = state.branch.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { continue } - // First leaf reuses the initial terminal created by addWorkspace; - // subsequent leaves were created via newTerminalSplit which also seeds - // a placeholder terminal. - var focusPanelId: UUID? - for leaf in leaves { - populateCustomPane(leaf.paneId, surfaces: leaf.surfaces, baseCwd: baseCwd, focusPanelId: &focusPanelId) + if branchDirty[name] == nil { + orderedNames.append(name) + branchDirty[name] = state.isDirty + } else if state.isDirty { + branchDirty[name] = true + } } - let liveRoot = bonsplitController.treeSnapshot() - applyCustomDividerPositions(configNode: layout, liveNode: liveRoot) + if orderedNames.isEmpty, let fallbackBranch { + let name = fallbackBranch.branch.trimmingCharacters(in: .whitespacesAndNewlines) + if !name.isEmpty { + return [BranchEntry(name: name, isDirty: fallbackBranch.isDirty)] + } + } - if let focusPanelId { - focusPanel(focusPanelId) + return orderedNames.map { name in + BranchEntry(name: name, isDirty: branchDirty[name] ?? false) } } - private func buildCustomLayoutTree( - _ node: ProgramaLayoutNode, - inPane paneId: PaneID, - leaves: inout [(paneId: PaneID, surfaces: [ProgramaSurfaceDefinition])] - ) { - switch node { - case .pane(let pane): - leaves.append((paneId: paneId, surfaces: pane.surfaces)) - - case .split(let split): - guard split.children.count == 2 else { - NSLog("[ProgramaConfig] split node requires exactly 2 children, got %d", split.children.count) - leaves.append((paneId: paneId, surfaces: [])) - return + static func orderedUniquePullRequests( + orderedPanelIds: [UUID], + panelPullRequests: [UUID: SidebarPullRequestState], + fallbackPullRequest: SidebarPullRequestState? + ) -> [SidebarPullRequestState] { + func statusPriority(_ status: SidebarPullRequestStatus) -> Int { + switch status { + case .merged: return 3 + case .open: return 2 + case .closed: return 1 } + } - var anchorPanelId = bonsplitController - .tabs(inPane: paneId) - .compactMap { panelIdFromSurfaceId($0.id) } - .first - - if anchorPanelId == nil { - anchorPanelId = newTerminalSurface(inPane: paneId, focus: false)?.id + func checksPriority(_ checks: SidebarPullRequestChecksStatus?) -> Int { + switch checks { + case .fail: return 3 + case .pending: return 2 + case .pass: return 1 + case nil: return 0 } + } - guard let anchorPanelId, - let newSplitPanel = newTerminalSplit( - from: anchorPanelId, - orientation: split.splitOrientation, - insertFirst: false, - focus: false - ), - let secondPaneId = self.paneId(forPanelId: newSplitPanel.id) else { - leaves.append((paneId: paneId, surfaces: [])) - return + func normalizedReviewURLKey(for url: URL) -> String { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url.absoluteString } - buildCustomLayoutTree(split.children[0], inPane: paneId, leaves: &leaves) - buildCustomLayoutTree(split.children[1], inPane: secondPaneId, leaves: &leaves) + // Treat URL variants that differ only by query/fragment as the same review item. + components.query = nil + components.fragment = nil + let scheme = components.scheme?.lowercased() ?? "" + let host = components.host?.lowercased() ?? "" + let port = components.port.map { ":\($0)" } ?? "" + var path = components.path + if path.hasSuffix("/"), path.count > 1 { + path.removeLast() + } + return "\(scheme)://\(host)\(port)\(path)" } - } - - private func populateCustomPane( - _ paneId: PaneID, - surfaces: [ProgramaSurfaceDefinition], - baseCwd: String, - focusPanelId: inout UUID? - ) { - let existingPanelIds = bonsplitController - .tabs(inPane: paneId) - .compactMap { panelIdFromSurfaceId($0.id) } - - guard !surfaces.isEmpty else { return } - let firstSurface = surfaces[0] - if let placeholderPanelId = existingPanelIds.first { - configureExistingSurface( - panelId: placeholderPanelId, - inPane: paneId, - surface: firstSurface, - baseCwd: baseCwd, - focusPanelId: &focusPanelId - ) + func reviewKey(for state: SidebarPullRequestState) -> String { + "\(state.label.lowercased())#\(state.number)|\(normalizedReviewURLKey(for: state.url))" } - for surfaceIndex in 1.. statusPriority(existing.status) { + pullRequestsByKey[key] = state + } else if state.status == existing.status, + checksPriority(state.checks) > checksPriority(existing.checks) { + pullRequestsByKey[key] = state } } - } - - private func createNewSurface( - inPane paneId: PaneID, - surface: ProgramaSurfaceDefinition, - baseCwd: String, - focusPanelId: inout UUID? - ) { - switch surface.type { - case .terminal: - let resolvedCwd = ProgramaConfigStore.resolveCwd(surface.cwd, relativeTo: baseCwd) - if let panel = newTerminalSurface( - inPane: paneId, - focus: false, - workingDirectory: resolvedCwd, - startupEnvironment: surface.env ?? [:] - ) { - if let name = surface.name { setPanelCustomTitle(panelId: panel.id, title: name) } - if surface.focus == true { focusPanelId = panel.id } - if let command = surface.command { sendInputWhenReady(command + "\n", to: panel) } - } - case .browser: - let url = surface.url.flatMap { URL(string: $0) } - if let panel = newBrowserSurface(inPane: paneId, url: url, focus: false) { - if let name = surface.name { setPanelCustomTitle(panelId: panel.id, title: name) } - if surface.focus == true { focusPanelId = panel.id } - } + if orderedKeys.isEmpty, let fallbackPullRequest { + return [fallbackPullRequest] } + + return orderedKeys.compactMap { pullRequestsByKey[$0] } } - private func applyCustomDividerPositions( - configNode: ProgramaLayoutNode, - liveNode: ExternalTreeNode - ) { - switch (configNode, liveNode) { - case (.split(let configSplit), .split(let liveSplit)): - if let splitID = UUID(uuidString: liveSplit.id) { - _ = bonsplitController.setDividerPosition( - CGFloat(configSplit.clampedSplitPosition), - forSplit: splitID, - fromExternal: true - ) - } - if configSplit.children.count == 2 { - applyCustomDividerPositions(configNode: configSplit.children[0], liveNode: liveSplit.first) - applyCustomDividerPositions(configNode: configSplit.children[1], liveNode: liveSplit.second) - } - default: - break + static func orderedUniqueBranchDirectoryEntries( + orderedPanelIds: [UUID], + panelBranches: [UUID: SidebarGitBranchState], + panelDirectories: [UUID: String], + defaultDirectory: String?, + homeDirectoryForTildeExpansion: String?, + fallbackBranch: SidebarGitBranchState? + ) -> [BranchDirectoryEntry] { + struct EntryKey: Hashable { + let directory: String? + let branch: String? } - } - private func sendInputWhenReady(_ text: String, to panel: TerminalPanel) { - if panel.surface.surface != nil { - panel.sendInput(text) - return + struct MutableEntry { + var branch: String? + var isDirty: Bool + var directory: String? } - var resolved = false - var observer: NSObjectProtocol? + let normalized = normalizedDirectory + let normalizedFallbackBranch = normalized(fallbackBranch?.branch) + let shouldUseFallbackBranchPerPanel = !orderedPanelIds.contains { + normalized(panelBranches[$0]?.branch) != nil + } + let defaultBranchForPanels = shouldUseFallbackBranchPerPanel ? normalizedFallbackBranch : nil + let defaultBranchDirty = shouldUseFallbackBranchPerPanel ? (fallbackBranch?.isDirty ?? false) : false - observer = NotificationCenter.default.addObserver( - forName: .terminalSurfaceDidBecomeReady, - object: panel.surface, - queue: .main - ) { [weak panel] _ in - guard !resolved, let panel else { return } - resolved = true - if let observer { NotificationCenter.default.removeObserver(observer) } - panel.sendInput(text) + var order: [EntryKey] = [] + var entries: [EntryKey: MutableEntry] = [:] + + for panelId in orderedPanelIds { + let panelBranch = normalized(panelBranches[panelId]?.branch) + let branch = panelBranch ?? defaultBranchForPanels + let directory = normalized(panelDirectories[panelId]) + guard branch != nil || directory != nil else { continue } + + let panelDirty = panelBranch != nil + ? (panelBranches[panelId]?.isDirty ?? false) + : defaultBranchDirty + + let key: EntryKey + if let directoryKey = canonicalDirectoryKey( + directory, + homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion + ) { + // Keep one line per directory and allow the latest branch state to overwrite. + key = EntryKey(directory: directoryKey, branch: nil) + } else { + key = EntryKey(directory: nil, branch: branch) + } + + guard key.directory != nil || key.branch != nil else { continue } + + if var existing = entries[key] { + if key.directory != nil { + if let branch { + existing.branch = branch + existing.isDirty = panelDirty + } else if existing.branch == nil { + existing.isDirty = panelDirty + } + existing.directory = preferredDisplayedDirectory( + existing: existing.directory, + replacement: directory, + homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion + ) + entries[key] = existing + } else if panelDirty { + existing.isDirty = true + entries[key] = existing + } + } else { + order.append(key) + entries[key] = MutableEntry(branch: branch, isDirty: panelDirty, directory: directory) + } + } + + if order.isEmpty { + let fallbackDirectory = normalized(defaultDirectory) + if normalizedFallbackBranch != nil || fallbackDirectory != nil { + return [ + BranchDirectoryEntry( + branch: normalizedFallbackBranch, + isDirty: fallbackBranch?.isDirty ?? false, + directory: fallbackDirectory + ) + ] + } } - DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { - guard !resolved else { return } - resolved = true - if let observer { NotificationCenter.default.removeObserver(observer) } - NSLog("[ProgramaConfig] surface not ready after 3s, dropping command (%d chars)", text.count) + return order.compactMap { key in + guard let entry = entries[key] else { return nil } + return BranchDirectoryEntry( + branch: entry.branch, + isDirty: entry.isDirty, + directory: entry.directory + ) } } } -enum SidebarLogLevel: String { - case info - case progress - case success - case warning - case error +struct ClosedBrowserPanelRestoreSnapshot { + let workspaceId: UUID + let url: URL? + let profileID: UUID? + let originalPaneId: UUID + let originalTabIndex: Int + let fallbackSplitOrientation: SplitOrientation? + let fallbackSplitInsertFirst: Bool + let fallbackAnchorPaneId: UUID? } -struct SidebarLogEntry: Equatable { - let message: String - let level: SidebarLogLevel - let source: String? - let timestamp: Date -} +/// Workspace represents a sidebar tab. +/// Each workspace contains one BonsplitController that manages split panes and nested surfaces. +@MainActor +final class Workspace: Identifiable, ObservableObject { + let id: UUID + @Published var title: String + @Published var customTitle: String? + @Published var customDescription: String? + @Published var isPinned: Bool = false + @Published var customColor: String? // hex string, e.g. "#C0392B" + @Published var currentDirectory: String + private(set) var preferredBrowserProfileID: UUID? -struct SidebarProgressState: Equatable { - let value: Double - let label: String? -} + /// Ordinal for PROGRAMA_PORT range assignment (monotonically increasing per app session) + var portOrdinal: Int = 0 -struct SidebarGitBranchState: Equatable { - let branch: String - let isDirty: Bool -} + /// The bonsplit controller managing the split panes for this workspace + let bonsplitController: BonsplitController -private struct SidebarPanelObservationState: Equatable { - let panelIds: [UUID] + /// Mapping from bonsplit TabID to our Panel instances + @Published var panels: [UUID: any Panel] = [:] - init(panels: [UUID: any Panel]) { - panelIds = panels.keys.sorted { $0.uuidString < $1.uuidString } - } -} + /// Subscriptions for panel updates (e.g., browser title changes) + var panelSubscriptions: [UUID: AnyCancellable] = [:] -enum WorkspaceRemoteConnectionState: String { - case disconnected - case connecting - case connected - case error -} + /// When true, suppresses auto-creation in didSplitPane (programmatic splits handle their own panels) + var isProgrammaticSplit = false + var debugStressPreloadSelectionDepth = 0 -enum WorkspaceRemoteDaemonState: String { - case unavailable - case bootstrapping - case ready - case error -} + /// Last terminal panel used as an inheritance source (typically last focused terminal). + var lastTerminalConfigInheritancePanelId: UUID? + /// Last known terminal font points from inheritance sources. Used as fallback when + /// no live terminal surface is currently available. + var lastTerminalConfigInheritanceFontPoints: Float? + /// Per-panel inherited zoom lineage. Descendants reuse this root value unless + /// a panel is explicitly re-zoomed by the user. + var terminalInheritanceFontPointsByPanelId: [UUID: Float] = [:] -struct WorkspaceRemoteDaemonStatus: Equatable { - var state: WorkspaceRemoteDaemonState = .unavailable - var detail: String? - var version: String? - var name: String? - var capabilities: [String] = [] - var remotePath: String? + /// Callback used by TabManager to capture recently closed browser panels for Cmd+Shift+T restore. + var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)? + weak var owningTabManager: TabManager? - func payload() -> [String: Any] { - [ - "state": state.rawValue, - "detail": detail ?? NSNull(), - "version": version ?? NSNull(), - "name": name ?? NSNull(), - "capabilities": capabilities, - "remote_path": remotePath ?? NSNull(), - ] + + // Closing tabs mutates split layout immediately; terminal views handle their own AppKit + // layout/size synchronization. + + /// The currently focused pane's panel ID + var focusedPanelId: UUID? { + guard let paneId = bonsplitController.focusedPaneId, + let tab = bonsplitController.selectedTab(inPane: paneId) else { + return nil + } + return panelIdFromSurfaceId(tab.id) } -} -struct WorkspaceRemoteConfiguration: Equatable { - let destination: String - let port: Int? - let identityFile: String? - let sshOptions: [String] - let localProxyPort: Int? - let relayPort: Int? - let relayID: String? - let relayToken: String? - let localSocketPath: String? - let terminalStartupCommand: String? - let foregroundAuthToken: String? + /// The currently focused terminal panel (if any) + var focusedTerminalPanel: TerminalPanel? { + guard let panelId = focusedPanelId, + let panel = panels[panelId] as? TerminalPanel else { + return nil + } + return panel + } - init( - destination: String, - port: Int?, - identityFile: String?, - sshOptions: [String], - localProxyPort: Int?, - relayPort: Int?, - relayID: String?, - relayToken: String?, - localSocketPath: String?, - terminalStartupCommand: String?, - foregroundAuthToken: String? = nil - ) { - self.destination = destination - self.port = port - self.identityFile = identityFile - self.sshOptions = sshOptions - self.localProxyPort = localProxyPort - self.relayPort = relayPort - self.relayID = relayID - self.relayToken = relayToken - self.localSocketPath = localSocketPath - self.terminalStartupCommand = terminalStartupCommand - self.foregroundAuthToken = foregroundAuthToken - } - - var displayTarget: String { - guard let port else { return destination } - return "\(destination):\(port)" + func effectiveSelectedPanelId(inPane paneId: PaneID) -> UUID? { + bonsplitController.selectedTab(inPane: paneId).flatMap { panelIdFromSurfaceId($0.id) } } - var proxyBrokerTransportKey: String { - let normalizedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) - let normalizedPort = port.map(String.init) ?? "" - let normalizedIdentity = identityFile?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let normalizedLocalProxyPort = localProxyPort.map(String.init) ?? "" - let normalizedOptions = Self.proxyBrokerSSHOptions(sshOptions).joined(separator: "\u{1f}") - return [normalizedDestination, normalizedPort, normalizedIdentity, normalizedOptions, normalizedLocalProxyPort] - .joined(separator: "\u{1e}") + enum FocusPanelTrigger { + case standard + case terminalFirstResponder } - private static func proxyBrokerSSHOptions(_ options: [String]) -> [String] { - options.compactMap { option in - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - }.filter { option in - proxyBrokerSSHOptionKey(option) != "controlpath" - } - } + /// Published directory for each panel + @Published var panelDirectories: [UUID: String] = [:] + @Published var panelTitles: [UUID: String] = [:] + @Published var panelCustomTitles: [UUID: String] = [:] + @Published var pinnedPanelIds: Set = [] + @Published var manualUnreadPanelIds: Set = [] + @Published var tmuxLayoutSnapshot: LayoutSnapshot? + @Published private(set) var tmuxWorkspaceFlashPanelId: UUID? + @Published private(set) var tmuxWorkspaceFlashReason: WorkspaceAttentionFlashReason? + @Published private(set) var tmuxWorkspaceFlashToken: UInt64 = 0 + var manualUnreadMarkedAt: [UUID: Date] = [:] + nonisolated private static let manualUnreadFocusGraceInterval: TimeInterval = 0.2 + nonisolated static let manualUnreadClearDelayAfterFocusFlash: TimeInterval = 0.2 + @Published var statusEntries: [String: SidebarStatusEntry] = [:] + @Published var metadataBlocks: [String: SidebarMetadataBlock] = [:] + @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 + @Published var remoteConnectionDetail: String? + @Published var remoteDaemonStatus: WorkspaceRemoteDaemonStatus = WorkspaceRemoteDaemonStatus() + @Published var remoteDetectedPorts: [Int] = [] + @Published var remoteForwardedPorts: [Int] = [] + @Published var remotePortConflicts: [Int] = [] + @Published var remoteProxyEndpoint: BrowserProxyEndpoint? + @Published var remoteHeartbeatCount: Int = 0 + @Published var remoteLastHeartbeatAt: Date? + @Published var listeningPorts: [Int] = [] + @Published private(set) var activeRemoteTerminalSessionCount: Int = 0 + var surfaceTTYNames: [UUID: String] = [:] + var remoteSessionController: WorkspaceRemoteSessionController? + var pendingRemoteForegroundAuthToken: String? + var activeRemoteSessionControllerID: UUID? + var remoteLastErrorFingerprint: String? + var remoteLastDaemonErrorFingerprint: String? + var remoteLastPortConflictFingerprint: String? + var remoteDetectedSurfaceIds: Set = [] + var activeRemoteTerminalSurfaceIds: Set = [] + var pendingRemoteTerminalChildExitSurfaceIds: Set = [] + + static let remoteErrorStatusKey = "remote.error" + static let remotePortConflictStatusKey = "remote.port_conflicts" + static let remoteNotificationCooldown: TimeInterval = 5 * 60 + static let sshControlMasterCleanupQueue = DispatchQueue( + label: "com.cmux.remote-ssh.control-master-cleanup", + qos: .utility + ) + static let remoteHeartbeatDateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + nonisolated(unsafe) static var runSSHControlMasterCommandOverrideForTesting: (([String]) -> Void)? + var panelShellActivityStates: [UUID: PanelShellActivityState] = [:] + /// PIDs associated with agent status entries (e.g. claude_code), keyed by status key. + /// Used for stale-session detection: if the PID is dead, the status entry is cleared. + var agentPIDs: [String: pid_t] = [:] + var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] - private static func proxyBrokerSSHOptionKey(_ option: String) -> String? { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) - .first - .map(String.init)? - .lowercased() + func sidebarObservationSignal( + _ publisher: Published.Publisher + ) -> AnyPublisher { + publisher + .dropFirst() + .removeDuplicates() + .map { _ in () } + .eraseToAnyPublisher() } -} -enum SidebarPullRequestStatus: String { - case open - case merged - case closed -} + lazy var sidebarImmediateObservationPublisher: AnyPublisher = { + let publishers: [AnyPublisher] = [ + sidebarObservationSignal($title), + sidebarObservationSignal($customDescription), + sidebarObservationSignal($isPinned), + sidebarObservationSignal($customColor), + ] -enum SidebarPullRequestChecksStatus: String { - case pass - case fail - case pending -} + return Publishers.MergeMany(publishers).eraseToAnyPublisher() + }() -private func normalizedSidebarBranchName(_ branch: String?) -> String? { - guard let branch else { return nil } - let trimmed = branch.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed -} + lazy var sidebarObservationPublisher: AnyPublisher = { + let publishers: [AnyPublisher] = [ + sidebarObservationSignal($currentDirectory), + $panels + .map(SidebarPanelObservationState.init) + .dropFirst() + .removeDuplicates() + .map { _ in () } + .eraseToAnyPublisher(), + sidebarObservationSignal($panelDirectories), + sidebarObservationSignal($statusEntries), + sidebarObservationSignal($metadataBlocks), + sidebarObservationSignal($logEntries), + sidebarObservationSignal($progress), + sidebarObservationSignal($gitBranch), + sidebarObservationSignal($panelGitBranches), + sidebarObservationSignal($pullRequest), + sidebarObservationSignal($panelPullRequests), + sidebarObservationSignal($remoteConfiguration), + sidebarObservationSignal($remoteConnectionState), + sidebarObservationSignal($remoteConnectionDetail), + sidebarObservationSignal($activeRemoteTerminalSessionCount), + sidebarObservationSignal($listeningPorts), + ] -struct SidebarPullRequestState: Equatable { - let number: Int - let label: String - let url: URL - let status: SidebarPullRequestStatus - let branch: String? - let checks: SidebarPullRequestChecksStatus? + return Publishers.MergeMany(publishers).eraseToAnyPublisher() + }() - init( - number: Int, - label: String, - url: URL, - status: SidebarPullRequestStatus, - branch: String? = nil, - checks: SidebarPullRequestChecksStatus? = nil - ) { - self.number = number - self.label = label - self.url = url - self.status = status - self.branch = normalizedSidebarBranchName(branch) - self.checks = checks + static func isProxyOnlyRemoteError(_ detail: String) -> Bool { + let lowered = detail.lowercased() + return lowered.contains("remote proxy") + || lowered.contains("proxy_unavailable") + || lowered.contains("local daemon proxy") + || lowered.contains("proxy failure") + || lowered.contains("daemon transport") } -} -enum SidebarBranchOrdering { - struct BranchEntry: Equatable { - let name: String - let isDirty: Bool + var preservesSSHTerminalConnection: Bool { + activeRemoteTerminalSessionCount > 0 + && remoteConfiguration?.terminalStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false } - struct BranchDirectoryEntry: Equatable { - let branch: String? - let isDirty: Bool - let directory: String? + var hasProxyOnlyRemoteSidebarError: Bool { + guard let entry = statusEntries[Self.remoteErrorStatusKey]?.value else { return false } + return entry.lowercased().contains("remote proxy unavailable") } - fileprivate static func normalizedDirectory(_ text: String?) -> String? { - guard let text else { return nil } - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed + func remoteNotificationCooldownKey(target: String) -> String? { + let rawTarget = (remoteConfiguration?.destination ?? target) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !rawTarget.isEmpty else { return nil } + let normalizedHost = rawTarget + .split(separator: "@", maxSplits: 1, omittingEmptySubsequences: false) + .last + .map(String.init)? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard let normalizedHost, !normalizedHost.isEmpty else { return nil } + return "remote-host:\(normalizedHost)" } - private static func relativePathFromTilde(_ directory: String) -> String? { - let normalized = normalizedDirectory(directory) - switch normalized { - case "~": - return "" - case let path? where path.hasPrefix("~/"): - return String(path.dropFirst(2)) - default: - return nil - } + var focusedSurfaceId: UUID? { focusedPanelId } + var surfaceDirectories: [UUID: String] { + get { panelDirectories } + set { panelDirectories = newValue } } - private static func commonHomeDirectoryPrefix(from absoluteDirectory: String) -> String? { - guard let normalized = normalizedDirectory(absoluteDirectory) else { return nil } - let standardized = NSString(string: normalized).standardizingPath - if standardized == "/root" || standardized.hasPrefix("/root/") { - return "/root" - } + var processTitle: String - let components = NSString(string: standardized).pathComponents - if components.count >= 3, components[0] == "/", components[1] == "Users" { - return NSString.path(withComponents: Array(components.prefix(3))) - } - if components.count >= 3, components[0] == "/", components[1] == "home" { - return NSString.path(withComponents: Array(components.prefix(3))) - } - if components.count >= 4, components[0] == "/", components[1] == "var", components[2] == "home" { - return NSString.path(withComponents: Array(components.prefix(4))) - } + enum SurfaceKind { + static let terminal = "terminal" + static let browser = "browser" + static let markdown = "markdown" + } - return nil + enum PanelShellActivityState: String { + case unknown + case promptIdle + case commandRunning } - private static func inferredHomeDirectory( - matchingTildeDirectory tildeDirectory: String, - absoluteDirectory: String - ) -> String? { - guard let relativePath = relativePathFromTilde(tildeDirectory), - let normalizedAbsolute = normalizedDirectory(absoluteDirectory) else { return nil } - let standardizedAbsolute = NSString(string: normalizedAbsolute).standardizingPath - let homeDirectory: String - if relativePath.isEmpty { - homeDirectory = standardizedAbsolute - } else { - let suffix = "/" + relativePath - guard standardizedAbsolute.hasSuffix(suffix) else { return nil } - homeDirectory = String(standardizedAbsolute.dropLast(suffix.count)) + nonisolated static func resolveCloseConfirmation( + shellActivityState: PanelShellActivityState?, + fallbackNeedsConfirmClose: Bool + ) -> Bool { + switch shellActivityState ?? .unknown { + case .promptIdle: + return false + case .commandRunning: + return true + case .unknown: + return fallbackNeedsConfirmClose } - - guard commonHomeDirectoryPrefix(from: homeDirectory) == homeDirectory else { return nil } - return homeDirectory } - fileprivate static func inferredRemoteHomeDirectory( - from directories: [String], - fallbackDirectory: String? - ) -> String? { - let candidates = directories + [fallbackDirectory].compactMap { $0 } - let tildeDirectories = candidates.compactMap { directory -> String? in - guard let normalized = normalizedDirectory(directory), - relativePathFromTilde(normalized) != nil else { return nil } - return normalized - } - let absoluteDirectories = candidates.compactMap { directory -> String? in - guard let normalized = normalizedDirectory(directory), normalized.hasPrefix("/") else { return nil } - return NSString(string: normalized).standardizingPath - } + // MARK: - Initialization - let inferredHomes = Set( - tildeDirectories.flatMap { tildeDirectory in - absoluteDirectories.compactMap { absoluteDirectory in - inferredHomeDirectory( - matchingTildeDirectory: tildeDirectory, - absoluteDirectory: absoluteDirectory - ) - } - } + static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips { + BonsplitConfiguration.SplitButtonTooltips( + newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip("New Terminal"), + newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip("New Browser"), + splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip("Split Right"), + splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip("Split Down") ) - - if inferredHomes.count == 1 { - return inferredHomes.first - } - if !inferredHomes.isEmpty { - return nil - } - - return absoluteDirectories.lazy.compactMap(commonHomeDirectoryPrefix(from:)).first } - private static func expandedTildePath( - _ directory: String, - homeDirectoryForTildeExpansion: String? - ) -> String { - guard let relativePath = relativePathFromTilde(directory), - let homeDirectory = normalizedDirectory(homeDirectoryForTildeExpansion) else { - return directory - } - if relativePath.isEmpty { - return homeDirectory - } - return NSString(string: homeDirectory).appendingPathComponent(relativePath) + static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance { + bonsplitAppearance( + from: config.backgroundColor, + backgroundOpacity: config.backgroundOpacity + ) } - fileprivate static func canonicalDirectoryKey( - _ directory: String?, - homeDirectoryForTildeExpansion: String? - ) -> String? { - guard let directory = normalizedDirectory(directory) else { return nil } - let expanded = expandedTildePath( - directory, - homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion + static func bonsplitChromeHex(backgroundColor: NSColor, backgroundOpacity: Double) -> String { + let themedColor = GhosttyBackgroundTheme.color( + backgroundColor: backgroundColor, + opacity: backgroundOpacity ) - let standardized = NSString(string: expanded).standardizingPath - let cleaned = standardized.trimmingCharacters(in: .whitespacesAndNewlines) - return cleaned.isEmpty ? nil : cleaned + let includeAlpha = themedColor.alphaComponent < 0.999 + return themedColor.hexString(includeAlpha: includeAlpha) } - private static func preferredDisplayedDirectory( - existing: String?, - replacement: String?, - homeDirectoryForTildeExpansion: String? - ) -> String? { - guard let replacement = normalizedDirectory(replacement) else { return existing } - guard let existing = normalizedDirectory(existing) else { return replacement } - - let existingUsesTilde = relativePathFromTilde(existing) != nil - let replacementUsesTilde = relativePathFromTilde(replacement) != nil - if existingUsesTilde != replacementUsesTilde { - return replacementUsesTilde ? existing : replacement + /// Returns a clearly-perceptible divider hex derived from the chrome background hex. + /// Dark backgrounds are lightened ~28% toward white; light backgrounds are darkened ~20% toward + /// black. These factors are meaningfully stronger than bonsplit's built-in weak fallback (0.16/0.12 + /// tone at reduced alpha), ensuring the 1pt split divider is visible in both dark and light themes. + /// The result is always an opaque #RRGGBB string (no alpha), matching hexString(includeAlpha:false). + static func bonsplitDividerHex(fromChromeHex chromeHex: String) -> String { + // Parse #RRGGBB or #RRGGBBAA produced by hexString(includeAlpha:) + let stripped = chromeHex.hasPrefix("#") ? String(chromeHex.dropFirst()) : chromeHex + guard stripped.count >= 6, + let rByte = UInt8(stripped.prefix(2), radix: 16), + let gByte = UInt8(stripped.dropFirst(2).prefix(2), radix: 16), + let bByte = UInt8(stripped.dropFirst(4).prefix(2), radix: 16) + else { + return chromeHex // fallback: return unchanged on parse failure } - if canonicalDirectoryKey(existing, homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion) - == canonicalDirectoryKey( - replacement, - homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion - ) { - return existing - } + var r = CGFloat(rByte) / 255.0 + var g = CGFloat(gByte) / 255.0 + var b = CGFloat(bByte) / 255.0 - return replacement - } + // Perceptual luminance check (sRGB coefficients, no gamma expansion needed for light/dark) + let luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b - static func orderedPaneIds(tree: ExternalTreeNode) -> [String] { - switch tree { - case .pane(let pane): - return [pane.id] - case .split(let split): - // Bonsplit split order matches visual order for both horizontal and vertical splits. - return orderedPaneIds(tree: split.first) + orderedPaneIds(tree: split.second) + if luminance < 0.5 { + // Dark background: lighten 28% toward white + r += (1.0 - r) * 0.28 + g += (1.0 - g) * 0.28 + b += (1.0 - b) * 0.28 + } else { + // Light background: darken 20% toward black + r *= 0.80 + g *= 0.80 + b *= 0.80 } - } - - static func orderedPanelIds( - tree: ExternalTreeNode, - paneTabs: [String: [UUID]], - fallbackPanelIds: [UUID] - ) -> [UUID] { - var ordered: [UUID] = [] - var seen: Set = [] - for paneId in orderedPaneIds(tree: tree) { - for panelId in paneTabs[paneId] ?? [] { - if seen.insert(panelId).inserted { - ordered.append(panelId) - } - } - } + let rOut = min(255, max(0, Int((r * 255).rounded()))) + let gOut = min(255, max(0, Int((g * 255).rounded()))) + let bOut = min(255, max(0, Int((b * 255).rounded()))) + return String(format: "#%02X%02X%02X", rOut, gOut, bOut) + } - for panelId in fallbackPanelIds { - if seen.insert(panelId).inserted { - ordered.append(panelId) - } - } + nonisolated static func resolvedChromeColors( + from backgroundColor: NSColor + ) -> BonsplitConfiguration.Appearance.ChromeColors { + .init(backgroundHex: backgroundColor.hexString()) + } - return ordered + static func bonsplitAppearance( + from backgroundColor: NSColor, + backgroundOpacity: Double + ) -> BonsplitConfiguration.Appearance { + let chromeHex = Self.bonsplitChromeHex( + backgroundColor: backgroundColor, + backgroundOpacity: backgroundOpacity + ) + return BonsplitConfiguration.Appearance( + splitButtonTooltips: Self.currentSplitButtonTooltips(), + enableAnimations: false, + chromeColors: .init( + backgroundHex: chromeHex, + borderHex: Self.bonsplitDividerHex(fromChromeHex: chromeHex) + ) + ) } - static func orderedUniqueBranches( - orderedPanelIds: [UUID], - panelBranches: [UUID: SidebarGitBranchState], - fallbackBranch: SidebarGitBranchState? - ) -> [BranchEntry] { - var orderedNames: [String] = [] - var branchDirty: [String: Bool] = [:] + func applyGhosttyChrome(from config: GhosttyConfig, reason: String = "unspecified") { + applyGhosttyChrome( + backgroundColor: config.backgroundColor, + backgroundOpacity: config.backgroundOpacity, + reason: reason + ) + } - for panelId in orderedPanelIds { - guard let state = panelBranches[panelId] else { continue } - let name = state.branch.trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty else { continue } + func applyGhosttyChrome(backgroundColor: NSColor, backgroundOpacity: Double, reason: String = "unspecified") { + let nextHex = Self.bonsplitChromeHex( + backgroundColor: backgroundColor, + backgroundOpacity: backgroundOpacity + ) + let currentChromeColors = bonsplitController.configuration.appearance.chromeColors + let isNoOp = currentChromeColors.backgroundHex == nextHex - if branchDirty[name] == nil { - orderedNames.append(name) - branchDirty[name] = state.isDirty - } else if state.isDirty { - branchDirty[name] = true - } + if GhosttyApp.shared.backgroundLogEnabled { + let currentBackgroundHex = currentChromeColors.backgroundHex ?? "nil" + GhosttyApp.shared.logBackground( + "theme apply workspace=\(id.uuidString) reason=\(reason) currentBg=\(currentBackgroundHex) nextBg=\(nextHex) noop=\(isNoOp)" + ) } - if orderedNames.isEmpty, let fallbackBranch { - let name = fallbackBranch.branch.trimmingCharacters(in: .whitespacesAndNewlines) - if !name.isEmpty { - return [BranchEntry(name: name, isDirty: fallbackBranch.isDirty)] - } + if isNoOp { + return } - - return orderedNames.map { name in - BranchEntry(name: name, isDirty: branchDirty[name] ?? false) + bonsplitController.configuration.appearance.chromeColors.backgroundHex = nextHex + bonsplitController.configuration.appearance.chromeColors.borderHex = Self.bonsplitDividerHex(fromChromeHex: nextHex) + if GhosttyApp.shared.backgroundLogEnabled { + GhosttyApp.shared.logBackground( + "theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil")" + ) } } - static func orderedUniquePullRequests( - orderedPanelIds: [UUID], - panelPullRequests: [UUID: SidebarPullRequestState], - fallbackPullRequest: SidebarPullRequestState? - ) -> [SidebarPullRequestState] { - func statusPriority(_ status: SidebarPullRequestStatus) -> Int { - switch status { - case .merged: return 3 - case .open: return 2 - case .closed: return 1 - } - } - - func checksPriority(_ checks: SidebarPullRequestChecksStatus?) -> Int { - switch checks { - case .fail: return 3 - case .pending: return 2 - case .pass: return 1 - case nil: return 0 - } - } - - func normalizedReviewURLKey(for url: URL) -> String { - guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return url.absoluteString - } + init( + title: String = "Terminal", + workingDirectory: String? = nil, + portOrdinal: Int = 0, + configTemplate: ProgramaSurfaceConfigTemplate? = nil, + initialTerminalCommand: String? = nil, + initialTerminalEnvironment: [String: String] = [:] + ) { + self.id = UUID() + self.portOrdinal = portOrdinal + self.processTitle = title + self.title = title + self.customTitle = nil + self.customDescription = nil - // Treat URL variants that differ only by query/fragment as the same review item. - components.query = nil - components.fragment = nil - let scheme = components.scheme?.lowercased() ?? "" - let host = components.host?.lowercased() ?? "" - let port = components.port.map { ":\($0)" } ?? "" - var path = components.path - if path.hasSuffix("/"), path.count > 1 { - path.removeLast() - } - return "\(scheme)://\(host)\(port)\(path)" - } + let trimmedWorkingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let hasWorkingDirectory = !trimmedWorkingDirectory.isEmpty + self.currentDirectory = hasWorkingDirectory + ? trimmedWorkingDirectory + : FileManager.default.homeDirectoryForCurrentUser.path - func reviewKey(for state: SidebarPullRequestState) -> String { - "\(state.label.lowercased())#\(state.number)|\(normalizedReviewURLKey(for: state.url))" - } + // Configure bonsplit with keepAllAlive to preserve terminal state + // and keep split entry instantaneous. + // 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.bonsplitAppearance( + from: GhosttyApp.shared.defaultBackgroundColor, + backgroundOpacity: GhosttyApp.shared.defaultBackgroundOpacity + ) + let config = BonsplitConfiguration( + 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() - var orderedKeys: [String] = [] - var pullRequestsByKey: [String: SidebarPullRequestState] = [:] + // Remove the default "Welcome" tab that bonsplit creates + let welcomeTabIds = bonsplitController.allTabIds - for panelId in orderedPanelIds { - guard let state = panelPullRequests[panelId] else { continue } - let key = reviewKey(for: state) - if pullRequestsByKey[key] == nil { - orderedKeys.append(key) - pullRequestsByKey[key] = state - continue - } - guard let existing = pullRequestsByKey[key] else { continue } - if statusPriority(state.status) > statusPriority(existing.status) { - pullRequestsByKey[key] = state - } else if state.status == existing.status, - checksPriority(state.checks) > checksPriority(existing.checks) { - pullRequestsByKey[key] = state - } - } + // Create initial terminal panel + let terminalPanel = TerminalPanel( + workspaceId: id, + context: GHOSTTY_SURFACE_CONTEXT_TAB, + configTemplate: configTemplate, + workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil, + portOrdinal: portOrdinal, + initialCommand: initialTerminalCommand, + initialEnvironmentOverrides: initialTerminalEnvironment + ) + configureTerminalPanel(terminalPanel) + panels[terminalPanel.id] = terminalPanel + panelTitles[terminalPanel.id] = terminalPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate) - if orderedKeys.isEmpty, let fallbackPullRequest { - return [fallbackPullRequest] + // Create initial tab in bonsplit and store the mapping + var initialTabId: TabID? + if let tabId = bonsplitController.createTab( + title: title, + icon: "terminal.fill", + kind: SurfaceKind.terminal, + isDirty: false, + isPinned: false + ) { + surfaceIdToPanelId[tabId] = terminalPanel.id + initialTabId = tabId } - return orderedKeys.compactMap { pullRequestsByKey[$0] } - } - - static func orderedUniqueBranchDirectoryEntries( - orderedPanelIds: [UUID], - panelBranches: [UUID: SidebarGitBranchState], - panelDirectories: [UUID: String], - defaultDirectory: String?, - homeDirectoryForTildeExpansion: String?, - fallbackBranch: SidebarGitBranchState? - ) -> [BranchDirectoryEntry] { - struct EntryKey: Hashable { - let directory: String? - let branch: String? + // Close the default Welcome tab(s) + for welcomeTabId in welcomeTabIds { + bonsplitController.closeTab(welcomeTabId) } - struct MutableEntry { - var branch: String? - var isDirty: Bool - var directory: String? + bonsplitController.onExternalTabDrop = { [weak self] request in + self?.handleExternalTabDrop(request) ?? false } - - let normalized = normalizedDirectory - let normalizedFallbackBranch = normalized(fallbackBranch?.branch) - let shouldUseFallbackBranchPerPanel = !orderedPanelIds.contains { - normalized(panelBranches[$0]?.branch) != nil + bonsplitController.onTabCloseRequest = { [weak self] tabId, _ in + self?.markExplicitClose(surfaceId: tabId) } - let defaultBranchForPanels = shouldUseFallbackBranchPerPanel ? normalizedFallbackBranch : nil - let defaultBranchDirty = shouldUseFallbackBranchPerPanel ? (fallbackBranch?.isDirty ?? false) : false - - var order: [EntryKey] = [] - var entries: [EntryKey: MutableEntry] = [:] - - for panelId in orderedPanelIds { - let panelBranch = normalized(panelBranches[panelId]?.branch) - let branch = panelBranch ?? defaultBranchForPanels - let directory = normalized(panelDirectories[panelId]) - guard branch != nil || directory != nil else { continue } - - let panelDirty = panelBranch != nil - ? (panelBranches[panelId]?.isDirty ?? false) - : defaultBranchDirty - - let key: EntryKey - if let directoryKey = canonicalDirectoryKey( - directory, - homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion - ) { - // Keep one line per directory and allow the latest branch state to overwrite. - key = EntryKey(directory: directoryKey, branch: nil) - } else { - key = EntryKey(directory: nil, branch: branch) - } - guard key.directory != nil || key.branch != nil else { continue } + // Set ourselves as delegate + bonsplitController.delegate = self - if var existing = entries[key] { - if key.directory != nil { - if let branch { - existing.branch = branch - existing.isDirty = panelDirty - } else if existing.branch == nil { - existing.isDirty = panelDirty + // Ensure bonsplit has a focused pane and our didSelectTab handler runs for the + // initial terminal. bonsplit'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 }) { + return paneId } - existing.directory = preferredDisplayedDirectory( - existing: existing.directory, - replacement: directory, - homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion - ) - entries[key] = existing - } else if panelDirty { - existing.isDirty = true - entries[key] = existing } - } else { - order.append(key) - entries[key] = MutableEntry(branch: branch, isDirty: panelDirty, directory: directory) + return bonsplitController.allPaneIds.first + }() + if let paneToFocus { + bonsplitController.focusPane(paneToFocus) } + bonsplitController.selectTab(initialTabId) } + tmuxLayoutSnapshot = bonsplitController.layoutSnapshot() + } - if order.isEmpty { - let fallbackDirectory = normalized(defaultDirectory) - if normalizedFallbackBranch != nil || fallbackDirectory != nil { - return [ - BranchDirectoryEntry( - branch: normalizedFallbackBranch, - isDirty: fallbackBranch?.isDirty ?? false, - directory: fallbackDirectory - ) - ] - } - } + /// Initialize a workspace using a pre-warmed terminal panel from the surface pool. + /// The panel's surface is already running a shell process. + init( + claimedPanel: TerminalPanel, + title: String = "Terminal", + workingDirectory: String? = nil, + portOrdinal: Int = 0, + configTemplate: ProgramaSurfaceConfigTemplate? = nil + ) { + self.id = UUID() + self.portOrdinal = portOrdinal + self.processTitle = title + self.title = title + self.customTitle = nil + self.customDescription = nil - return order.compactMap { key in - guard let entry = entries[key] else { return nil } - return BranchDirectoryEntry( - branch: entry.branch, - isDirty: entry.isDirty, - directory: entry.directory - ) - } - } -} + let trimmedWorkingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let hasWorkingDirectory = !trimmedWorkingDirectory.isEmpty + self.currentDirectory = hasWorkingDirectory + ? trimmedWorkingDirectory + : FileManager.default.homeDirectoryForCurrentUser.path -struct ClosedBrowserPanelRestoreSnapshot { - let workspaceId: UUID - let url: URL? - let profileID: UUID? - let originalPaneId: UUID - let originalTabIndex: Int - let fallbackSplitOrientation: SplitOrientation? - let fallbackSplitInsertFirst: Bool - let fallbackAnchorPaneId: UUID? -} - -/// Workspace represents a sidebar tab. -/// Each workspace contains one BonsplitController that manages split panes and nested surfaces. -@MainActor -final class Workspace: Identifiable, ObservableObject { - let id: UUID - @Published var title: String - @Published var customTitle: String? - @Published var customDescription: String? - @Published var isPinned: Bool = false - @Published var customColor: String? // hex string, e.g. "#C0392B" - @Published var currentDirectory: String - private(set) var preferredBrowserProfileID: UUID? - - /// Ordinal for PROGRAMA_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 - - /// Mapping from bonsplit TabID to our Panel instances - @Published private(set) var panels: [UUID: any Panel] = [:] - - /// Subscriptions for panel updates (e.g., browser title changes) - private var panelSubscriptions: [UUID: AnyCancellable] = [:] - - /// When true, suppresses auto-creation in didSplitPane (programmatic splits handle their own panels) - private var isProgrammaticSplit = false - private var debugStressPreloadSelectionDepth = 0 + let appearance = Self.bonsplitAppearance( + from: GhosttyApp.shared.defaultBackgroundColor, + backgroundOpacity: GhosttyApp.shared.defaultBackgroundOpacity + ) + let config = BonsplitConfiguration( + 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() - /// Last terminal panel used as an inheritance source (typically last focused terminal). - private var lastTerminalConfigInheritancePanelId: UUID? - /// Last known terminal font points from inheritance sources. Used as fallback when - /// no live terminal surface is currently available. - private var lastTerminalConfigInheritanceFontPoints: Float? - /// Per-panel inherited zoom lineage. Descendants reuse this root value unless - /// a panel is explicitly re-zoomed by the user. - private var terminalInheritanceFontPointsByPanelId: [UUID: Float] = [:] + let welcomeTabIds = bonsplitController.allTabIds - /// Callback used by TabManager to capture recently closed browser panels for Cmd+Shift+T restore. - var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)? - weak var owningTabManager: TabManager? + // Use the pre-warmed panel, updating its workspace ID to ours + claimedPanel.updateWorkspaceId(id) + let terminalPanel = claimedPanel + configureTerminalPanel(terminalPanel) + panels[terminalPanel.id] = terminalPanel + panelTitles[terminalPanel.id] = terminalPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate) + var initialTabId: TabID? + if let tabId = bonsplitController.createTab( + title: title, + icon: "terminal.fill", + kind: SurfaceKind.terminal, + isDirty: false, + isPinned: false + ) { + surfaceIdToPanelId[tabId] = terminalPanel.id + initialTabId = tabId + } - // Closing tabs mutates split layout immediately; terminal views handle their own AppKit - // layout/size synchronization. + for welcomeTabId in welcomeTabIds { + bonsplitController.closeTab(welcomeTabId) + } - /// The currently focused pane's panel ID - var focusedPanelId: UUID? { - guard let paneId = bonsplitController.focusedPaneId, - let tab = bonsplitController.selectedTab(inPane: paneId) else { - return nil + bonsplitController.onExternalTabDrop = { [weak self] request in + self?.handleExternalTabDrop(request) ?? false + } + bonsplitController.onTabCloseRequest = { [weak self] tabId, _ in + self?.markExplicitClose(surfaceId: tabId) } - return panelIdFromSurfaceId(tab.id) - } - /// The currently focused terminal panel (if any) - var focusedTerminalPanel: TerminalPanel? { - guard let panelId = focusedPanelId, - let panel = panels[panelId] as? TerminalPanel else { - return nil + bonsplitController.delegate = self + + if let initialTabId { + let paneToFocus: PaneID? = { + for paneId in bonsplitController.allPaneIds { + if bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == initialTabId }) { + return paneId + } + } + return bonsplitController.allPaneIds.first + }() + if let paneToFocus { + bonsplitController.focusPane(paneToFocus) + } + bonsplitController.selectTab(initialTabId) } - return panel + tmuxLayoutSnapshot = bonsplitController.layoutSnapshot() } - func effectiveSelectedPanelId(inPane paneId: PaneID) -> UUID? { - bonsplitController.selectedTab(inPane: paneId).flatMap { panelIdFromSurfaceId($0.id) } + deinit { + activeRemoteSessionControllerID = nil + remoteSessionController?.stop() } - enum FocusPanelTrigger { - case standard - case terminalFirstResponder + func refreshSplitButtonTooltips() { + let tooltips = Self.currentSplitButtonTooltips() + var configuration = bonsplitController.configuration + guard configuration.appearance.splitButtonTooltips != tooltips else { return } + configuration.appearance.splitButtonTooltips = tooltips + bonsplitController.configuration = configuration } - /// 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 = [] - @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] = [:] - @Published var metadataBlocks: [String: SidebarMetadataBlock] = [:] - @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 - @Published var remoteConnectionDetail: String? - @Published var remoteDaemonStatus: WorkspaceRemoteDaemonStatus = WorkspaceRemoteDaemonStatus() - @Published var remoteDetectedPorts: [Int] = [] - @Published var remoteForwardedPorts: [Int] = [] - @Published var remotePortConflicts: [Int] = [] - @Published var remoteProxyEndpoint: BrowserProxyEndpoint? - @Published var remoteHeartbeatCount: Int = 0 - @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? - var activeRemoteSessionControllerID: UUID? - private var remoteLastErrorFingerprint: String? - private var remoteLastDaemonErrorFingerprint: String? - private var remoteLastPortConflictFingerprint: String? - private var remoteDetectedSurfaceIds: Set = [] - private var activeRemoteTerminalSurfaceIds: Set = [] - private var pendingRemoteTerminalChildExitSurfaceIds: Set = [] - - private static let remoteErrorStatusKey = "remote.error" - private static let remotePortConflictStatusKey = "remote.port_conflicts" - private static let remoteNotificationCooldown: TimeInterval = 5 * 60 - private static let sshControlMasterCleanupQueue = DispatchQueue( - label: "com.cmux.remote-ssh.control-master-cleanup", - qos: .utility - ) - private static let remoteHeartbeatDateFormatter: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter - }() - nonisolated(unsafe) static var runSSHControlMasterCommandOverrideForTesting: (([String]) -> Void)? - private var panelShellActivityStates: [UUID: PanelShellActivityState] = [:] - /// PIDs associated with agent status entries (e.g. claude_code), keyed by status key. - /// 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 func sidebarObservationSignal( - _ publisher: Published.Publisher - ) -> AnyPublisher { - publisher - .dropFirst() - .removeDuplicates() - .map { _ in () } - .eraseToAnyPublisher() - } + // MARK: - Surface ID to Panel ID Mapping - lazy var sidebarImmediateObservationPublisher: AnyPublisher = { - let publishers: [AnyPublisher] = [ - sidebarObservationSignal($title), - sidebarObservationSignal($customDescription), - sidebarObservationSignal($isPinned), - sidebarObservationSignal($customColor), - ] + /// Mapping from bonsplit TabID (surface ID) to panel UUID + var surfaceIdToPanelId: [TabID: UUID] = [:] - return Publishers.MergeMany(publishers).eraseToAnyPublisher() - }() + /// 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. + var forceCloseTabIds: Set = [] - lazy var sidebarObservationPublisher: AnyPublisher = { - let publishers: [AnyPublisher] = [ - sidebarObservationSignal($currentDirectory), - $panels - .map(SidebarPanelObservationState.init) - .dropFirst() - .removeDuplicates() - .map { _ in () } - .eraseToAnyPublisher(), - sidebarObservationSignal($panelDirectories), - sidebarObservationSignal($statusEntries), - sidebarObservationSignal($metadataBlocks), - sidebarObservationSignal($logEntries), - sidebarObservationSignal($progress), - sidebarObservationSignal($gitBranch), - sidebarObservationSignal($panelGitBranches), - sidebarObservationSignal($pullRequest), - sidebarObservationSignal($panelPullRequests), - sidebarObservationSignal($remoteConfiguration), - sidebarObservationSignal($remoteConnectionState), - sidebarObservationSignal($remoteConnectionDetail), - sidebarObservationSignal($activeRemoteTerminalSessionCount), - sidebarObservationSignal($listeningPorts), - ] + /// Tab IDs that are currently showing (or about to show) a close confirmation prompt. + /// Prevents repeated close gestures (e.g., middle-click spam) from stacking dialogs. + var pendingCloseConfirmTabIds: Set = [] - return Publishers.MergeMany(publishers).eraseToAnyPublisher() - }() + /// Tab IDs whose next close attempt should be treated as an explicit + /// workspace-close gesture from the user (the tab-strip X button, or Cmd+W when + /// the shortcut preference is set to close the workspace on the last surface), + /// rather than an internal close/move flow. + var explicitUserCloseTabIds: Set = [] - private static func isProxyOnlyRemoteError(_ detail: String) -> Bool { - let lowered = detail.lowercased() - return lowered.contains("remote proxy") - || lowered.contains("proxy_unavailable") - || lowered.contains("local daemon proxy") - || lowered.contains("proxy failure") - || lowered.contains("daemon transport") + /// Deterministic tab selection to apply after a tab closes. + /// Keyed by the closing tab ID, value is the tab ID we want to select next. + 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. + var pendingPaneClosePanelIds: [UUID: [UUID]] = [:] + var pendingClosedBrowserRestoreSnapshots: [TabID: ClosedBrowserPanelRestoreSnapshot] = [:] + var isApplyingTabSelection = false + struct PendingTabSelectionRequest { + let tabId: TabID + let pane: PaneID + let reassertAppKitFocus: Bool + let focusIntent: PanelFocusIntent? + let previousTerminalHostedView: GhosttySurfaceScrollView? } - - private var preservesSSHTerminalConnection: Bool { - activeRemoteTerminalSessionCount > 0 - && remoteConfiguration?.terminalStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + var pendingTabSelection: PendingTabSelectionRequest? + var isReconcilingFocusState = false + var focusReconcileScheduled = false +#if DEBUG + private(set) var debugFocusReconcileScheduledDuringDetachCount: Int = 0 + var debugLastDidMoveTabTimestamp: TimeInterval = 0 + var debugDidMoveTabEventCount: UInt64 = 0 +#endif + var layoutFollowUpObservers: [NSObjectProtocol] = [] + var layoutFollowUpPanelsCancellable: AnyCancellable? + var layoutFollowUpTimeoutWorkItem: DispatchWorkItem? + var layoutFollowUpReason: String? + var layoutFollowUpTerminalFocusPanelId: UUID? + var layoutFollowUpBrowserPanelId: UUID? + var layoutFollowUpBrowserExitFocusPanelId: UUID? + var layoutFollowUpNeedsGeometryPass = false + var layoutFollowUpAttemptScheduled = false + var layoutFollowUpAttemptVersion: Int = 0 + var layoutFollowUpStalledAttemptCount = 0 + var isAttemptingLayoutFollowUp = false + var isNormalizingPinnedTabOrder = false + var pendingNonFocusSplitFocusReassert: PendingNonFocusSplitFocusReassert? + var nonFocusSplitFocusReassertGeneration: UInt64 = 0 + + struct PendingNonFocusSplitFocusReassert { + let generation: UInt64 + let preferredPanelId: UUID + let splitPanelId: UUID } - private var hasProxyOnlyRemoteSidebarError: Bool { - guard let entry = statusEntries[Self.remoteErrorStatusKey]?.value else { return false } - return entry.lowercased().contains("remote proxy unavailable") - } + 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? + let cachedTitle: String? + let customTitle: String? + let manuallyUnread: Bool + let isRemoteTerminal: Bool + let remoteRelayPort: Int? + let remoteCleanupConfiguration: WorkspaceRemoteConfiguration? - private func remoteNotificationCooldownKey(target: String) -> String? { - let rawTarget = (remoteConfiguration?.destination ?? target) - .trimmingCharacters(in: .whitespacesAndNewlines) - guard !rawTarget.isEmpty else { return nil } - let normalizedHost = rawTarget - .split(separator: "@", maxSplits: 1, omittingEmptySubsequences: false) - .last - .map(String.init)? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - guard let normalizedHost, !normalizedHost.isEmpty else { return nil } - return "remote-host:\(normalizedHost)" + func withRemoteCleanupConfiguration(_ configuration: WorkspaceRemoteConfiguration?) -> Self { + Self( + panelId: panelId, + panel: panel, + title: title, + icon: icon, + iconImageData: iconImageData, + kind: kind, + isLoading: isLoading, + isPinned: isPinned, + directory: directory, + ttyName: ttyName, + cachedTitle: cachedTitle, + customTitle: customTitle, + manuallyUnread: manuallyUnread, + isRemoteTerminal: isRemoteTerminal, + remoteRelayPort: remoteRelayPort, + remoteCleanupConfiguration: configuration + ) + } } - var focusedSurfaceId: UUID? { focusedPanelId } - var surfaceDirectories: [UUID: String] { - get { panelDirectories } - set { panelDirectories = newValue } + var detachingTabIds: Set = [] + var pendingDetachedSurfaces: [TabID: DetachedSurfaceTransfer] = [:] + var activeDetachCloseTransactions: Int = 0 + var isDetachingCloseTransaction: Bool { activeDetachCloseTransactions > 0 } + var pendingRemoteSurfaceTTYName: String? + var pendingRemoteSurfaceTTYSurfaceId: UUID? + var pendingRemoteSurfacePortKickReason: WorkspaceRemoteSessionController.PortScanKickReason? + var pendingRemoteSurfacePortKickSurfaceId: UUID? + // When the last live remote terminal is detached out, the source workspace may be + // closed immediately after the move succeeds. That teardown must not shut down the + // shared SSH control master that is still serving the moved terminal. + var skipControlMasterCleanupAfterDetachedRemoteTransfer = false + var transferredRemoteCleanupConfigurationsByPanelId: [UUID: WorkspaceRemoteConfiguration] = [:] + +#if DEBUG + func debugElapsedMs(since start: TimeInterval) -> String { + let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 + return String(format: "%.2f", ms) } +#endif - private var processTitle: String + func panelIdFromSurfaceId(_ surfaceId: TabID) -> UUID? { + surfaceIdToPanelId[surfaceId] + } - private enum SurfaceKind { - static let terminal = "terminal" - static let browser = "browser" - static let markdown = "markdown" + func markExplicitClose(surfaceId: TabID) { + explicitUserCloseTabIds.insert(surfaceId) } - enum PanelShellActivityState: String { - case unknown - case promptIdle - case commandRunning + func surfaceIdFromPanelId(_ panelId: UUID) -> TabID? { + surfaceIdToPanelId.first { $0.value == panelId }?.key } - nonisolated static func resolveCloseConfirmation( - shellActivityState: PanelShellActivityState?, - fallbackNeedsConfirmClose: Bool - ) -> Bool { - switch shellActivityState ?? .unknown { - case .promptIdle: - return false - case .commandRunning: - return true - case .unknown: - return fallbackNeedsConfirmClose + func configureTerminalPanel(_ terminalPanel: TerminalPanel) { + terminalPanel.onRequestWorkspacePaneFlash = { [weak self, weak terminalPanel] reason in + guard let self, let terminalPanel else { return } + self.triggerWorkspacePaneFlash(panelId: terminalPanel.id, reason: reason) } } - // MARK: - Initialization - - private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips { - BonsplitConfiguration.SplitButtonTooltips( - newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip("New Terminal"), - newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip("New Browser"), - splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip("Split Right"), - splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip("Split Down") - ) + func triggerWorkspacePaneFlash(panelId: UUID, reason: WorkspaceAttentionFlashReason) { + tmuxWorkspaceFlashPanelId = panelId + tmuxWorkspaceFlashReason = reason + tmuxWorkspaceFlashToken &+= 1 } - private static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance { - bonsplitAppearance( - from: config.backgroundColor, - backgroundOpacity: config.backgroundOpacity - ) - } - static func bonsplitChromeHex(backgroundColor: NSColor, backgroundOpacity: Double) -> String { - let themedColor = GhosttyBackgroundTheme.color( - backgroundColor: backgroundColor, - opacity: backgroundOpacity + func installBrowserPanelSubscription(_ browserPanel: BrowserPanel) { + let subscription = Publishers.CombineLatest3( + browserPanel.$pageTitle.removeDuplicates(), + browserPanel.$isLoading.removeDuplicates(), + browserPanel.$faviconPNGData.removeDuplicates(by: { $0 == $1 }) ) - let includeAlpha = themedColor.alphaComponent < 0.999 - return themedColor.hexString(includeAlpha: includeAlpha) - } - - /// Returns a clearly-perceptible divider hex derived from the chrome background hex. - /// Dark backgrounds are lightened ~28% toward white; light backgrounds are darkened ~20% toward - /// black. These factors are meaningfully stronger than bonsplit's built-in weak fallback (0.16/0.12 - /// tone at reduced alpha), ensuring the 1pt split divider is visible in both dark and light themes. - /// The result is always an opaque #RRGGBB string (no alpha), matching hexString(includeAlpha:false). - static func bonsplitDividerHex(fromChromeHex chromeHex: String) -> String { - // Parse #RRGGBB or #RRGGBBAA produced by hexString(includeAlpha:) - let stripped = chromeHex.hasPrefix("#") ? String(chromeHex.dropFirst()) : chromeHex - guard stripped.count >= 6, - let rByte = UInt8(stripped.prefix(2), radix: 16), - let gByte = UInt8(stripped.dropFirst(2).prefix(2), radix: 16), - let bByte = UInt8(stripped.dropFirst(4).prefix(2), radix: 16) - else { - return chromeHex // fallback: return unchanged on parse failure - } + .receive(on: DispatchQueue.main) + .sink { [weak self, weak browserPanel] _, isLoading, favicon 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 } - var r = CGFloat(rByte) / 255.0 - var g = CGFloat(gByte) / 255.0 - var b = CGFloat(bByte) / 255.0 + 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 - // Perceptual luminance check (sRGB coefficients, no gamma expansion needed for light/dark) - let luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b + 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 + ) + } + panelSubscriptions[browserPanel.id] = subscription + setPreferredBrowserProfileID(browserPanel.profileID) + } - if luminance < 0.5 { - // Dark background: lighten 28% toward white - r += (1.0 - r) * 0.28 - g += (1.0 - g) * 0.28 - b += (1.0 - b) * 0.28 - } else { - // Light background: darken 20% toward black - r *= 0.80 - g *= 0.80 - b *= 0.80 + func setPreferredBrowserProfileID(_ profileID: UUID?) { + guard let profileID else { + preferredBrowserProfileID = nil + return } + guard BrowserProfileStore.shared.profileDefinition(id: profileID) != nil else { return } + preferredBrowserProfileID = profileID + } - let rOut = min(255, max(0, Int((r * 255).rounded()))) - let gOut = min(255, max(0, Int((g * 255).rounded()))) - let bOut = min(255, max(0, Int((b * 255).rounded()))) - return String(format: "#%02X%02X%02X", rOut, gOut, bOut) + func resolvedNewBrowserProfileID( + preferredProfileID: UUID? = nil, + sourcePanelId: UUID? = nil + ) -> UUID { + if let preferredProfileID, + BrowserProfileStore.shared.profileDefinition(id: preferredProfileID) != nil { + return preferredProfileID + } + if let sourcePanelId, + let sourceBrowserPanel = browserPanel(for: sourcePanelId), + BrowserProfileStore.shared.profileDefinition(id: sourceBrowserPanel.profileID) != nil { + return sourceBrowserPanel.profileID + } + if let preferredBrowserProfileID, + BrowserProfileStore.shared.profileDefinition(id: preferredBrowserProfileID) != nil { + return preferredBrowserProfileID + } + return BrowserProfileStore.shared.effectiveLastUsedProfileID } - nonisolated static func resolvedChromeColors( - from backgroundColor: NSColor - ) -> BonsplitConfiguration.Appearance.ChromeColors { - .init(backgroundHex: backgroundColor.hexString()) + func installMarkdownPanelSubscription(_ markdownPanel: MarkdownPanel) { + let subscription = markdownPanel.$displayTitle + .removeDuplicates() + .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 } + + 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 + ) + } + panelSubscriptions[markdownPanel.id] = subscription } - private static func bonsplitAppearance( - from backgroundColor: NSColor, - backgroundOpacity: Double - ) -> BonsplitConfiguration.Appearance { - let chromeHex = Self.bonsplitChromeHex( - backgroundColor: backgroundColor, - backgroundOpacity: backgroundOpacity - ) - return BonsplitConfiguration.Appearance( - splitButtonTooltips: Self.currentSplitButtonTooltips(), - enableAnimations: false, - chromeColors: .init( - backgroundHex: chromeHex, - borderHex: Self.bonsplitDividerHex(fromChromeHex: chromeHex) - ) + func browserRemoteWorkspaceStatusSnapshot() -> BrowserRemoteWorkspaceStatus? { + guard let target = remoteDisplayTarget else { return nil } + return BrowserRemoteWorkspaceStatus( + target: target, + connectionState: remoteConnectionState, + heartbeatCount: remoteHeartbeatCount, + lastHeartbeatAt: remoteLastHeartbeatAt ) } - func applyGhosttyChrome(from config: GhosttyConfig, reason: String = "unspecified") { - applyGhosttyChrome( - backgroundColor: config.backgroundColor, - backgroundOpacity: config.backgroundOpacity, - reason: reason - ) + func applyBrowserRemoteWorkspaceStatusToPanels() { + let snapshot = browserRemoteWorkspaceStatusSnapshot() + for panel in panels.values { + guard let browserPanel = panel as? BrowserPanel else { continue } + browserPanel.setRemoteWorkspaceStatus(snapshot) + } } - func applyGhosttyChrome(backgroundColor: NSColor, backgroundOpacity: Double, reason: String = "unspecified") { - let nextHex = Self.bonsplitChromeHex( - backgroundColor: backgroundColor, - backgroundOpacity: backgroundOpacity - ) - let currentChromeColors = bonsplitController.configuration.appearance.chromeColors - let isNoOp = currentChromeColors.backgroundHex == nextHex + // MARK: - Panel Access - if GhosttyApp.shared.backgroundLogEnabled { - let currentBackgroundHex = currentChromeColors.backgroundHex ?? "nil" - GhosttyApp.shared.logBackground( - "theme apply workspace=\(id.uuidString) reason=\(reason) currentBg=\(currentBackgroundHex) nextBg=\(nextHex) noop=\(isNoOp)" - ) + func panel(for surfaceId: TabID) -> (any Panel)? { + guard let panelId = panelIdFromSurfaceId(surfaceId) else { return nil } + return panels[panelId] + } + + func terminalPanel(for panelId: UUID) -> TerminalPanel? { + panels[panelId] as? TerminalPanel + } + + func browserPanel(for panelId: UUID) -> BrowserPanel? { + panels[panelId] as? BrowserPanel + } + + func markdownPanel(for panelId: UUID) -> MarkdownPanel? { + panels[panelId] as? MarkdownPanel + } + + 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 } + } - if isNoOp { - return + 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 } - bonsplitController.configuration.appearance.chromeColors.backgroundHex = nextHex - bonsplitController.configuration.appearance.chromeColors.borderHex = Self.bonsplitDividerHex(fromChromeHex: nextHex) - if GhosttyApp.shared.backgroundLogEnabled { - GhosttyApp.shared.logBackground( - "theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil")" + return fallbackTitle + } + + 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) } } - init( - title: String = "Terminal", - workingDirectory: String? = nil, - portOrdinal: Int = 0, - configTemplate: ProgramaSurfaceConfigTemplate? = nil, - initialTerminalCommand: String? = nil, - initialTerminalEnvironment: [String: String] = [:] - ) { - self.id = UUID() - self.portOrdinal = portOrdinal - self.processTitle = title - self.title = title - self.customTitle = nil - self.customDescription = nil - - let trimmedWorkingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let hasWorkingDirectory = !trimmedWorkingDirectory.isEmpty - self.currentDirectory = hasWorkingDirectory - ? trimmedWorkingDirectory - : FileManager.default.homeDirectoryForCurrentUser.path + func hasUnreadNotification(panelId: UUID) -> Bool { + AppDelegate.shared?.notificationStore?.hasVisibleNotificationIndicator(forTabId: id, surfaceId: panelId) ?? false + } - // Configure bonsplit with keepAllAlive to preserve terminal state - // and keep split entry instantaneous. - // 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.bonsplitAppearance( - from: GhosttyApp.shared.defaultBackgroundColor, - backgroundOpacity: GhosttyApp.shared.defaultBackgroundOpacity + func attentionPersistentState() -> WorkspaceAttentionPersistentState { + let notificationStore = AppDelegate.shared?.notificationStore + let unreadPanelIDs = Set( + panels.keys.filter { + notificationStore?.hasUnreadNotification(forTabId: id, surfaceId: $0) ?? false + } ) - let config = BonsplitConfiguration( - allowSplits: true, - allowCloseTabs: true, - allowCloseLastPane: false, - allowTabReordering: true, - allowCrossPaneTabMove: true, - autoCloseEmptyPanes: true, - contentViewLifecycle: .keepAllAlive, - newTabPosition: .current, - appearance: appearance + return WorkspaceAttentionPersistentState( + unreadPanelIDs: unreadPanelIDs, + focusedReadPanelID: notificationStore?.focusedReadIndicatorSurfaceId(forTabId: id), + manualUnreadPanelIDs: manualUnreadPanelIds ) - self.bonsplitController = BonsplitController(configuration: config) - bonsplitController.contextMenuShortcuts = Self.buildContextMenuShortcuts() - - // Remove the default "Welcome" tab that bonsplit creates - let welcomeTabIds = bonsplitController.allTabIds + } - // Create initial terminal panel - let terminalPanel = TerminalPanel( - workspaceId: id, - context: GHOSTTY_SURFACE_CONTEXT_TAB, - configTemplate: configTemplate, - workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil, - portOrdinal: portOrdinal, - initialCommand: initialTerminalCommand, - initialEnvironmentOverrides: initialTerminalEnvironment + func requestAttentionFlash(panelId: UUID, reason: WorkspaceAttentionFlashReason) { + let decision = WorkspaceAttentionCoordinator.decideFlash( + targetPanelID: panelId, + reason: reason, + persistentState: attentionPersistentState() ) - configureTerminalPanel(terminalPanel) - panels[terminalPanel.id] = terminalPanel - panelTitles[terminalPanel.id] = terminalPanel.displayTitle - seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate) + guard decision.isAllowed else { return } + panels[panelId]?.triggerFlash(reason: reason) + } - // Create initial tab in bonsplit and store the mapping - var initialTabId: TabID? - if let tabId = bonsplitController.createTab( - title: title, - icon: "terminal.fill", - kind: SurfaceKind.terminal, - isDirty: false, - isPinned: false - ) { - surfaceIdToPanelId[tabId] = terminalPanel.id - initialTabId = tabId + 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 } + bonsplitController.updateTab(tabId, showsNotificationBadge: shouldShowUnread) + } - // Close the default Welcome tab(s) - for welcomeTabId in welcomeTabIds { - bonsplitController.closeTab(welcomeTabId) - } + func normalizePinnedTabs(in paneId: PaneID) { + guard !isNormalizingPinnedTabOrder else { return } + isNormalizingPinnedTabOrder = true + defer { isNormalizingPinnedTabOrder = false } - bonsplitController.onExternalTabDrop = { [weak self] request in - self?.handleExternalTabDrop(request) ?? false + 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) } - bonsplitController.onTabCloseRequest = { [weak self] tabId, _ in - self?.markExplicitClose(surfaceId: tabId) + let unpinnedTabs = tabs.filter { tab in + guard let panelId = panelIdFromSurfaceId(tab.id) else { return true } + return !pinnedPanelIds.contains(panelId) } + let desiredOrder = pinnedTabs + unpinnedTabs - // Set ourselves as delegate - bonsplitController.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 - // 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 }) { - return paneId - } - } - return bonsplitController.allPaneIds.first - }() - if let paneToFocus { - bonsplitController.focusPane(paneToFocus) + 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) } - bonsplitController.selectTab(initialTabId) } - tmuxLayoutSnapshot = bonsplitController.layoutSnapshot() } - /// Initialize a workspace using a pre-warmed terminal panel from the surface pool. - /// The panel's surface is already running a shell process. - init( - claimedPanel: TerminalPanel, - title: String = "Terminal", - workingDirectory: String? = nil, - portOrdinal: Int = 0, - configTemplate: ProgramaSurfaceConfigTemplate? = nil - ) { - self.id = UUID() - self.portOrdinal = portOrdinal - self.processTitle = title - self.title = title - self.customTitle = nil - self.customDescription = nil + 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) + } - let trimmedWorkingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let hasWorkingDirectory = !trimmedWorkingDirectory.isEmpty - self.currentDirectory = hasWorkingDirectory - ? trimmedWorkingDirectory - : FileManager.default.homeDirectoryForCurrentUser.path + 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 + } - let appearance = Self.bonsplitAppearance( - from: GhosttyApp.shared.defaultBackgroundColor, - backgroundOpacity: GhosttyApp.shared.defaultBackgroundOpacity + 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 ) - let config = BonsplitConfiguration( - 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() - - let welcomeTabIds = bonsplitController.allTabIds + } - // Use the pre-warmed panel, updating its workspace ID to ours - claimedPanel.updateWorkspaceId(id) - let terminalPanel = claimedPanel - configureTerminalPanel(terminalPanel) - panels[terminalPanel.id] = terminalPanel - panelTitles[terminalPanel.id] = terminalPanel.displayTitle - seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate) + func isPanelPinned(_ panelId: UUID) -> Bool { + pinnedPanelIds.contains(panelId) + } - var initialTabId: TabID? - if let tabId = bonsplitController.createTab( - title: title, - icon: "terminal.fill", - kind: SurfaceKind.terminal, - isDirty: false, - isPinned: false - ) { - surfaceIdToPanelId[tabId] = terminalPanel.id - initialTabId = tabId - } + func panelKind(panelId: UUID) -> String? { + guard let panel = panels[panelId] else { return nil } + return surfaceKind(for: panel) + } - for welcomeTabId in welcomeTabIds { - bonsplitController.closeTab(welcomeTabId) + func requestBackgroundTerminalSurfaceStartIfNeeded() { + for terminalPanel in panels.values.compactMap({ $0 as? TerminalPanel }) { + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() } + } - bonsplitController.onExternalTabDrop = { [weak self] request in - self?.handleExternalTabDrop(request) ?? false - } - bonsplitController.onTabCloseRequest = { [weak self] tabId, _ in - self?.markExplicitClose(surfaceId: tabId) + @discardableResult + func preloadTerminalPanelForDebugStress( + tabId: TabID, + inPane paneId: PaneID + ) -> TerminalPanel? { + guard let panelId = panelIdFromSurfaceId(tabId), + let terminalPanel = panels[panelId] as? TerminalPanel else { + return nil } - bonsplitController.delegate = self + debugStressPreloadSelectionDepth += 1 + defer { debugStressPreloadSelectionDepth -= 1 } + let isVisibleSelection = + bonsplitController.focusedPaneId == paneId && + bonsplitController.selectedTab(inPane: paneId)?.id == tabId && + terminalPanel.hostedView.window != nil && + terminalPanel.hostedView.superview != nil - if let initialTabId { - let paneToFocus: PaneID? = { - for paneId in bonsplitController.allPaneIds { - if bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == initialTabId }) { - return paneId - } - } - return bonsplitController.allPaneIds.first - }() - if let paneToFocus { - bonsplitController.focusPane(paneToFocus) - } - bonsplitController.selectTab(initialTabId) + if isVisibleSelection { + terminalPanel.requestViewReattach() + scheduleTerminalGeometryReconcile() } - tmuxLayoutSnapshot = bonsplitController.layoutSnapshot() + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + return terminalPanel } - deinit { - activeRemoteSessionControllerID = nil - remoteSessionController?.stop() + func scheduleDebugStressTerminalGeometryReconcile() { + scheduleTerminalGeometryReconcile() } - func refreshSplitButtonTooltips() { - let tooltips = Self.currentSplitButtonTooltips() - var configuration = bonsplitController.configuration - guard configuration.appearance.splitButtonTooltips != tooltips else { return } - configuration.appearance.splitButtonTooltips = tooltips - bonsplitController.configuration = configuration + func hasLoadedTerminalSurface() -> Bool { + let terminalPanels = panels.values.compactMap { $0 as? TerminalPanel } + guard !terminalPanels.isEmpty else { return true } + return terminalPanels.contains { $0.surface.surface != nil } } - // MARK: - Surface ID to Panel ID Mapping - - /// Mapping from bonsplit TabID (surface ID) to panel UUID - private var surfaceIdToPanelId: [TabID: UUID] = [:] + func panelTitle(panelId: UUID) -> String? { + guard let panel = panels[panelId] else { return nil } + let fallback = panelTitles[panelId] ?? panel.displayTitle + return resolvedPanelTitle(panelId: panelId, fallback: fallback) + } - /// 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. - private var forceCloseTabIds: Set = [] + func setPanelPinned(panelId: UUID, pinned: Bool) { + guard panels[panelId] != nil else { return } + let wasPinned = pinnedPanelIds.contains(panelId) + guard wasPinned != pinned else { return } + if pinned { + pinnedPanelIds.insert(panelId) + } else { + pinnedPanelIds.remove(panelId) + } - /// Tab IDs that are currently showing (or about to show) a close confirmation prompt. - /// Prevents repeated close gestures (e.g., middle-click spam) from stacking dialogs. - private var pendingCloseConfirmTabIds: Set = [] + guard let tabId = surfaceIdFromPanelId(panelId), + let paneId = paneId(forPanelId: panelId) else { return } + bonsplitController.updateTab(tabId, isPinned: pinned) + normalizePinnedTabs(in: paneId) + } - /// Tab IDs whose next close attempt should be treated as an explicit - /// workspace-close gesture from the user (the tab-strip X button, or Cmd+W when - /// the shortcut preference is set to close the workspace on the last surface), - /// rather than an internal close/move flow. - private var explicitUserCloseTabIds: Set = [] + func markPanelUnread(_ panelId: UUID) { + guard panels[panelId] != nil else { return } + guard manualUnreadPanelIds.insert(panelId).inserted else { return } + manualUnreadMarkedAt[panelId] = Date() + syncUnreadBadgeStateForPanel(panelId) + } - /// Deterministic tab selection to apply after a tab closes. - /// Keyed by the closing tab ID, value is the tab ID we want to select next. - private var postCloseSelectTabId: [TabID: TabID] = [:] - /// Panel IDs that were in a pane when a pane-close operation was approved. - /// Bonsplit pane-close does not emit per-tab didClose callbacks. - private var pendingPaneClosePanelIds: [UUID: [UUID]] = [:] - private var pendingClosedBrowserRestoreSnapshots: [TabID: ClosedBrowserPanelRestoreSnapshot] = [:] - private var isApplyingTabSelection = false - private struct PendingTabSelectionRequest { - let tabId: TabID - let pane: PaneID - let reassertAppKitFocus: Bool - let focusIntent: PanelFocusIntent? - let previousTerminalHostedView: GhosttySurfaceScrollView? + func markPanelRead(_ panelId: UUID) { + guard panels[panelId] != nil else { return } + AppDelegate.shared?.notificationStore?.markRead(forTabId: id, surfaceId: panelId) + clearManualUnread(panelId: panelId) } - private var pendingTabSelection: PendingTabSelectionRequest? - private var isReconcilingFocusState = false - private var focusReconcileScheduled = false -#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 + + func clearManualUnread(panelId: UUID) { + let didRemoveUnread = manualUnreadPanelIds.remove(panelId) != nil + manualUnreadMarkedAt.removeValue(forKey: panelId) + guard didRemoveUnread else { return } + syncUnreadBadgeStateForPanel(panelId) } - 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? - let cachedTitle: String? - let customTitle: String? - let manuallyUnread: Bool - let isRemoteTerminal: Bool - let remoteRelayPort: Int? - let remoteCleanupConfiguration: WorkspaceRemoteConfiguration? + static func shouldClearManualUnread( + previousFocusedPanelId: UUID?, + nextFocusedPanelId: UUID, + isManuallyUnread: Bool, + markedAt: Date?, + now: Date = Date(), + sameTabGraceInterval: TimeInterval = manualUnreadFocusGraceInterval + ) -> Bool { + guard isManuallyUnread else { return false } - func withRemoteCleanupConfiguration(_ configuration: WorkspaceRemoteConfiguration?) -> Self { - Self( - panelId: panelId, - panel: panel, - title: title, - icon: icon, - iconImageData: iconImageData, - kind: kind, - isLoading: isLoading, - isPinned: isPinned, - directory: directory, - ttyName: ttyName, - cachedTitle: cachedTitle, - customTitle: customTitle, - manuallyUnread: manuallyUnread, - isRemoteTerminal: isRemoteTerminal, - remoteRelayPort: remoteRelayPort, - remoteCleanupConfiguration: configuration - ) + if let previousFocusedPanelId, previousFocusedPanelId != nextFocusedPanelId { + return true } - } - private var detachingTabIds: Set = [] - private var pendingDetachedSurfaces: [TabID: DetachedSurfaceTransfer] = [:] - private var activeDetachCloseTransactions: Int = 0 - private var isDetachingCloseTransaction: Bool { activeDetachCloseTransactions > 0 } - private var pendingRemoteSurfaceTTYName: String? - private var pendingRemoteSurfaceTTYSurfaceId: UUID? - private var pendingRemoteSurfacePortKickReason: WorkspaceRemoteSessionController.PortScanKickReason? - private var pendingRemoteSurfacePortKickSurfaceId: UUID? - // When the last live remote terminal is detached out, the source workspace may be - // closed immediately after the move succeeds. That teardown must not shut down the - // shared SSH control master that is still serving the moved terminal. - private var skipControlMasterCleanupAfterDetachedRemoteTransfer = false - private var transferredRemoteCleanupConfigurationsByPanelId: [UUID: WorkspaceRemoteConfiguration] = [:] + guard let markedAt else { return true } + return now.timeIntervalSince(markedAt) >= sameTabGraceInterval + } -#if DEBUG - private func debugElapsedMs(since start: TimeInterval) -> String { - let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 - return String(format: "%.2f", ms) + static func shouldShowUnreadIndicator(hasUnreadNotification: Bool, isManuallyUnread: Bool) -> Bool { + hasUnreadNotification || isManuallyUnread } -#endif - func panelIdFromSurfaceId(_ surfaceId: TabID) -> UUID? { - surfaceIdToPanelId[surfaceId] + // MARK: - Title Management + + var hasCustomTitle: Bool { + let trimmed = customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return !trimmed.isEmpty } - func markExplicitClose(surfaceId: TabID) { - explicitUserCloseTabIds.insert(surfaceId) + var hasCustomDescription: Bool { + Self.normalizedCustomDescription(customDescription) != nil } - func surfaceIdFromPanelId(_ panelId: UUID) -> TabID? { - surfaceIdToPanelId.first { $0.value == panelId }?.key + func applyProcessTitle(_ title: String) { + processTitle = title + guard customTitle == nil else { return } + self.title = title } - private func configureTerminalPanel(_ terminalPanel: TerminalPanel) { - terminalPanel.onRequestWorkspacePaneFlash = { [weak self, weak terminalPanel] reason in - guard let self, let terminalPanel else { return } - self.triggerWorkspacePaneFlash(panelId: terminalPanel.id, reason: reason) + func setCustomColor(_ hex: String?) { + if let hex { + customColor = WorkspaceTabColorSettings.normalizedHex(hex) + } else { + customColor = nil } } - private func triggerWorkspacePaneFlash(panelId: UUID, reason: WorkspaceAttentionFlashReason) { - tmuxWorkspaceFlashPanelId = panelId - tmuxWorkspaceFlashReason = reason - tmuxWorkspaceFlashToken &+= 1 + static func normalizedCustomDescription(_ description: String?) -> String? { + let normalizedLineEndings = description? + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + let trimmed = normalizedLineEndings?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return nil } + return normalizedLineEndings } - - private func installBrowserPanelSubscription(_ browserPanel: BrowserPanel) { - let subscription = Publishers.CombineLatest3( - browserPanel.$pageTitle.removeDuplicates(), - browserPanel.$isLoading.removeDuplicates(), - browserPanel.$faviconPNGData.removeDuplicates(by: { $0 == $1 }) - ) - .receive(on: DispatchQueue.main) - .sink { [weak self, weak browserPanel] _, isLoading, favicon 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 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 - ) + func setCustomTitle(_ title: String?) { + let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { + customTitle = nil + self.title = processTitle + } else { + customTitle = trimmed + self.title = trimmed } - panelSubscriptions[browserPanel.id] = subscription - setPreferredBrowserProfileID(browserPanel.profileID) } - func setPreferredBrowserProfileID(_ profileID: UUID?) { - guard let profileID else { - preferredBrowserProfileID = nil - return - } - guard BrowserProfileStore.shared.profileDefinition(id: profileID) != nil else { return } - preferredBrowserProfileID = profileID + func setCustomDescription(_ description: String?) { + let normalizedDescription = Self.normalizedCustomDescription(description) +#if DEBUG + let inputNewlines = description?.reduce(into: 0) { count, character in + if character == "\n" { count += 1 } + } ?? 0 + let normalizedNewlines = normalizedDescription?.reduce(into: 0) { count, character in + if character == "\n" { count += 1 } + } ?? 0 + dlog( + "workspace.customDescription.update workspace=\(id.uuidString.prefix(8)) " + + "inputLen=\((description as NSString?)?.length ?? 0) " + + "inputNewlines=\(inputNewlines) " + + "normalizedLen=\((normalizedDescription as NSString?)?.length ?? 0) " + + "normalizedNewlines=\(normalizedNewlines) " + + "input=\"\(debugWorkspaceDescriptionPreview(description))\" " + + "normalized=\"\(debugWorkspaceDescriptionPreview(normalizedDescription))\"" + ) +#endif + customDescription = normalizedDescription } - private func resolvedNewBrowserProfileID( - preferredProfileID: UUID? = nil, - sourcePanelId: UUID? = nil - ) -> UUID { - if let preferredProfileID, - BrowserProfileStore.shared.profileDefinition(id: preferredProfileID) != nil { - return preferredProfileID - } - if let sourcePanelId, - let sourceBrowserPanel = browserPanel(for: sourcePanelId), - BrowserProfileStore.shared.profileDefinition(id: sourceBrowserPanel.profileID) != nil { - return sourceBrowserPanel.profileID + // MARK: - Directory Updates + + func updatePanelDirectory(panelId: UUID, directory: String) { + let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + if panelDirectories[panelId] != trimmed { + panelDirectories[panelId] = trimmed } - if let preferredBrowserProfileID, - BrowserProfileStore.shared.profileDefinition(id: preferredBrowserProfileID) != nil { - return preferredBrowserProfileID + // Update current directory if this is the focused panel + if panelId == focusedPanelId, currentDirectory != trimmed { + currentDirectory = trimmed } - return BrowserProfileStore.shared.effectiveLastUsedProfileID } - private func installMarkdownPanelSubscription(_ markdownPanel: MarkdownPanel) { - let subscription = markdownPanel.$displayTitle - .removeDuplicates() - .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 } - - 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 - ) - } - panelSubscriptions[markdownPanel.id] = subscription + /// Updates the shell-activity state for a panel. + /// + /// - Returns: `true` if the update was applied (panel exists and state changed), + /// `false` if it was a no-op (panel absent or state unchanged). + /// Callers that deduplicate reports MUST only record the state in their dedup + /// dict when this returns `true`; recording on `false` would suppress the next + /// identical report even though it was never actually applied. + @discardableResult + func updatePanelShellActivityState(panelId: UUID, state: PanelShellActivityState) -> Bool { + guard panels[panelId] != nil else { return false } + let previousState = panelShellActivityStates[panelId] ?? .unknown + guard previousState != state else { return false } + panelShellActivityStates[panelId] = state +#if DEBUG + dlog( + "surface.shellState workspace=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) from=\(previousState.rawValue) to=\(state.rawValue)" + ) +#endif + return true } - private func browserRemoteWorkspaceStatusSnapshot() -> BrowserRemoteWorkspaceStatus? { - guard let target = remoteDisplayTarget else { return nil } - return BrowserRemoteWorkspaceStatus( - target: target, - connectionState: remoteConnectionState, - heartbeatCount: remoteHeartbeatCount, - lastHeartbeatAt: remoteLastHeartbeatAt + func panelNeedsConfirmClose(panelId: UUID, fallbackNeedsConfirmClose: Bool) -> Bool { + Self.resolveCloseConfirmation( + shellActivityState: panelShellActivityStates[panelId], + fallbackNeedsConfirmClose: fallbackNeedsConfirmClose ) } - private func applyBrowserRemoteWorkspaceStatusToPanels() { - let snapshot = browserRemoteWorkspaceStatusSnapshot() - for panel in panels.values { - guard let browserPanel = panel as? BrowserPanel else { continue } - browserPanel.setRemoteWorkspaceStatus(snapshot) + func updatePanelGitBranch(panelId: UUID, branch: String, isDirty: Bool) { + let state = SidebarGitBranchState(branch: branch, isDirty: isDirty) + let existing = panelGitBranches[panelId] + let branchChanged = existing?.branch != nil && existing?.branch != branch + if existing?.branch != branch || existing?.isDirty != isDirty { + panelGitBranches[panelId] = state } - } - - // MARK: - Panel Access - - func panel(for surfaceId: TabID) -> (any Panel)? { - guard let panelId = panelIdFromSurfaceId(surfaceId) else { return nil } - return panels[panelId] - } - - func terminalPanel(for panelId: UUID) -> TerminalPanel? { - panels[panelId] as? TerminalPanel - } - - func browserPanel(for panelId: UUID) -> BrowserPanel? { - panels[panelId] 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 + if branchChanged { + if panelPullRequests[panelId] != nil { + panelPullRequests.removeValue(forKey: panelId) + } + if panelId == focusedPanelId, pullRequest != nil { + pullRequest = nil + } } - } - - 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 + if panelId == focusedPanelId, gitBranch != state { + gitBranch = state } - return fallbackTitle } - 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) + func clearPanelGitBranch(panelId: UUID) { + if panelGitBranches[panelId] != nil { + panelGitBranches.removeValue(forKey: panelId) } - } - - private func hasUnreadNotification(panelId: UUID) -> Bool { - AppDelegate.shared?.notificationStore?.hasVisibleNotificationIndicator(forTabId: id, surfaceId: panelId) ?? false - } - - private func attentionPersistentState() -> WorkspaceAttentionPersistentState { - let notificationStore = AppDelegate.shared?.notificationStore - let unreadPanelIDs = Set( - panels.keys.filter { - notificationStore?.hasUnreadNotification(forTabId: id, surfaceId: $0) ?? false + if panelPullRequests[panelId] != nil { + panelPullRequests.removeValue(forKey: panelId) + } + if panelId == focusedPanelId { + if gitBranch != nil { + gitBranch = nil + } + if pullRequest != nil { + pullRequest = nil } - ) - return WorkspaceAttentionPersistentState( - unreadPanelIDs: unreadPanelIDs, - focusedReadPanelID: notificationStore?.focusedReadIndicatorSurfaceId(forTabId: id), - manualUnreadPanelIDs: manualUnreadPanelIds - ) - } - - 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 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 } - bonsplitController.updateTab(tabId, showsNotificationBadge: shouldShowUnread) } - private func normalizePinnedTabs(in paneId: PaneID) { - guard !isNormalizingPinnedTabOrder else { return } - isNormalizingPinnedTabOrder = true - defer { isNormalizingPinnedTabOrder = false } - - 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) + func updatePanelPullRequest( + panelId: UUID, + number: Int, + label: String, + url: URL, + status: SidebarPullRequestStatus, + branch: String? = nil, + checks: SidebarPullRequestChecksStatus? = nil + ) { + let existing = panelPullRequests[panelId] + let normalizedBranch = normalizedSidebarBranchName(branch) + let currentPanelBranch = normalizedSidebarBranchName(panelGitBranches[panelId]?.branch) + let resolvedBranch: String? = { + if let normalizedBranch { + return normalizedBranch + } + if let currentPanelBranch { + return currentPanelBranch + } + guard let existing, + existing.number == number, + existing.label == label, + existing.url == url, + existing.status == status else { + return nil + } + return existing.branch + }() + let resolvedChecks: SidebarPullRequestChecksStatus? = { + if let checks { + return checks + } + guard let existing, + existing.number == number, + existing.label == label, + existing.url == url, + existing.status == status else { + return nil + } + return existing.checks + }() + let state = SidebarPullRequestState( + number: number, + label: label, + url: url, + status: status, + branch: resolvedBranch, + checks: resolvedChecks + ) + if existing != state { + panelPullRequests[panelId] = state } - let unpinnedTabs = tabs.filter { tab in - guard let panelId = panelIdFromSurfaceId(tab.id) else { return true } - return !pinnedPanelIds.contains(panelId) + if panelId == focusedPanelId, pullRequest != state { + pullRequest = state } - let desiredOrder = pinnedTabs + unpinnedTabs + } - 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 clearPanelPullRequest(panelId: UUID) { + if panelPullRequests[panelId] != nil { + panelPullRequests.removeValue(forKey: panelId) + } + if panelId == focusedPanelId, pullRequest != nil { + pullRequest = nil } } - 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 resetSidebarContext(reason: String = "unspecified") { + statusEntries.removeAll() + agentPIDs.removeAll() + agentListeningPorts.removeAll() + logEntries.removeAll() + progress = nil + gitBranch = nil + panelGitBranches.removeAll() + pullRequest = nil + panelPullRequests.removeAll() + surfaceListeningPorts.removeAll() + listeningPorts.removeAll() + metadataBlocks.removeAll() + resetBrowserPanelsForContextChange(reason: reason) } - 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 - } + func resetBrowserPanelsForContextChange(reason: String) { + let browserPanels = panels.values.compactMap { $0 as? BrowserPanel } + guard !browserPanels.isEmpty else { return } - 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 +#if DEBUG + dlog( + "workspace.contextReset.browserPanels workspace=\(id.uuidString.prefix(5)) " + + "reason=\(reason) count=\(browserPanels.count)" ) - } +#endif - func isPanelPinned(_ panelId: UUID) -> Bool { - pinnedPanelIds.contains(panelId) - } + for browserPanel in browserPanels { + browserPanel.resetForWorkspaceContextChange(reason: reason) + let nextTitle = browserPanel.displayTitle + _ = updatePanelTitle(panelId: browserPanel.id, title: nextTitle) - func panelKind(panelId: UUID) -> String? { - guard let panel = panels[panelId] else { return nil } - return surfaceKind(for: panel) - } + guard let tabId = surfaceIdFromPanelId(browserPanel.id), + let existing = bonsplitController.tab(tabId) else { + continue + } - func requestBackgroundTerminalSurfaceStartIfNeeded() { - for terminalPanel in panels.values.compactMap({ $0 as? TerminalPanel }) { - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + 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 + ) } } @discardableResult - func preloadTerminalPanelForDebugStress( - tabId: TabID, - inPane paneId: PaneID - ) -> TerminalPanel? { - guard let panelId = panelIdFromSurfaceId(tabId), - let terminalPanel = panels[panelId] as? TerminalPanel else { - return nil - } + func updatePanelTitle(panelId: UUID, title: String) -> Bool { + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + var didMutate = false - debugStressPreloadSelectionDepth += 1 - defer { debugStressPreloadSelectionDepth -= 1 } - let isVisibleSelection = - bonsplitController.focusedPaneId == paneId && - bonsplitController.selectedTab(inPane: paneId)?.id == tabId && - terminalPanel.hostedView.window != nil && - terminalPanel.hostedView.superview != nil + if panelTitles[panelId] != trimmed { + panelTitles[panelId] = trimmed + didMutate = true + } - if isVisibleSelection { - terminalPanel.requestViewReattach() - scheduleTerminalGeometryReconcile() + // 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 + ) } - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() - return terminalPanel - } - func scheduleDebugStressTerminalGeometryReconcile() { - scheduleTerminalGeometryReconcile() - } + // If this is the only panel and no custom title, update workspace title + if panels.count == 1, customTitle == nil { + if self.title != trimmed { + self.title = trimmed + didMutate = true + } + if processTitle != trimmed { + processTitle = trimmed + } + } - func hasLoadedTerminalSurface() -> Bool { - let terminalPanels = panels.values.compactMap { $0 as? TerminalPanel } - guard !terminalPanels.isEmpty else { return true } - return terminalPanels.contains { $0.surface.surface != nil } + return didMutate } - func panelTitle(panelId: UUID) -> String? { - guard let panel = panels[panelId] else { return nil } - let fallback = panelTitles[panelId] ?? panel.displayTitle - return resolvedPanelTitle(panelId: panelId, fallback: fallback) + func pruneSurfaceMetadata(validSurfaceIds: Set) { + 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) } + remoteDetectedSurfaceIds = remoteDetectedSurfaceIds.filter { validSurfaceIds.contains($0) } + panelShellActivityStates = panelShellActivityStates.filter { validSurfaceIds.contains($0.key) } + panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) } + syncRemotePortScanTTYs() + recomputeListeningPorts() } - func setPanelPinned(panelId: UUID, pinned: Bool) { - guard panels[panelId] != nil else { return } - let wasPinned = pinnedPanelIds.contains(panelId) - guard wasPinned != pinned else { return } - if pinned { - pinnedPanelIds.insert(panelId) - } else { - pinnedPanelIds.remove(panelId) + func recomputeListeningPorts() { + let unique = Set(surfaceListeningPorts.values.flatMap { $0 }) + .union(agentListeningPorts) + .union(remoteDetectedPorts) + .union(remoteForwardedPorts) + let next = unique.sorted() + if listeningPorts != next { + listeningPorts = next } - - guard let tabId = surfaceIdFromPanelId(panelId), - let paneId = paneId(forPanelId: panelId) else { return } - bonsplitController.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) - } - - func markPanelRead(_ panelId: UUID) { - guard panels[panelId] != nil else { return } - AppDelegate.shared?.notificationStore?.markRead(forTabId: id, surfaceId: panelId) - clearManualUnread(panelId: panelId) - } + func sidebarOrderedPanelIds() -> [UUID] { + let paneTabs: [String: [UUID]] = Dictionary( + uniqueKeysWithValues: bonsplitController.allPaneIds.map { paneId in + let panelIds = bonsplitController + .tabs(inPane: paneId) + .compactMap { panelIdFromSurfaceId($0.id) } + return (paneId.id.uuidString, panelIds) + } + ) - func clearManualUnread(panelId: UUID) { - let didRemoveUnread = manualUnreadPanelIds.remove(panelId) != nil - manualUnreadMarkedAt.removeValue(forKey: panelId) - guard didRemoveUnread else { return } - syncUnreadBadgeStateForPanel(panelId) + let fallbackPanelIds = panels.keys.sorted { $0.uuidString < $1.uuidString } + let tree = bonsplitController.treeSnapshot() + return SidebarBranchOrdering.orderedPanelIds( + tree: tree, + paneTabs: paneTabs, + fallbackPanelIds: fallbackPanelIds + ) } - static func shouldClearManualUnread( - previousFocusedPanelId: UUID?, - nextFocusedPanelId: UUID, - isManuallyUnread: Bool, - markedAt: Date?, - now: Date = Date(), - sameTabGraceInterval: TimeInterval = manualUnreadFocusGraceInterval - ) -> Bool { - guard isManuallyUnread else { return false } + func normalizedSidebarDirectory(_ directory: String?) -> String? { + guard let directory else { return nil } + let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } - if let previousFocusedPanelId, previousFocusedPanelId != nextFocusedPanelId { - return true + func sidebarHomeDirectoryForCanonicalization( + resolvedPanelDirectories: [UUID: String] + ) -> String? { + if isRemoteWorkspace { + return SidebarBranchOrdering.inferredRemoteHomeDirectory( + from: Array(resolvedPanelDirectories.values), + fallbackDirectory: normalizedSidebarDirectory(currentDirectory) + ) } + return FileManager.default.homeDirectoryForCurrentUser.path + } - guard let markedAt else { return true } - return now.timeIntervalSince(markedAt) >= sameTabGraceInterval + func sidebarResolvedDirectory(for panelId: UUID) -> String? { + if let directory = normalizedSidebarDirectory(panelDirectories[panelId]) { + return directory + } + if let requestedDirectory = normalizedSidebarDirectory( + terminalPanel(for: panelId)?.requestedWorkingDirectory + ) { + return requestedDirectory + } + guard panelId == focusedPanelId else { return nil } + return normalizedSidebarDirectory(currentDirectory) } - static func shouldShowUnreadIndicator(hasUnreadNotification: Bool, isManuallyUnread: Bool) -> Bool { - hasUnreadNotification || isManuallyUnread + func sidebarResolvedPanelDirectories(orderedPanelIds: [UUID]) -> [UUID: String] { + var resolved: [UUID: String] = [:] + for panelId in orderedPanelIds { + if let directory = sidebarResolvedDirectory(for: panelId) { + resolved[panelId] = directory + } + } + return resolved } - // MARK: - Title Management + func sidebarDirectoriesInDisplayOrder(orderedPanelIds: [UUID]) -> [String] { + let resolvedDirectories = sidebarResolvedPanelDirectories(orderedPanelIds: orderedPanelIds) + let homeDirectoryForCanonicalization = sidebarHomeDirectoryForCanonicalization( + resolvedPanelDirectories: resolvedDirectories + ) + var ordered: [String] = [] + var seen: Set = [] - var hasCustomTitle: Bool { - let trimmed = customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return !trimmed.isEmpty + for panelId in orderedPanelIds { + guard let directory = resolvedDirectories[panelId], + let key = SidebarBranchOrdering.canonicalDirectoryKey( + directory, + homeDirectoryForTildeExpansion: homeDirectoryForCanonicalization + ) else { continue } + if seen.insert(key).inserted { + ordered.append(directory) + } + } + + if ordered.isEmpty, let fallbackDirectory = normalizedSidebarDirectory(currentDirectory) { + return [fallbackDirectory] + } + + return ordered } - var hasCustomDescription: Bool { - Self.normalizedCustomDescription(customDescription) != nil + func sidebarDirectoriesInDisplayOrder() -> [String] { + sidebarDirectoriesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) } - func applyProcessTitle(_ title: String) { - processTitle = title - guard customTitle == nil else { return } - self.title = title + func sidebarGitBranchesInDisplayOrder(orderedPanelIds: [UUID]) -> [SidebarGitBranchState] { + SidebarBranchOrdering + .orderedUniqueBranches( + orderedPanelIds: orderedPanelIds, + panelBranches: panelGitBranches, + fallbackBranch: gitBranch + ) + .map { SidebarGitBranchState(branch: $0.name, isDirty: $0.isDirty) } } - func setCustomColor(_ hex: String?) { - if let hex { - customColor = WorkspaceTabColorSettings.normalizedHex(hex) - } else { - customColor = nil - } + func sidebarGitBranchesInDisplayOrder() -> [SidebarGitBranchState] { + sidebarGitBranchesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) } - private static func normalizedCustomDescription(_ description: String?) -> String? { - let normalizedLineEndings = description? - .replacingOccurrences(of: "\r\n", with: "\n") - .replacingOccurrences(of: "\r", with: "\n") - let trimmed = normalizedLineEndings?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !trimmed.isEmpty else { return nil } - return normalizedLineEndings + func sidebarBranchDirectoryEntriesInDisplayOrder( + orderedPanelIds: [UUID] + ) -> [SidebarBranchOrdering.BranchDirectoryEntry] { + let resolvedDirectories = sidebarResolvedPanelDirectories(orderedPanelIds: orderedPanelIds) + return SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( + orderedPanelIds: orderedPanelIds, + panelBranches: panelGitBranches, + panelDirectories: resolvedDirectories, + defaultDirectory: normalizedSidebarDirectory(currentDirectory), + homeDirectoryForTildeExpansion: sidebarHomeDirectoryForCanonicalization( + resolvedPanelDirectories: resolvedDirectories + ), + fallbackBranch: gitBranch + ) } - func setCustomTitle(_ title: String?) { - let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if trimmed.isEmpty { - customTitle = nil - self.title = processTitle - } else { - customTitle = trimmed - self.title = trimmed - } + func sidebarBranchDirectoryEntriesInDisplayOrder() -> [SidebarBranchOrdering.BranchDirectoryEntry] { + sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) } - func setCustomDescription(_ description: String?) { - let normalizedDescription = Self.normalizedCustomDescription(description) -#if DEBUG - let inputNewlines = description?.reduce(into: 0) { count, character in - if character == "\n" { count += 1 } - } ?? 0 - let normalizedNewlines = normalizedDescription?.reduce(into: 0) { count, character in - if character == "\n" { count += 1 } - } ?? 0 - dlog( - "workspace.customDescription.update workspace=\(id.uuidString.prefix(8)) " + - "inputLen=\((description as NSString?)?.length ?? 0) " + - "inputNewlines=\(inputNewlines) " + - "normalizedLen=\((normalizedDescription as NSString?)?.length ?? 0) " + - "normalizedNewlines=\(normalizedNewlines) " + - "input=\"\(debugWorkspaceDescriptionPreview(description))\" " + - "normalized=\"\(debugWorkspaceDescriptionPreview(normalizedDescription))\"" + func sidebarPullRequestsInDisplayOrder(orderedPanelIds: [UUID]) -> [SidebarPullRequestState] { + let validPanelPullRequests = panelPullRequests.filter { panelId, state in + guard let pullRequestBranch = normalizedSidebarBranchName(state.branch) else { + return true + } + return normalizedSidebarBranchName(panelGitBranches[panelId]?.branch) == pullRequestBranch + } + return SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: orderedPanelIds, + panelPullRequests: validPanelPullRequests, + fallbackPullRequest: nil ) -#endif - customDescription = normalizedDescription } - // MARK: - Directory Updates + func sidebarPullRequestsInDisplayOrder() -> [SidebarPullRequestState] { + sidebarPullRequestsInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) + } - func updatePanelDirectory(panelId: UUID, directory: String) { - let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - if panelDirectories[panelId] != trimmed { - panelDirectories[panelId] = trimmed - } - // Update current directory if this is the focused panel - if panelId == focusedPanelId, currentDirectory != trimmed { - currentDirectory = trimmed + func sidebarStatusEntriesInDisplayOrder() -> [SidebarStatusEntry] { + statusEntries.values.sorted { lhs, rhs in + if lhs.priority != rhs.priority { return lhs.priority > rhs.priority } + if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } + return lhs.key < rhs.key } } - /// Updates the shell-activity state for a panel. - /// - /// - Returns: `true` if the update was applied (panel exists and state changed), - /// `false` if it was a no-op (panel absent or state unchanged). - /// Callers that deduplicate reports MUST only record the state in their dedup - /// dict when this returns `true`; recording on `false` would suppress the next - /// identical report even though it was never actually applied. - @discardableResult - func updatePanelShellActivityState(panelId: UUID, state: PanelShellActivityState) -> Bool { - guard panels[panelId] != nil else { return false } - let previousState = panelShellActivityStates[panelId] ?? .unknown - guard previousState != state else { return false } - panelShellActivityStates[panelId] = state -#if DEBUG - dlog( - "surface.shellState workspace=\(id.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) from=\(previousState.rawValue) to=\(state.rawValue)" - ) -#endif - return true + func sidebarMetadataBlocksInDisplayOrder() -> [SidebarMetadataBlock] { + metadataBlocks.values.sorted { lhs, rhs in + if lhs.priority != rhs.priority { return lhs.priority > rhs.priority } + if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } + return lhs.key < rhs.key + } } - func panelNeedsConfirmClose(panelId: UUID, fallbackNeedsConfirmClose: Bool) -> Bool { - Self.resolveCloseConfirmation( - shellActivityState: panelShellActivityStates[panelId], - fallbackNeedsConfirmClose: fallbackNeedsConfirmClose - ) + var isRemoteWorkspace: Bool { + remoteConfiguration != nil } - func updatePanelGitBranch(panelId: UUID, branch: String, isDirty: Bool) { - let state = SidebarGitBranchState(branch: branch, isDirty: isDirty) - let existing = panelGitBranches[panelId] - let branchChanged = existing?.branch != nil && existing?.branch != branch - if existing?.branch != branch || existing?.isDirty != isDirty { - panelGitBranches[panelId] = state - } - if branchChanged { - if panelPullRequests[panelId] != nil { - panelPullRequests.removeValue(forKey: panelId) - } - if panelId == focusedPanelId, pullRequest != nil { - pullRequest = nil - } - } - if panelId == focusedPanelId, gitBranch != state { - gitBranch = state - } + @MainActor + func isRemoteTerminalSurface(_ panelId: UUID) -> Bool { + activeRemoteTerminalSurfaceIds.contains(panelId) } - func clearPanelGitBranch(panelId: UUID) { - if panelGitBranches[panelId] != nil { - panelGitBranches.removeValue(forKey: panelId) - } - if panelPullRequests[panelId] != nil { - panelPullRequests.removeValue(forKey: panelId) - } - if panelId == focusedPanelId { - if gitBranch != nil { - gitBranch = nil - } - if pullRequest != nil { - pullRequest = nil - } - } - } - - func updatePanelPullRequest( - panelId: UUID, - number: Int, - label: String, - url: URL, - status: SidebarPullRequestStatus, - branch: String? = nil, - checks: SidebarPullRequestChecksStatus? = nil - ) { - let existing = panelPullRequests[panelId] - let normalizedBranch = normalizedSidebarBranchName(branch) - let currentPanelBranch = normalizedSidebarBranchName(panelGitBranches[panelId]?.branch) - let resolvedBranch: String? = { - if let normalizedBranch { - return normalizedBranch - } - if let currentPanelBranch { - return currentPanelBranch - } - guard let existing, - existing.number == number, - existing.label == label, - existing.url == url, - existing.status == status else { - return nil - } - return existing.branch - }() - let resolvedChecks: SidebarPullRequestChecksStatus? = { - if let checks { - return checks - } - guard let existing, - existing.number == number, - existing.label == label, - existing.url == url, - existing.status == status else { - return nil - } - return existing.checks - }() - let state = SidebarPullRequestState( - number: number, - label: label, - url: url, - status: status, - branch: resolvedBranch, - checks: resolvedChecks - ) - if existing != state { - panelPullRequests[panelId] = state - } - if panelId == focusedPanelId, pullRequest != state { - pullRequest = state - } - } - - func clearPanelPullRequest(panelId: UUID) { - if panelPullRequests[panelId] != nil { - panelPullRequests.removeValue(forKey: panelId) - } - if panelId == focusedPanelId, pullRequest != nil { - pullRequest = nil - } - } - - func resetSidebarContext(reason: String = "unspecified") { - statusEntries.removeAll() - agentPIDs.removeAll() - agentListeningPorts.removeAll() - logEntries.removeAll() - progress = nil - gitBranch = nil - panelGitBranches.removeAll() - pullRequest = nil - panelPullRequests.removeAll() - surfaceListeningPorts.removeAll() - listeningPorts.removeAll() - metadataBlocks.removeAll() - resetBrowserPanelsForContextChange(reason: reason) - } - - func resetBrowserPanelsForContextChange(reason: String) { - let browserPanels = panels.values.compactMap { $0 as? BrowserPanel } - guard !browserPanels.isEmpty else { return } - -#if DEBUG - dlog( - "workspace.contextReset.browserPanels workspace=\(id.uuidString.prefix(5)) " + - "reason=\(reason) count=\(browserPanels.count)" - ) -#endif - - for browserPanel in browserPanels { - 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 - ) - } + @MainActor + func shouldDemoteWorkspaceAfterChildExit(surfaceId: UUID) -> Bool { + isRemoteWorkspace || pendingRemoteTerminalChildExitSurfaceIds.contains(surfaceId) } - @discardableResult - func updatePanelTitle(panelId: UUID, title: String) -> Bool { - let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return false } - var didMutate = false - - if panelTitles[panelId] != trimmed { - panelTitles[panelId] = 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 { - self.title = trimmed - didMutate = true - } - if processTitle != trimmed { - processTitle = trimmed - } - } - - return didMutate + var remoteDisplayTarget: String? { + remoteConfiguration?.displayTarget } - func pruneSurfaceMetadata(validSurfaceIds: Set) { - 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) } - remoteDetectedSurfaceIds = remoteDetectedSurfaceIds.filter { validSurfaceIds.contains($0) } - panelShellActivityStates = panelShellActivityStates.filter { validSurfaceIds.contains($0.key) } - panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) } - syncRemotePortScanTTYs() - recomputeListeningPorts() + var hasActiveRemoteTerminalSessions: Bool { + activeRemoteTerminalSessionCount > 0 } - func recomputeListeningPorts() { - let unique = Set(surfaceListeningPorts.values.flatMap { $0 }) - .union(agentListeningPorts) - .union(remoteDetectedPorts) - .union(remoteForwardedPorts) - let next = unique.sorted() - if listeningPorts != next { - listeningPorts = next + @MainActor + func uploadDroppedFilesForRemoteTerminal( + _ fileURLs: [URL], + operation: TerminalImageTransferOperation, + completion: @escaping (Result<[String], Error>) -> Void + ) { + guard let controller = remoteSessionController else { + completion(.failure(RemoteDropUploadError.unavailable)) + return } + controller.uploadDroppedFiles(fileURLs, operation: operation, completion: completion) } - func sidebarOrderedPanelIds() -> [UUID] { - let paneTabs: [String: [UUID]] = Dictionary( - uniqueKeysWithValues: bonsplitController.allPaneIds.map { paneId in - let panelIds = bonsplitController - .tabs(inPane: paneId) - .compactMap { panelIdFromSurfaceId($0.id) } - return (paneId.id.uuidString, panelIds) - } - ) - - let fallbackPanelIds = panels.keys.sorted { $0.uuidString < $1.uuidString } - let tree = bonsplitController.treeSnapshot() - return SidebarBranchOrdering.orderedPanelIds( - tree: tree, - paneTabs: paneTabs, - fallbackPanelIds: fallbackPanelIds + @MainActor + func uploadDroppedFilesForRemoteTerminal( + _ fileURLs: [URL], + completion: @escaping (Result<[String], Error>) -> Void + ) { + uploadDroppedFilesForRemoteTerminal( + fileURLs, + operation: TerminalImageTransferOperation(), + completion: completion ) } - private func normalizedSidebarDirectory(_ directory: String?) -> String? { - guard let directory else { return nil } - let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed + func syncRemotePortScanTTYs() { + guard isRemoteWorkspace else { return } + remoteSessionController?.updateRemotePortScanTTYs(surfaceTTYNames) } - private func sidebarHomeDirectoryForCanonicalization( - resolvedPanelDirectories: [UUID: String] - ) -> String? { - if isRemoteWorkspace { - return SidebarBranchOrdering.inferredRemoteHomeDirectory( - from: Array(resolvedPanelDirectories.values), - fallbackDirectory: normalizedSidebarDirectory(currentDirectory) - ) - } - return FileManager.default.homeDirectoryForCurrentUser.path - } - - private func sidebarResolvedDirectory(for panelId: UUID) -> String? { - if let directory = normalizedSidebarDirectory(panelDirectories[panelId]) { - return directory - } - if let requestedDirectory = normalizedSidebarDirectory( - terminalPanel(for: panelId)?.requestedWorkingDirectory - ) { - return requestedDirectory - } - guard panelId == focusedPanelId else { return nil } - return normalizedSidebarDirectory(currentDirectory) - } - - private func sidebarResolvedPanelDirectories(orderedPanelIds: [UUID]) -> [UUID: String] { - var resolved: [UUID: String] = [:] - for panelId in orderedPanelIds { - if let directory = sidebarResolvedDirectory(for: panelId) { - resolved[panelId] = directory - } - } - return resolved - } - - func sidebarDirectoriesInDisplayOrder(orderedPanelIds: [UUID]) -> [String] { - let resolvedDirectories = sidebarResolvedPanelDirectories(orderedPanelIds: orderedPanelIds) - let homeDirectoryForCanonicalization = sidebarHomeDirectoryForCanonicalization( - resolvedPanelDirectories: resolvedDirectories - ) - var ordered: [String] = [] - var seen: Set = [] - - for panelId in orderedPanelIds { - guard let directory = resolvedDirectories[panelId], - let key = SidebarBranchOrdering.canonicalDirectoryKey( - directory, - homeDirectoryForTildeExpansion: homeDirectoryForCanonicalization - ) else { continue } - if seen.insert(key).inserted { - ordered.append(directory) - } - } - - if ordered.isEmpty, let fallbackDirectory = normalizedSidebarDirectory(currentDirectory) { - return [fallbackDirectory] - } - - return ordered - } - - func sidebarDirectoriesInDisplayOrder() -> [String] { - sidebarDirectoriesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) - } - - func sidebarGitBranchesInDisplayOrder(orderedPanelIds: [UUID]) -> [SidebarGitBranchState] { - SidebarBranchOrdering - .orderedUniqueBranches( - orderedPanelIds: orderedPanelIds, - panelBranches: panelGitBranches, - fallbackBranch: gitBranch - ) - .map { SidebarGitBranchState(branch: $0.name, isDirty: $0.isDirty) } - } - - func sidebarGitBranchesInDisplayOrder() -> [SidebarGitBranchState] { - sidebarGitBranchesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) - } - - func sidebarBranchDirectoryEntriesInDisplayOrder( - orderedPanelIds: [UUID] - ) -> [SidebarBranchOrdering.BranchDirectoryEntry] { - let resolvedDirectories = sidebarResolvedPanelDirectories(orderedPanelIds: orderedPanelIds) - return SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( - orderedPanelIds: orderedPanelIds, - panelBranches: panelGitBranches, - panelDirectories: resolvedDirectories, - defaultDirectory: normalizedSidebarDirectory(currentDirectory), - homeDirectoryForTildeExpansion: sidebarHomeDirectoryForCanonicalization( - resolvedPanelDirectories: resolvedDirectories - ), - fallbackBranch: gitBranch - ) - } - - func sidebarBranchDirectoryEntriesInDisplayOrder() -> [SidebarBranchOrdering.BranchDirectoryEntry] { - sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) - } - - func sidebarPullRequestsInDisplayOrder(orderedPanelIds: [UUID]) -> [SidebarPullRequestState] { - let validPanelPullRequests = panelPullRequests.filter { panelId, state in - guard let pullRequestBranch = normalizedSidebarBranchName(state.branch) else { - return true - } - return normalizedSidebarBranchName(panelGitBranches[panelId]?.branch) == pullRequestBranch - } - return SidebarBranchOrdering.orderedUniquePullRequests( - orderedPanelIds: orderedPanelIds, - panelPullRequests: validPanelPullRequests, - fallbackPullRequest: nil - ) - } - - func sidebarPullRequestsInDisplayOrder() -> [SidebarPullRequestState] { - sidebarPullRequestsInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) - } - - func sidebarStatusEntriesInDisplayOrder() -> [SidebarStatusEntry] { - statusEntries.values.sorted { lhs, rhs in - if lhs.priority != rhs.priority { return lhs.priority > rhs.priority } - if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } - return lhs.key < rhs.key - } - } - - func sidebarMetadataBlocksInDisplayOrder() -> [SidebarMetadataBlock] { - metadataBlocks.values.sorted { lhs, rhs in - if lhs.priority != rhs.priority { return lhs.priority > rhs.priority } - if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } - return lhs.key < rhs.key - } - } - - var isRemoteWorkspace: Bool { - remoteConfiguration != nil - } - - @MainActor - func isRemoteTerminalSurface(_ panelId: UUID) -> Bool { - activeRemoteTerminalSurfaceIds.contains(panelId) - } - - @MainActor - func shouldDemoteWorkspaceAfterChildExit(surfaceId: UUID) -> Bool { - isRemoteWorkspace || pendingRemoteTerminalChildExitSurfaceIds.contains(surfaceId) - } - - var remoteDisplayTarget: String? { - remoteConfiguration?.displayTarget - } - - var hasActiveRemoteTerminalSessions: Bool { - activeRemoteTerminalSessionCount > 0 - } - - @MainActor - func uploadDroppedFilesForRemoteTerminal( - _ fileURLs: [URL], - operation: TerminalImageTransferOperation, - completion: @escaping (Result<[String], Error>) -> Void - ) { - guard let controller = remoteSessionController else { - completion(.failure(RemoteDropUploadError.unavailable)) - return - } - controller.uploadDroppedFiles(fileURLs, operation: operation, completion: completion) - } - - @MainActor - func uploadDroppedFilesForRemoteTerminal( - _ fileURLs: [URL], - completion: @escaping (Result<[String], Error>) -> Void - ) { - uploadDroppedFilesForRemoteTerminal( - fileURLs, - operation: TerminalImageTransferOperation(), - completion: completion - ) - } - - func syncRemotePortScanTTYs() { - guard isRemoteWorkspace else { return } - remoteSessionController?.updateRemotePortScanTTYs(surfaceTTYNames) - } - - func kickRemotePortScan(panelId: UUID, reason: WorkspaceRemoteSessionController.PortScanKickReason = .command) { - guard isRemoteWorkspace else { return } - syncRemotePortScanTTYs() - remoteSessionController?.kickRemotePortScan(panelId: panelId, reason: reason) + func kickRemotePortScan(panelId: UUID, reason: WorkspaceRemoteSessionController.PortScanKickReason = .command) { + guard isRemoteWorkspace else { return } + syncRemotePortScanTTYs() + remoteSessionController?.kickRemotePortScan(panelId: panelId, reason: reason) } func remoteStatusPayload() -> [String: Any] { @@ -3259,4179 +2508,3119 @@ final class Workspace: Identifiable, ObservableObject { "detected_ports": remoteDetectedPorts, "forwarded_ports": remoteForwardedPorts, "conflicted_ports": remotePortConflicts, - "detail": remoteConnectionDetail ?? NSNull(), - "heartbeat": [ - "count": remoteHeartbeatCount, - "last_seen_at": heartbeatTimestamp, - "age_seconds": heartbeatAgeSeconds, - ], - ] - if let endpoint = remoteProxyEndpoint { - payload["proxy"] = [ - "state": "ready", - "host": endpoint.host, - "port": endpoint.port, - "schemes": ["socks5", "http_connect"], - "url": "socks5://\(endpoint.host):\(endpoint.port)", - ] - } else { - let proxyState: String - if hasProxyOnlyRemoteSidebarError { - proxyState = "error" - } else { - switch remoteConnectionState { - case .connecting: - proxyState = "connecting" - case .error: - proxyState = "error" - default: - proxyState = "unavailable" - } - } - payload["proxy"] = [ - "state": proxyState, - "host": NSNull(), - "port": NSNull(), - "schemes": ["socks5", "http_connect"], - "url": NSNull(), - "error_code": proxyState == "error" ? "proxy_unavailable" : NSNull(), - ] - } - if let remoteConfiguration { - payload["destination"] = remoteConfiguration.destination - payload["port"] = remoteConfiguration.port ?? NSNull() - payload["has_identity_file"] = remoteConfiguration.identityFile != nil - payload["has_ssh_options"] = !remoteConfiguration.sshOptions.isEmpty - payload["local_proxy_port"] = remoteConfiguration.localProxyPort ?? NSNull() - } else { - payload["destination"] = NSNull() - payload["port"] = NSNull() - payload["has_identity_file"] = false - payload["has_ssh_options"] = false - payload["local_proxy_port"] = NSNull() - } - return payload - } - - func configureRemoteConnection(_ configuration: WorkspaceRemoteConfiguration, autoConnect: Bool = true) { - skipControlMasterCleanupAfterDetachedRemoteTransfer = false - remoteConfiguration = configuration - seedInitialRemoteTerminalSessionIfNeeded(configuration: configuration) - clearRemoteDetectedSurfacePorts() - remoteDetectedPorts = [] - remoteForwardedPorts = [] - remotePortConflicts = [] - remoteProxyEndpoint = nil - remoteHeartbeatCount = 0 - remoteLastHeartbeatAt = nil - remoteConnectionDetail = nil - remoteDaemonStatus = WorkspaceRemoteDaemonStatus() - statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) - statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey) - remoteLastErrorFingerprint = nil - remoteLastDaemonErrorFingerprint = nil - remoteLastPortConflictFingerprint = nil - recomputeListeningPorts() - - let previousController = remoteSessionController - activeRemoteSessionControllerID = nil - remoteSessionController = nil - previousController?.stop() - applyRemoteProxyEndpointUpdate(nil) - applyBrowserRemoteWorkspaceStatusToPanels() - - let foregroundAuthToken = Self.normalizedForegroundAuthToken(configuration.foregroundAuthToken) - let shouldAutoConnect = - autoConnect - || (foregroundAuthToken != nil && foregroundAuthToken == pendingRemoteForegroundAuthToken) - pendingRemoteForegroundAuthToken = nil - guard shouldAutoConnect else { - remoteConnectionState = .disconnected - applyBrowserRemoteWorkspaceStatusToPanels() - return - } - - remoteConnectionState = .connecting - applyBrowserRemoteWorkspaceStatusToPanels() - let controllerID = UUID() - let controller = WorkspaceRemoteSessionController( - workspace: self, - configuration: configuration, - controllerID: controllerID - ) - activeRemoteSessionControllerID = controllerID - remoteSessionController = controller - syncRemotePortScanTTYs() - controller.start() - } - - func reconnectRemoteConnection() { - guard let configuration = remoteConfiguration else { return } - configureRemoteConnection(configuration, autoConnect: true) - } - - private static func normalizedForegroundAuthToken(_ token: String?) -> String? { - guard let token else { return nil } - let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - - func notifyRemoteForegroundAuthenticationReady(token: String? = nil) { - guard let foregroundAuthToken = Self.normalizedForegroundAuthToken(token) else { - return - } - - guard let remoteConfiguration else { - pendingRemoteForegroundAuthToken = foregroundAuthToken - return - } - - guard Self.normalizedForegroundAuthToken(remoteConfiguration.foregroundAuthToken) == foregroundAuthToken else { - return - } - - pendingRemoteForegroundAuthToken = nil - guard remoteConnectionState == .disconnected else { return } - reconnectRemoteConnection() - } - - func disconnectRemoteConnection(clearConfiguration: Bool = false) { - let shouldCleanupControlMaster = - clearConfiguration - && !isDetachingCloseTransaction - && pendingDetachedSurfaces.isEmpty - && !skipControlMasterCleanupAfterDetachedRemoteTransfer - let configurationForCleanup = shouldCleanupControlMaster ? remoteConfiguration : nil - let previousController = remoteSessionController - activeRemoteSessionControllerID = nil - remoteSessionController = nil - previousController?.stop() - pendingRemoteForegroundAuthToken = nil - activeRemoteTerminalSurfaceIds.removeAll() - activeRemoteTerminalSessionCount = 0 - pendingRemoteSurfaceTTYName = nil - pendingRemoteSurfaceTTYSurfaceId = nil - pendingRemoteSurfacePortKickReason = nil - pendingRemoteSurfacePortKickSurfaceId = nil - clearRemoteDetectedSurfacePorts() - remoteDetectedPorts = [] - remoteForwardedPorts = [] - remotePortConflicts = [] - remoteProxyEndpoint = nil - remoteHeartbeatCount = 0 - remoteLastHeartbeatAt = nil - remoteConnectionState = .disconnected - remoteConnectionDetail = nil - remoteDaemonStatus = WorkspaceRemoteDaemonStatus() - statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) - statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey) - remoteLastErrorFingerprint = nil - remoteLastDaemonErrorFingerprint = nil - remoteLastPortConflictFingerprint = nil - if clearConfiguration { - remoteConfiguration = nil - skipControlMasterCleanupAfterDetachedRemoteTransfer = false - } - applyRemoteProxyEndpointUpdate(nil) - applyBrowserRemoteWorkspaceStatusToPanels() - recomputeListeningPorts() - if let configurationForCleanup { - Self.requestSSHControlMasterCleanupIfNeeded(configuration: configurationForCleanup) - } - } - - private func clearRemoteConfigurationIfWorkspaceBecameLocal() { - guard !isDetachingCloseTransaction, panels.isEmpty, remoteConfiguration != nil else { return } - disconnectRemoteConnection(clearConfiguration: true) - } - - private func seedInitialRemoteTerminalSessionIfNeeded(configuration: WorkspaceRemoteConfiguration) { - guard configuration.terminalStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { - return - } - guard activeRemoteTerminalSurfaceIds.isEmpty else { return } - let terminalIds = panels.compactMap { panelId, panel in - panel is TerminalPanel ? panelId : nil - } - guard terminalIds.count == 1, let initialPanelId = terminalIds.first else { return } - trackRemoteTerminalSurface(initialPanelId) - } - - private func trackRemoteTerminalSurface(_ panelId: UUID) { - skipControlMasterCleanupAfterDetachedRemoteTransfer = false - pendingRemoteTerminalChildExitSurfaceIds.remove(panelId) - transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: panelId) - guard activeRemoteTerminalSurfaceIds.insert(panelId).inserted else { return } - activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count - applyPendingRemoteSurfaceTTYIfNeeded(to: panelId) - _ = applyPendingRemoteSurfacePortKickIfNeeded(to: panelId) - } - - private func untrackRemoteTerminalSurface(_ panelId: UUID) { - guard activeRemoteTerminalSurfaceIds.remove(panelId) != nil else { return } - activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count - guard !isDetachingCloseTransaction else { return } - maybeDemoteRemoteWorkspaceAfterSSHSessionEnded() - } - - private func maybeDemoteRemoteWorkspaceAfterSSHSessionEnded() { - guard activeRemoteTerminalSurfaceIds.isEmpty, remoteConfiguration != nil else { return } - let hasBrowserPanels = panels.values.contains { $0 is BrowserPanel } - if !hasBrowserPanels { - if remoteConnectionState == .error || remoteDaemonStatus.state == .error || remoteConnectionState == .connecting { - return - } - disconnectRemoteConnection(clearConfiguration: true) - } - } - - @MainActor - func rememberPendingRemoteSurfaceTTY(_ ttyName: String, requestedSurfaceId: UUID?) { - let trimmedTTY = ttyName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedTTY.isEmpty else { return } - pendingRemoteSurfaceTTYName = trimmedTTY - pendingRemoteSurfaceTTYSurfaceId = requestedSurfaceId - } - - @MainActor - func rememberPendingRemoteSurfacePortKick( - reason: WorkspaceRemoteSessionController.PortScanKickReason, - requestedSurfaceId: UUID? - ) { - pendingRemoteSurfacePortKickReason = reason - pendingRemoteSurfacePortKickSurfaceId = requestedSurfaceId - } - - @MainActor - private func applyPendingRemoteSurfaceTTYIfNeeded(to panelId: UUID) { - guard let ttyName = pendingRemoteSurfaceTTYName?.trimmingCharacters(in: .whitespacesAndNewlines), - !ttyName.isEmpty else { - return - } - if let requestedSurfaceId = pendingRemoteSurfaceTTYSurfaceId, requestedSurfaceId != panelId { - return - } - surfaceTTYNames[panelId] = ttyName - pendingRemoteSurfaceTTYName = nil - pendingRemoteSurfaceTTYSurfaceId = nil - syncRemotePortScanTTYs() - if !applyPendingRemoteSurfacePortKickIfNeeded(to: panelId) { - kickRemotePortScan(panelId: panelId, reason: .command) - } - } - - @MainActor - @discardableResult - func applyPendingRemoteSurfacePortKickIfNeeded(to panelId: UUID) -> Bool { - guard let reason = pendingRemoteSurfacePortKickReason else { - return false - } - if let requestedSurfaceId = pendingRemoteSurfacePortKickSurfaceId, - requestedSurfaceId != panelId { - return false - } - guard let ttyName = surfaceTTYNames[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines), - !ttyName.isEmpty else { - return false - } - _ = ttyName - pendingRemoteSurfacePortKickReason = nil - pendingRemoteSurfacePortKickSurfaceId = nil - kickRemotePortScan(panelId: panelId, reason: reason) - return true - } - - @MainActor - func applyBootstrapRemoteTTY(_ ttyName: String) { - let trimmedTTY = ttyName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedTTY.isEmpty else { return } - - let candidateSurfaceId: UUID? = { - if let focusedPanelId, activeRemoteTerminalSurfaceIds.contains(focusedPanelId) { - return focusedPanelId - } - if activeRemoteTerminalSurfaceIds.count == 1 { - return activeRemoteTerminalSurfaceIds.first - } - return nil - }() - - guard let candidateSurfaceId else { - rememberPendingRemoteSurfaceTTY(trimmedTTY, requestedSurfaceId: nil) - return - } - - surfaceTTYNames[candidateSurfaceId] = trimmedTTY - syncRemotePortScanTTYs() - if !applyPendingRemoteSurfacePortKickIfNeeded(to: candidateSurfaceId) { - kickRemotePortScan(panelId: candidateSurfaceId, reason: .command) - } - } - - private func cleanupTransferredRemoteConnectionIfNeeded(surfaceId: UUID, relayPort: Int?) -> Bool { - guard let relayPort, - relayPort > 0, - let cleanupConfiguration = transferredRemoteCleanupConfigurationsByPanelId[surfaceId], - cleanupConfiguration.relayPort == relayPort else { - return false - } - transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: surfaceId) - Self.requestSSHControlMasterCleanupIfNeeded(configuration: cleanupConfiguration) - return true - } - - func markRemoteTerminalSessionEnded(surfaceId: UUID, relayPort: Int?) { - if cleanupTransferredRemoteConnectionIfNeeded(surfaceId: surfaceId, relayPort: relayPort) { - return - } - guard let relayPort, - relayPort > 0, - remoteConfiguration?.relayPort == relayPort else { - return - } - pendingRemoteTerminalChildExitSurfaceIds.insert(surfaceId) - untrackRemoteTerminalSurface(surfaceId) - } - - func teardownRemoteConnection() { - disconnectRemoteConnection(clearConfiguration: true) - } - - private static func requestSSHControlMasterCleanupIfNeeded(configuration: WorkspaceRemoteConfiguration) { - guard let arguments = sshControlMasterCleanupArguments(configuration: configuration) else { return } - if let override = runSSHControlMasterCommandOverrideForTesting { - override(arguments) - return - } - - sshControlMasterCleanupQueue.async { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") - process.arguments = arguments - process.standardInput = FileHandle.nullDevice - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - let exitSemaphore = DispatchSemaphore(value: 0) - process.terminationHandler = { _ in - exitSemaphore.signal() - } - - do { - try process.run() - if exitSemaphore.wait(timeout: .now() + 5) == .timedOut { - if process.isRunning { - process.terminate() - } - _ = exitSemaphore.wait(timeout: .now() + 1) - } - } catch { - return - } - } - } - - private static func sshControlMasterCleanupArguments(configuration: WorkspaceRemoteConfiguration) -> [String]? { - let sshOptions = normalizedSSHControlCleanupOptions(configuration.sshOptions) - var arguments: [String] = [ - "-o", "BatchMode=yes", - "-o", "ControlMaster=no", - ] - if let port = configuration.port { - arguments += ["-p", String(port)] - } - if let identityFile = configuration.identityFile?.trimmingCharacters(in: .whitespacesAndNewlines), - !identityFile.isEmpty { - arguments += ["-i", identityFile] - } - for option in sshOptions { - arguments += ["-o", option] - } - arguments += ["-O", "exit", configuration.destination] - return arguments - } - - private static func normalizedSSHControlCleanupOptions(_ options: [String]) -> [String] { - let disallowedKeys: Set = ["controlmaster", "controlpersist"] - return options.compactMap { option in - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - guard let key = sshOptionKeyForControlCleanup(trimmed) else { return nil } - return disallowedKeys.contains(key) ? nil : trimmed - } - } - - private static func sshOptionKeyForControlCleanup(_ option: String) -> String? { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) - .first - .map(String.init)? - .lowercased() - } - - func applyRemoteConnectionStateUpdate( - _ state: WorkspaceRemoteConnectionState, - detail: String?, - target: String - ) { - let trimmedDetail = detail?.trimmingCharacters(in: .whitespacesAndNewlines) - let proxyOnlyError = trimmedDetail.map(Self.isProxyOnlyRemoteError) ?? false - let preserveConnectedStateForRetry = - state == .connecting && preservesSSHTerminalConnection && hasProxyOnlyRemoteSidebarError - let effectiveState: WorkspaceRemoteConnectionState - if state == .error && proxyOnlyError && preservesSSHTerminalConnection { - effectiveState = .connected - } else if preserveConnectedStateForRetry { - effectiveState = .connected - } else { - effectiveState = state - } - - remoteConnectionState = effectiveState - remoteConnectionDetail = detail - applyBrowserRemoteWorkspaceStatusToPanels() - - if let trimmedDetail, !trimmedDetail.isEmpty, (state == .error || proxyOnlyError) { - let statusPrefix = proxyOnlyError ? "Remote proxy unavailable" : "SSH error" - let statusIcon = proxyOnlyError ? "exclamationmark.triangle.fill" : "network.slash" - let notificationTitle = proxyOnlyError ? "Remote Proxy Unavailable" : "Remote SSH Error" - let logSource = proxyOnlyError ? "remote-proxy" : "remote" - statusEntries[Self.remoteErrorStatusKey] = SidebarStatusEntry( - key: Self.remoteErrorStatusKey, - value: "\(statusPrefix) (\(target)): \(trimmedDetail)", - icon: statusIcon, - color: nil, - timestamp: Date() - ) - - let fingerprint = "connection:\(trimmedDetail)" - if remoteLastErrorFingerprint != fingerprint { - remoteLastErrorFingerprint = fingerprint - appendSidebarLog( - message: "\(statusPrefix) (\(target)): \(trimmedDetail)", - level: .error, - source: logSource - ) - AppDelegate.shared?.notificationStore?.addNotification( - tabId: id, - surfaceId: nil, - title: notificationTitle, - subtitle: target, - body: trimmedDetail, - cooldownKey: remoteNotificationCooldownKey(target: target), - cooldownInterval: Self.remoteNotificationCooldown - ) - } - return - } - - if state == .connected { - statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) - remoteLastErrorFingerprint = nil - } - } - - func applyRemoteDaemonStatusUpdate(_ status: WorkspaceRemoteDaemonStatus, target: String) { - remoteDaemonStatus = status - applyBrowserRemoteWorkspaceStatusToPanels() - guard status.state == .error else { - remoteLastDaemonErrorFingerprint = nil - return - } - let trimmedDetail = status.detail?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "remote daemon error" - let fingerprint = "daemon:\(trimmedDetail)" - guard remoteLastDaemonErrorFingerprint != fingerprint else { return } - remoteLastDaemonErrorFingerprint = fingerprint - appendSidebarLog( - message: "Remote daemon error (\(target)): \(trimmedDetail)", - level: .error, - source: "remote-daemon" - ) - } - - func applyRemoteProxyEndpointUpdate(_ endpoint: BrowserProxyEndpoint?) { - remoteProxyEndpoint = endpoint - for panel in panels.values { - guard let browserPanel = panel as? BrowserPanel else { continue } - browserPanel.setRemoteProxyEndpoint(endpoint) - } - applyBrowserRemoteWorkspaceStatusToPanels() - } - - func applyRemoteHeartbeatUpdate(count: Int, lastSeenAt: Date?) { - remoteHeartbeatCount = max(0, count) - remoteLastHeartbeatAt = lastSeenAt - applyBrowserRemoteWorkspaceStatusToPanels() - } - - func applyRemoteDetectedSurfacePortsSnapshot( - detectedByPanel: [UUID: [Int]], - detected: [Int], - forwarded: [Int], - conflicts: [Int], - target: String - ) { - let trackedSurfaceIds = Set(detectedByPanel.keys) - for panelId in remoteDetectedSurfaceIds.subtracting(trackedSurfaceIds) { - surfaceListeningPorts.removeValue(forKey: panelId) - } - remoteDetectedSurfaceIds = trackedSurfaceIds - - for (panelId, ports) in detectedByPanel { - if ports.isEmpty { - surfaceListeningPorts.removeValue(forKey: panelId) - } else { - surfaceListeningPorts[panelId] = ports - } - } - - remoteDetectedPorts = detected - remoteForwardedPorts = forwarded - remotePortConflicts = conflicts - recomputeListeningPorts() - - if conflicts.isEmpty { - statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey) - remoteLastPortConflictFingerprint = nil - return - } - - let conflictsList = conflicts.map { ":\($0)" }.joined(separator: ", ") - statusEntries[Self.remotePortConflictStatusKey] = SidebarStatusEntry( - key: Self.remotePortConflictStatusKey, - value: "SSH port conflicts (\(target)): \(conflictsList)", - icon: "exclamationmark.triangle.fill", - color: nil, - timestamp: Date() - ) - - let fingerprint = conflicts.map(String.init).joined(separator: ",") - guard remoteLastPortConflictFingerprint != fingerprint else { return } - remoteLastPortConflictFingerprint = fingerprint - appendSidebarLog( - message: "Port conflicts while forwarding \(target): \(conflictsList)", - level: .warning, - source: "remote-forward" - ) - } - - private func clearRemoteDetectedSurfacePorts() { - for panelId in remoteDetectedSurfaceIds { - surfaceListeningPorts.removeValue(forKey: panelId) - } - remoteDetectedSurfaceIds.removeAll() - } - - private func appendSidebarLog(message: String, level: SidebarLogLevel, source: String?) { - let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - logEntries.append(SidebarLogEntry(message: trimmed, level: level, source: source, timestamp: Date())) - let configuredLimit = UserDefaults.standard.object(forKey: "sidebarMaxLogEntries") as? Int ?? 50 - let limit = max(1, min(500, configuredLimit)) - if logEntries.count > limit { - logEntries.removeFirst(logEntries.count - limit) - } - } - - // MARK: - Panel Operations - - private func seedTerminalInheritanceFontPoints( - panelId: UUID, - configTemplate: ProgramaSurfaceConfigTemplate? - ) { - guard let fontPoints = configTemplate?.fontSize, fontPoints > 0 else { return } - terminalInheritanceFontPointsByPanelId[panelId] = fontPoints - lastTerminalConfigInheritanceFontPoints = fontPoints - } - - private func resolvedTerminalInheritanceFontPoints( - for terminalPanel: TerminalPanel, - sourceSurface: ghostty_surface_t, - inheritedConfig: ProgramaSurfaceConfigTemplate - ) -> Float? { - let runtimePoints = programaCurrentSurfaceFontSizePoints(sourceSurface) - if let rooted = terminalInheritanceFontPointsByPanelId[terminalPanel.id], rooted > 0 { - if let runtimePoints, abs(runtimePoints - rooted) > 0.05 { - // Runtime zoom changed after lineage was seeded (manual zoom on descendant); - // treat runtime as the new root for future descendants. - return runtimePoints - } - return rooted - } - if inheritedConfig.fontSize > 0 { - return inheritedConfig.fontSize - } - return runtimePoints - } - - private func rememberTerminalConfigInheritanceSource(_ terminalPanel: TerminalPanel) { - lastTerminalConfigInheritancePanelId = terminalPanel.id - if terminalPanel.surface.isSurfaceLive, - let sourceSurface = terminalPanel.surface.surface, - let runtimePoints = programaCurrentSurfaceFontSizePoints(sourceSurface) { - let existing = terminalInheritanceFontPointsByPanelId[terminalPanel.id] - if existing == nil || abs((existing ?? runtimePoints) - runtimePoints) > 0.05 { - terminalInheritanceFontPointsByPanelId[terminalPanel.id] = runtimePoints - } - lastTerminalConfigInheritanceFontPoints = - terminalInheritanceFontPointsByPanelId[terminalPanel.id] ?? runtimePoints - } - } - - func lastRememberedTerminalPanelForConfigInheritance() -> TerminalPanel? { - guard let panelId = lastTerminalConfigInheritancePanelId else { return nil } - return terminalPanel(for: panelId) - } - - func lastRememberedTerminalFontPointsForConfigInheritance() -> Float? { - lastTerminalConfigInheritanceFontPoints - } - - /// Candidate terminal panels used as the source when creating inherited Ghostty config. - /// Preference order: - /// 1) explicitly preferred terminal panel (when the caller has one), - /// 2) selected terminal in the target pane, - /// 3) currently focused terminal in the workspace, - /// 4) last remembered terminal source, - /// 5) first terminal tab in the target pane, - /// 6) deterministic workspace fallback. - private func terminalPanelConfigInheritanceCandidates( - preferredPanelId: UUID? = nil, - inPane preferredPaneId: PaneID? = nil - ) -> [TerminalPanel] { - var candidates: [TerminalPanel] = [] - var seen: Set = [] - - func appendCandidate(_ panel: TerminalPanel?) { - guard let panel, seen.insert(panel.id).inserted else { return } - candidates.append(panel) - } - - if let preferredPanelId, - let terminalPanel = terminalPanel(for: preferredPanelId) { - appendCandidate(terminalPanel) - } - - if let preferredPaneId, - let selectedSurfaceId = bonsplitController.selectedTab(inPane: preferredPaneId)?.id, - let selectedPanelId = panelIdFromSurfaceId(selectedSurfaceId), - let selectedTerminalPanel = terminalPanel(for: selectedPanelId) { - appendCandidate(selectedTerminalPanel) - } - - if let focusedTerminalPanel { - appendCandidate(focusedTerminalPanel) - } - - if let rememberedTerminalPanel = lastRememberedTerminalPanelForConfigInheritance() { - appendCandidate(rememberedTerminalPanel) - } - - if let preferredPaneId { - for tab in bonsplitController.tabs(inPane: preferredPaneId) { - guard let panelId = panelIdFromSurfaceId(tab.id), - let terminalPanel = terminalPanel(for: panelId) else { continue } - appendCandidate(terminalPanel) - } - } - - for terminalPanel in panels.values - .compactMap({ $0 as? TerminalPanel }) - .sorted(by: { $0.id.uuidString < $1.id.uuidString }) { - appendCandidate(terminalPanel) - } - - return candidates - } - - /// Picks the first terminal panel candidate used as the inheritance source. - func terminalPanelForConfigInheritance( - preferredPanelId: UUID? = nil, - inPane preferredPaneId: PaneID? = nil - ) -> TerminalPanel? { - terminalPanelConfigInheritanceCandidates( - preferredPanelId: preferredPanelId, - inPane: preferredPaneId - ).first - } - - private func inheritedTerminalConfig( - preferredPanelId: UUID? = nil, - inPane preferredPaneId: PaneID? = nil - ) -> ProgramaSurfaceConfigTemplate? { - // Walk candidates in priority order and use the first panel that still exposes - // a runtime surface pointer. - for terminalPanel in terminalPanelConfigInheritanceCandidates( - preferredPanelId: preferredPanelId, - inPane: preferredPaneId - ) { - // Pin the panel and its TerminalSurface wrapper for the duration of - // this iteration. The raw ghostty_surface_t extracted below is owned - // by `surface` (the TerminalSurface) — ARC must not release it while - // ghostty_surface_inherited_config or programaCurrentSurfaceFontSizePoints - // is still reading through the pointer. - let surface = terminalPanel.surface - guard let sourceSurface = surface.surface else { continue } - var config = programaInheritedSurfaceConfig( - sourceSurface: sourceSurface, - context: GHOSTTY_SURFACE_CONTEXT_SPLIT - ) - if let rootedFontPoints = resolvedTerminalInheritanceFontPoints( - for: terminalPanel, - sourceSurface: sourceSurface, - inheritedConfig: config - ), rootedFontPoints > 0 { - config.fontSize = rootedFontPoints - terminalInheritanceFontPointsByPanelId[terminalPanel.id] = rootedFontPoints - } - // Prevent ARC from releasing panel/surface before the C calls above complete. - withExtendedLifetime((terminalPanel, surface)) {} - rememberTerminalConfigInheritanceSource(terminalPanel) - if config.fontSize > 0 { - lastTerminalConfigInheritanceFontPoints = config.fontSize - } - return config - } - - if let fallbackFontPoints = lastTerminalConfigInheritanceFontPoints { - var config = ProgramaSurfaceConfigTemplate() - config.fontSize = fallbackFontPoints -#if DEBUG - dlog( - "zoom.inherit fallback=lastKnownFont context=split font=\(String(format: "%.2f", fallbackFontPoints))" - ) -#endif - return config - } - - return nil - } - - /// Create a new split with a terminal panel - @discardableResult - func newTerminalSplit( - from panelId: UUID, - orientation: SplitOrientation, - insertFirst: Bool = false, - focus: Bool = true - ) -> TerminalPanel? { - // 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 }) { - sourcePaneId = paneId - break - } - } - - guard let paneId = sourcePaneId else { return nil } - let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) - let remoteTerminalStartupCommand = remoteTerminalStartupCommand() - - // Inherit working directory: prefer the source panel's reported cwd, - // 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), - !panelDirectory.isEmpty { - return panelDirectory - } - if let requestedWorkingDirectory = terminalPanel(for: panelId)? - .requestedWorkingDirectory? - .trimmingCharacters(in: .whitespacesAndNewlines), - !requestedWorkingDirectory.isEmpty { - return requestedWorkingDirectory - } - let workspaceDirectory = currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) - return workspaceDirectory.isEmpty ? nil : workspaceDirectory - }() -#if DEBUG - dlog( - "split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") requestedDir=\(terminalPanel(for: panelId)?.requestedWorkingDirectory ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")" - ) -#endif - - // Create the new terminal panel. - let newPanel = TerminalPanel( - workspaceId: id, - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: inheritedConfig, - workingDirectory: splitWorkingDirectory, - portOrdinal: portOrdinal, - initialCommand: remoteTerminalStartupCommand - ) - configureTerminalPanel(newPanel) - panels[newPanel.id] = newPanel - panelTitles[newPanel.id] = newPanel.displayTitle - if remoteTerminalStartupCommand != nil { - trackRemoteTerminalSurface(newPanel.id) - } - seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) - - // 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, - // 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 { - panels.removeValue(forKey: newPanel.id) - panelTitles.removeValue(forKey: newPanel.id) - surfaceIdToPanelId.removeValue(forKey: newTab.id) - if remoteTerminalStartupCommand != nil { - untrackRemoteTerminalSurface(newPanel.id) + "detail": remoteConnectionDetail ?? NSNull(), + "heartbeat": [ + "count": remoteHeartbeatCount, + "last_seen_at": heartbeatTimestamp, + "age_seconds": heartbeatAgeSeconds, + ], + ] + if let endpoint = remoteProxyEndpoint { + payload["proxy"] = [ + "state": "ready", + "host": endpoint.host, + "port": endpoint.port, + "schemes": ["socks5", "http_connect"], + "url": "socks5://\(endpoint.host):\(endpoint.port)", + ] + } else { + let proxyState: String + if hasProxyOnlyRemoteSidebarError { + proxyState = "error" + } else { + switch remoteConnectionState { + case .connecting: + proxyState = "connecting" + case .error: + proxyState = "error" + default: + proxyState = "unavailable" + } } - terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) - return nil + payload["proxy"] = [ + "state": proxyState, + "host": NSNull(), + "port": NSNull(), + "schemes": ["socks5", "http_connect"], + "url": NSNull(), + "error_code": proxyState == "error" ? "proxy_unavailable" : NSNull(), + ] + } + if let remoteConfiguration { + payload["destination"] = remoteConfiguration.destination + payload["port"] = remoteConfiguration.port ?? NSNull() + payload["has_identity_file"] = remoteConfiguration.identityFile != nil + payload["has_ssh_options"] = !remoteConfiguration.sshOptions.isEmpty + payload["local_proxy_port"] = remoteConfiguration.localProxyPort ?? NSNull() + } else { + payload["destination"] = NSNull() + payload["port"] = NSNull() + payload["has_identity_file"] = false + payload["has_ssh_options"] = false + payload["local_proxy_port"] = NSNull() } + return payload + } -#if DEBUG - dlog("split.created pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation)") -#endif + func configureRemoteConnection(_ configuration: WorkspaceRemoteConfiguration, autoConnect: Bool = true) { + // Capture before resetRemoteState() nulls pendingRemoteForegroundAuthToken. + let foregroundAuthToken = Self.normalizedForegroundAuthToken(configuration.foregroundAuthToken) + let shouldAutoConnect = + autoConnect + || (foregroundAuthToken != nil && foregroundAuthToken == pendingRemoteForegroundAuthToken) - // Suppress the old view's becomeFirstResponder side-effects during SwiftUI reparenting. - // Without this, reparenting triggers onFocus + ghostty_surface_set_focus on the old view, - // stealing focus from the new panel and creating model/surface divergence. - 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 - ) + remoteConfiguration = configuration + resetRemoteState() + // Seed after the reset so a reconfigure of an already-connected workspace doesn't see + // stale per-panel bookkeeping from the previous destination and skip seeding. Refs #83. + seedInitialRemoteTerminalSessionIfNeeded(configuration: configuration) + recomputeListeningPorts() + applyRemoteProxyEndpointUpdate(nil) + applyBrowserRemoteWorkspaceStatusToPanels() + + guard shouldAutoConnect else { + remoteConnectionState = .disconnected + applyBrowserRemoteWorkspaceStatusToPanels() + return } - owningTabManager?.scheduleInitialWorkspaceGitMetadataRefreshIfPossible( - workspaceId: id, - panelId: newPanel.id, - reason: "splitCreate" + remoteConnectionState = .connecting + applyBrowserRemoteWorkspaceStatusToPanels() + let controllerID = UUID() + let controller = WorkspaceRemoteSessionController( + workspace: self, + configuration: configuration, + controllerID: controllerID ) + activeRemoteSessionControllerID = controllerID + remoteSessionController = controller + syncRemotePortScanTTYs() + controller.start() + } - return newPanel + func reconnectRemoteConnection() { + guard let configuration = remoteConfiguration else { return } + configureRemoteConnection(configuration, autoConnect: true) } - /// Create a new surface (nested tab) in the specified pane with a terminal panel. - /// - Parameter focus: nil = focus only if the target pane is already focused (default UI behavior), - /// true = force focus/selection of the new surface, - /// false = never focus (used for internal placeholder repair paths). - @discardableResult - func newTerminalSurface( - inPane paneId: PaneID, - focus: Bool? = nil, - workingDirectory: String? = nil, - startupEnvironment: [String: String] = [:] - ) -> TerminalPanel? { - let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) - let previousFocusedPanelId = focusedPanelId - let previousHostedView = focusedTerminalPanel?.hostedView + static func normalizedForegroundAuthToken(_ token: String?) -> String? { + guard let token else { return nil } + let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } - let inheritedConfig = inheritedTerminalConfig(inPane: paneId) - let remoteTerminalStartupCommand = remoteTerminalStartupCommand() + func notifyRemoteForegroundAuthenticationReady(token: String? = nil) { + guard let foregroundAuthToken = Self.normalizedForegroundAuthToken(token) else { + return + } - // Create new terminal panel - let newPanel = TerminalPanel( - workspaceId: id, - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: inheritedConfig, - workingDirectory: workingDirectory, - portOrdinal: portOrdinal, - initialCommand: remoteTerminalStartupCommand, - additionalEnvironment: startupEnvironment - ) - configureTerminalPanel(newPanel) - panels[newPanel.id] = newPanel - panelTitles[newPanel.id] = newPanel.displayTitle - if remoteTerminalStartupCommand != nil { - trackRemoteTerminalSurface(newPanel.id) + guard let remoteConfiguration else { + pendingRemoteForegroundAuthToken = foregroundAuthToken + return } - 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, - isPinned: false, - inPane: paneId - ) else { - panels.removeValue(forKey: newPanel.id) - panelTitles.removeValue(forKey: newPanel.id) - if remoteTerminalStartupCommand != nil { - untrackRemoteTerminalSurface(newPanel.id) - } - terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) - return nil + guard Self.normalizedForegroundAuthToken(remoteConfiguration.foregroundAuthToken) == foregroundAuthToken else { + return } - surfaceIdToPanelId[newTabId] = newPanel.id + pendingRemoteForegroundAuthToken = nil + guard remoteConnectionState == .disconnected else { return } + reconnectRemoteConnection() + } - // bonsplit'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) - newPanel.focus() - applyTabSelection(tabId: newTabId, inPane: paneId) - } else { - preserveFocusAfterNonFocusSplit( - preferredPanelId: previousFocusedPanelId, - splitPanelId: newPanel.id, - previousHostedView: previousHostedView - ) + func disconnectRemoteConnection(clearConfiguration: Bool = false) { + let shouldCleanupControlMaster = + clearConfiguration + && !isDetachingCloseTransaction + && pendingDetachedSurfaces.isEmpty + && !skipControlMasterCleanupAfterDetachedRemoteTransfer + let configurationForCleanup = shouldCleanupControlMaster ? remoteConfiguration : nil + resetRemoteState() + remoteConnectionState = .disconnected + if clearConfiguration { + remoteConfiguration = nil + skipControlMasterCleanupAfterDetachedRemoteTransfer = false + } + applyRemoteProxyEndpointUpdate(nil) + applyBrowserRemoteWorkspaceStatusToPanels() + recomputeListeningPorts() + if let configurationForCleanup { + Self.requestSSHControlMasterCleanupIfNeeded(configuration: configurationForCleanup) } + } - owningTabManager?.scheduleInitialWorkspaceGitMetadataRefreshIfPossible( - workspaceId: id, - panelId: newPanel.id, - reason: "surfaceCreate" - ) - return newPanel + /// Resets all per-connection and per-panel remote-session bookkeeping. This is the + /// complete union of what `configureRemoteConnection` and `disconnectRemoteConnection` + /// each need to clear before establishing (or tearing down) a remote connection, so a + /// reconfigure to a new destination can't leave stale state from the previous one. Refs #83. + func resetRemoteState() { + skipControlMasterCleanupAfterDetachedRemoteTransfer = false + let previousController = remoteSessionController + activeRemoteSessionControllerID = nil + remoteSessionController = nil + previousController?.stop() + pendingRemoteForegroundAuthToken = nil + activeRemoteTerminalSurfaceIds.removeAll() + activeRemoteTerminalSessionCount = 0 + pendingRemoteSurfaceTTYName = nil + pendingRemoteSurfaceTTYSurfaceId = nil + pendingRemoteSurfacePortKickReason = nil + pendingRemoteSurfacePortKickSurfaceId = nil + clearRemoteDetectedSurfacePorts() + remoteDetectedPorts = [] + remoteForwardedPorts = [] + remotePortConflicts = [] + remoteProxyEndpoint = nil + remoteHeartbeatCount = 0 + remoteLastHeartbeatAt = nil + remoteConnectionDetail = nil + remoteDaemonStatus = WorkspaceRemoteDaemonStatus() + statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) + statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey) + remoteLastErrorFingerprint = nil + remoteLastDaemonErrorFingerprint = nil + remoteLastPortConflictFingerprint = nil + } + + func clearRemoteConfigurationIfWorkspaceBecameLocal() { + guard !isDetachingCloseTransaction, panels.isEmpty, remoteConfiguration != nil else { return } + disconnectRemoteConnection(clearConfiguration: true) + } + + func seedInitialRemoteTerminalSessionIfNeeded(configuration: WorkspaceRemoteConfiguration) { + guard configuration.terminalStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + return + } + guard activeRemoteTerminalSurfaceIds.isEmpty else { return } + let terminalIds = panels.compactMap { panelId, panel in + panel is TerminalPanel ? panelId : nil + } + guard terminalIds.count == 1, let initialPanelId = terminalIds.first else { return } + trackRemoteTerminalSurface(initialPanelId) + } + + func trackRemoteTerminalSurface(_ panelId: UUID) { + skipControlMasterCleanupAfterDetachedRemoteTransfer = false + pendingRemoteTerminalChildExitSurfaceIds.remove(panelId) + transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: panelId) + guard activeRemoteTerminalSurfaceIds.insert(panelId).inserted else { return } + activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count + applyPendingRemoteSurfaceTTYIfNeeded(to: panelId) + _ = applyPendingRemoteSurfacePortKickIfNeeded(to: panelId) } - private func remoteTerminalStartupCommand() -> String? { - guard let command = remoteConfiguration?.terminalStartupCommand? - .trimmingCharacters(in: .whitespacesAndNewlines), - !command.isEmpty else { - return nil - } - return command + func untrackRemoteTerminalSurface(_ panelId: UUID) { + guard activeRemoteTerminalSurfaceIds.remove(panelId) != nil else { return } + activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count + guard !isDetachingCloseTransaction else { return } + maybeDemoteRemoteWorkspaceAfterSSHSessionEnded() } - /// Create a new browser panel split - @discardableResult - func newBrowserSplit( - from panelId: UUID, - orientation: SplitOrientation, - insertFirst: Bool = false, - url: URL? = nil, - preferredProfileID: UUID? = nil, - 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 }) { - sourcePaneId = paneId - break + func maybeDemoteRemoteWorkspaceAfterSSHSessionEnded() { + guard activeRemoteTerminalSurfaceIds.isEmpty, remoteConfiguration != nil else { return } + let hasBrowserPanels = panels.values.contains { $0 is BrowserPanel } + if !hasBrowserPanels { + if remoteConnectionState == .error || remoteDaemonStatus.state == .error || remoteConnectionState == .connecting { + return } + disconnectRemoteConnection(clearConfiguration: true) } + } - guard let paneId = sourcePaneId else { return nil } - - // Create browser panel - let browserPanel = BrowserPanel( - workspaceId: id, - profileID: resolvedNewBrowserProfileID( - preferredProfileID: preferredProfileID, - sourcePanelId: panelId - ), - initialURL: url, - proxyEndpoint: remoteProxyEndpoint, - isRemoteWorkspace: isRemoteWorkspace, - remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil - ) - panels[browserPanel.id] = browserPanel - panelTitles[browserPanel.id] = browserPanel.displayTitle + @MainActor + func rememberPendingRemoteSurfaceTTY(_ ttyName: String, requestedSurfaceId: UUID?) { + let trimmedTTY = ttyName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedTTY.isEmpty else { return } + pendingRemoteSurfaceTTYName = trimmedTTY + pendingRemoteSurfaceTTYSurfaceId = requestedSurfaceId + } - // 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 + @MainActor + func rememberPendingRemoteSurfacePortKick( + reason: WorkspaceRemoteSessionController.PortScanKickReason, + requestedSurfaceId: UUID? + ) { + pendingRemoteSurfacePortKickReason = reason + pendingRemoteSurfacePortKickSurfaceId = requestedSurfaceId + } - // 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) - panels.removeValue(forKey: browserPanel.id) - panelTitles.removeValue(forKey: browserPanel.id) - return nil + @MainActor + func applyPendingRemoteSurfaceTTYIfNeeded(to panelId: UUID) { + guard let ttyName = pendingRemoteSurfaceTTYName?.trimmingCharacters(in: .whitespacesAndNewlines), + !ttyName.isEmpty else { + return } - setPreferredBrowserProfileID(browserPanel.profileID) - - // See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting. - let previousHostedView = focusedTerminalPanel?.hostedView - 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 - ) + if let requestedSurfaceId = pendingRemoteSurfaceTTYSurfaceId, requestedSurfaceId != panelId { + return + } + surfaceTTYNames[panelId] = ttyName + pendingRemoteSurfaceTTYName = nil + pendingRemoteSurfaceTTYSurfaceId = nil + syncRemotePortScanTTYs() + if !applyPendingRemoteSurfacePortKickIfNeeded(to: panelId) { + kickRemotePortScan(panelId: panelId, reason: .command) } - - installBrowserPanelSubscription(browserPanel) - browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) - - return browserPanel } - /// Create a new browser surface in the specified pane. - /// - Parameter focus: nil = focus only if the target pane is already focused (default UI behavior), - /// true = force focus/selection of the new surface, - /// false = never focus (used for internal placeholder repair paths). + @MainActor @discardableResult - func newBrowserSurface( - inPane paneId: PaneID, - url: URL? = nil, - focus: Bool? = nil, - insertAtEnd: Bool = false, - preferredProfileID: UUID? = nil, - bypassInsecureHTTPHostOnce: String? = nil - ) -> BrowserPanel? { - let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) - let sourcePanelId = effectiveSelectedPanelId(inPane: paneId) - let previousFocusedPanelId = focusedPanelId - let previousHostedView = focusedTerminalPanel?.hostedView + func applyPendingRemoteSurfacePortKickIfNeeded(to panelId: UUID) -> Bool { + guard let reason = pendingRemoteSurfacePortKickReason else { + return false + } + if let requestedSurfaceId = pendingRemoteSurfacePortKickSurfaceId, + requestedSurfaceId != panelId { + return false + } + guard let ttyName = surfaceTTYNames[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines), + !ttyName.isEmpty else { + return false + } + _ = ttyName + pendingRemoteSurfacePortKickReason = nil + pendingRemoteSurfacePortKickSurfaceId = nil + kickRemotePortScan(panelId: panelId, reason: reason) + return true + } - let browserPanel = BrowserPanel( - workspaceId: id, - profileID: resolvedNewBrowserProfileID( - preferredProfileID: preferredProfileID, - sourcePanelId: sourcePanelId - ), - initialURL: url, - bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce, - proxyEndpoint: remoteProxyEndpoint, - isRemoteWorkspace: isRemoteWorkspace, - remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil - ) - panels[browserPanel.id] = browserPanel - panelTitles[browserPanel.id] = browserPanel.displayTitle + @MainActor + func applyBootstrapRemoteTTY(_ ttyName: String) { + let trimmedTTY = ttyName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedTTY.isEmpty else { return } - guard let newTabId = bonsplitController.createTab( - title: browserPanel.displayTitle, - icon: browserPanel.displayIcon, - kind: SurfaceKind.browser, - isDirty: browserPanel.isDirty, - isLoading: browserPanel.isLoading, - isPinned: false, - inPane: paneId - ) else { - panels.removeValue(forKey: browserPanel.id) - panelTitles.removeValue(forKey: browserPanel.id) + let candidateSurfaceId: UUID? = { + if let focusedPanelId, activeRemoteTerminalSurfaceIds.contains(focusedPanelId) { + return focusedPanelId + } + if activeRemoteTerminalSurfaceIds.count == 1 { + return activeRemoteTerminalSurfaceIds.first + } 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) + guard let candidateSurfaceId else { + rememberPendingRemoteSurfaceTTY(trimmedTTY, requestedSurfaceId: nil) + return } - // Match terminal behavior: enforce deterministic selection + focus. - if shouldFocusNewTab { - bonsplitController.focusPane(paneId) - bonsplitController.selectTab(newTabId) - browserPanel.focus() - applyTabSelection(tabId: newTabId, inPane: paneId) - } else { - preserveFocusAfterNonFocusSplit( - preferredPanelId: previousFocusedPanelId, - splitPanelId: browserPanel.id, - previousHostedView: previousHostedView - ) + surfaceTTYNames[candidateSurfaceId] = trimmedTTY + syncRemotePortScanTTYs() + if !applyPendingRemoteSurfacePortKickIfNeeded(to: candidateSurfaceId) { + kickRemotePortScan(panelId: candidateSurfaceId, reason: .command) } + } - installBrowserPanelSubscription(browserPanel) - browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) - - return browserPanel + func cleanupTransferredRemoteConnectionIfNeeded(surfaceId: UUID, relayPort: Int?) -> Bool { + guard let relayPort, + relayPort > 0, + let cleanupConfiguration = transferredRemoteCleanupConfigurationsByPanelId[surfaceId], + cleanupConfiguration.relayPort == relayPort else { + return false + } + transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: surfaceId) + Self.requestSSHControlMasterCleanupIfNeeded(configuration: cleanupConfiguration) + return true } - func newMarkdownSplit( - from panelId: UUID, - orientation: SplitOrientation, - insertFirst: Bool = false, - 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 }) { - sourcePaneId = paneId - break - } + func markRemoteTerminalSessionEnded(surfaceId: UUID, relayPort: Int?) { + if cleanupTransferredRemoteConnectionIfNeeded(surfaceId: surfaceId, relayPort: relayPort) { + return + } + guard let relayPort, + relayPort > 0, + remoteConfiguration?.relayPort == relayPort else { + return } + pendingRemoteTerminalChildExitSurfaceIds.insert(surfaceId) + untrackRemoteTerminalSurface(surfaceId) + } - guard let paneId = sourcePaneId else { return nil } - - 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 + func teardownRemoteConnection() { + disconnectRemoteConnection(clearConfiguration: true) + } - isProgrammaticSplit = true - defer { isProgrammaticSplit = false } - guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { - surfaceIdToPanelId.removeValue(forKey: newTab.id) - panels.removeValue(forKey: markdownPanel.id) - panelTitles.removeValue(forKey: markdownPanel.id) - return nil + static func requestSSHControlMasterCleanupIfNeeded(configuration: WorkspaceRemoteConfiguration) { + guard let arguments = sshControlMasterCleanupArguments(configuration: configuration) else { return } + if let override = runSSHControlMasterCommandOverrideForTesting { + override(arguments) + return } - let previousHostedView = focusedTerminalPanel?.hostedView - if focus { - previousHostedView?.suppressReparentFocus() - focusPanel(markdownPanel.id) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() + sshControlMasterCleanupQueue.async { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = arguments + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + let exitSemaphore = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in + exitSemaphore.signal() + } + + do { + try process.run() + if exitSemaphore.wait(timeout: .now() + 5) == .timedOut { + if process.isRunning { + process.terminate() + } + _ = exitSemaphore.wait(timeout: .now() + 1) + } + } catch { + return } - } else { - preserveFocusAfterNonFocusSplit( - preferredPanelId: previousFocusedPanelId, - splitPanelId: markdownPanel.id, - previousHostedView: previousHostedView - ) } + } - installMarkdownPanelSubscription(markdownPanel) - return markdownPanel + static func sshControlMasterCleanupArguments(configuration: WorkspaceRemoteConfiguration) -> [String]? { + let sshOptions = normalizedSSHControlCleanupOptions(configuration.sshOptions) + var arguments: [String] = [ + "-o", "BatchMode=yes", + "-o", "ControlMaster=no", + ] + if let port = configuration.port { + arguments += ["-p", String(port)] + } + if let identityFile = configuration.identityFile?.trimmingCharacters(in: .whitespacesAndNewlines), + !identityFile.isEmpty { + arguments += ["-i", identityFile] + } + for option in sshOptions { + arguments += ["-o", option] + } + arguments += ["-O", "exit", configuration.destination] + return arguments } - @discardableResult - func newMarkdownSurface( - inPane paneId: PaneID, - filePath: String, - focus: Bool? = nil - ) -> MarkdownPanel? { - let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) - let previousFocusedPanelId = focusedPanelId - let previousHostedView = focusedTerminalPanel?.hostedView + static func normalizedSSHControlCleanupOptions(_ options: [String]) -> [String] { + let disallowedKeys: Set = ["controlmaster", "controlpersist"] + return options.compactMap { option in + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard let key = sshOptionKeyForControlCleanup(trimmed) else { return nil } + return disallowedKeys.contains(key) ? nil : trimmed + } + } - let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath) - panels[markdownPanel.id] = markdownPanel - panelTitles[markdownPanel.id] = markdownPanel.displayTitle + static func sshOptionKeyForControlCleanup(_ option: String) -> String? { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) + .first + .map(String.init)? + .lowercased() + } - guard let newTabId = bonsplitController.createTab( - title: markdownPanel.displayTitle, - icon: markdownPanel.displayIcon, - kind: SurfaceKind.markdown, - isDirty: markdownPanel.isDirty, - isLoading: false, - isPinned: false, - inPane: paneId - ) else { - panels.removeValue(forKey: markdownPanel.id) - panelTitles.removeValue(forKey: markdownPanel.id) - return nil + func applyRemoteConnectionStateUpdate( + _ state: WorkspaceRemoteConnectionState, + detail: String?, + target: String + ) { + let trimmedDetail = detail?.trimmingCharacters(in: .whitespacesAndNewlines) + let proxyOnlyError = trimmedDetail.map(Self.isProxyOnlyRemoteError) ?? false + let preserveConnectedStateForRetry = + state == .connecting && preservesSSHTerminalConnection && hasProxyOnlyRemoteSidebarError + let effectiveState: WorkspaceRemoteConnectionState + if state == .error && proxyOnlyError && preservesSSHTerminalConnection { + effectiveState = .connected + } else if preserveConnectedStateForRetry { + effectiveState = .connected + } else { + effectiveState = state } - surfaceIdToPanelId[newTabId] = markdownPanel.id - if shouldFocusNewTab { - bonsplitController.focusPane(paneId) - bonsplitController.selectTab(newTabId) - applyTabSelection(tabId: newTabId, inPane: paneId) - } else { - preserveFocusAfterNonFocusSplit( - preferredPanelId: previousFocusedPanelId, - splitPanelId: markdownPanel.id, - previousHostedView: previousHostedView + remoteConnectionState = effectiveState + remoteConnectionDetail = detail + applyBrowserRemoteWorkspaceStatusToPanels() + + if let trimmedDetail, !trimmedDetail.isEmpty, (state == .error || proxyOnlyError) { + let statusPrefix = proxyOnlyError ? "Remote proxy unavailable" : "SSH error" + let statusIcon = proxyOnlyError ? "exclamationmark.triangle.fill" : "network.slash" + let notificationTitle = proxyOnlyError ? "Remote Proxy Unavailable" : "Remote SSH Error" + let logSource = proxyOnlyError ? "remote-proxy" : "remote" + statusEntries[Self.remoteErrorStatusKey] = SidebarStatusEntry( + key: Self.remoteErrorStatusKey, + value: "\(statusPrefix) (\(target)): \(trimmedDetail)", + icon: statusIcon, + color: nil, + timestamp: Date() ) + + let fingerprint = "connection:\(trimmedDetail)" + if remoteLastErrorFingerprint != fingerprint { + remoteLastErrorFingerprint = fingerprint + appendSidebarLog( + message: "\(statusPrefix) (\(target)): \(trimmedDetail)", + level: .error, + source: logSource + ) + AppDelegate.shared?.notificationStore?.addNotification( + tabId: id, + surfaceId: nil, + title: notificationTitle, + subtitle: target, + body: trimmedDetail, + cooldownKey: remoteNotificationCooldownKey(target: target), + cooldownInterval: Self.remoteNotificationCooldown + ) + } + return } - installMarkdownPanelSubscription(markdownPanel) - return markdownPanel + if state == .connected { + statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) + remoteLastErrorFingerprint = nil + } } - /// Tear down all panels in this workspace, freeing their Ghostty surfaces. - /// Called before the workspace is removed from TabManager to ensure child - /// processes receive SIGHUP even if ARC deallocation is delayed. - func teardownAllPanels() { - let panelEntries = Array(panels) - for (panelId, panel) in panelEntries { - panelSubscriptions.removeValue(forKey: panelId) - PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) - panel.close() + func applyRemoteDaemonStatusUpdate(_ status: WorkspaceRemoteDaemonStatus, target: String) { + remoteDaemonStatus = status + applyBrowserRemoteWorkspaceStatusToPanels() + guard status.state == .error else { + remoteLastDaemonErrorFingerprint = nil + return } + let trimmedDetail = status.detail?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "remote daemon error" + let fingerprint = "daemon:\(trimmedDetail)" + guard remoteLastDaemonErrorFingerprint != fingerprint else { return } + remoteLastDaemonErrorFingerprint = fingerprint + appendSidebarLog( + message: "Remote daemon error (\(target)): \(trimmedDetail)", + level: .error, + source: "remote-daemon" + ) + } - panels.removeAll(keepingCapacity: false) - surfaceIdToPanelId.removeAll(keepingCapacity: false) - panelSubscriptions.removeAll(keepingCapacity: false) - pendingRemoteTerminalChildExitSurfaceIds.removeAll(keepingCapacity: false) - pruneSurfaceMetadata(validSurfaceIds: []) - restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) - terminalInheritanceFontPointsByPanelId.removeAll(keepingCapacity: false) - lastTerminalConfigInheritancePanelId = nil - lastTerminalConfigInheritanceFontPoints = nil + func applyRemoteProxyEndpointUpdate(_ endpoint: BrowserProxyEndpoint?) { + remoteProxyEndpoint = endpoint + for panel in panels.values { + guard let browserPanel = panel as? BrowserPanel else { continue } + browserPanel.setRemoteProxyEndpoint(endpoint) + } + applyBrowserRemoteWorkspaceStatusToPanels() + } + + func applyRemoteHeartbeatUpdate(count: Int, lastSeenAt: Date?) { + remoteHeartbeatCount = max(0, count) + remoteLastHeartbeatAt = lastSeenAt + applyBrowserRemoteWorkspaceStatusToPanels() } - /// Close a panel. - /// Returns true when a bonsplit tab close request was issued. - func closePanel(_ panelId: UUID, force: Bool = false) -> Bool { - if let tabId = surfaceIdFromPanelId(panelId) { - if force { - forceCloseTabIds.insert(tabId) + func applyRemoteDetectedSurfacePortsSnapshot( + detectedByPanel: [UUID: [Int]], + detected: [Int], + forwarded: [Int], + conflicts: [Int], + target: String + ) { + let trackedSurfaceIds = Set(detectedByPanel.keys) + for panelId in remoteDetectedSurfaceIds.subtracting(trackedSurfaceIds) { + surfaceListeningPorts.removeValue(forKey: panelId) + } + remoteDetectedSurfaceIds = trackedSurfaceIds + + for (panelId, ports) in detectedByPanel { + if ports.isEmpty { + surfaceListeningPorts.removeValue(forKey: panelId) + } else { + surfaceListeningPorts[panelId] = ports } - // Close the tab in bonsplit (this triggers delegate callback) - return bonsplitController.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. - 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 { -#if DEBUG - 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")" - ) -#endif - return false - } + remoteDetectedPorts = detected + remoteForwardedPorts = forwarded + remotePortConflicts = conflicts + recomputeListeningPorts() - if force { - forceCloseTabIds.insert(selected.id) + if conflicts.isEmpty { + statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey) + remoteLastPortConflictFingerprint = nil + return } - let closed = bonsplitController.closeTab(selected.id) -#if DEBUG - dlog( - "surface.close.fallback panel=\(panelId.uuidString.prefix(5)) " + - "selectedTab=\(String(describing: selected.id).prefix(5)) " + - "closed=\(closed ? 1 : 0)" + + let conflictsList = conflicts.map { ":\($0)" }.joined(separator: ", ") + statusEntries[Self.remotePortConflictStatusKey] = SidebarStatusEntry( + key: Self.remotePortConflictStatusKey, + value: "SSH port conflicts (\(target)): \(conflictsList)", + icon: "exclamationmark.triangle.fill", + color: nil, + timestamp: Date() + ) + + let fingerprint = conflicts.map(String.init).joined(separator: ",") + guard remoteLastPortConflictFingerprint != fingerprint else { return } + remoteLastPortConflictFingerprint = fingerprint + appendSidebarLog( + message: "Port conflicts while forwarding \(target): \(conflictsList)", + level: .warning, + source: "remote-forward" ) -#endif - return closed } - 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 }) + func clearRemoteDetectedSurfacePorts() { + for panelId in remoteDetectedSurfaceIds { + surfaceListeningPorts.removeValue(forKey: panelId) } + remoteDetectedSurfaceIds.removeAll() } - 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 }) + func appendSidebarLog(message: String, level: SidebarLogLevel, source: String?) { + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + logEntries.append(SidebarLogEntry(message: trimmed, level: level, source: source, timestamp: Date())) + let configuredLimit = UserDefaults.standard.object(forKey: "sidebarMaxLogEntries") as? Int ?? 50 + let limit = max(1, min(500, configuredLimit)) + if logEntries.count > limit { + logEntries.removeFirst(logEntries.count - limit) + } } - /// Returns the nearest right-side sibling pane for browser placement. - /// The search is local to the source pane's ancestry in the split tree: - /// use the closest horizontal ancestor where the source is in the first (left) branch. - func preferredBrowserTargetPane(fromPanelId panelId: UUID) -> PaneID? { - guard let sourcePane = paneId(forPanelId: panelId) else { return nil } - let sourcePaneId = sourcePane.id.uuidString - let tree = bonsplitController.treeSnapshot() - guard let path = browserPathToPane(targetPaneId: sourcePaneId, node: tree) else { return nil } - - let layout = bonsplitController.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 - let sourceRightX = sourceFrame.map { $0.x + $0.width } ?? 0 - - for crumb in path { - guard crumb.split.orientation == "horizontal", crumb.branch == .first else { continue } - var candidateNodes: [ExternalPaneNode] = [] - browserCollectPaneNodes(node: crumb.split.second, into: &candidateNodes) - if candidateNodes.isEmpty { continue } - - let sorted = candidateNodes.sorted { lhs, rhs in - let lhsDy = abs((lhs.frame.y + (lhs.frame.height * 0.5)) - sourceCenterY) - let rhsDy = abs((rhs.frame.y + (rhs.frame.height * 0.5)) - sourceCenterY) - if lhsDy != rhsDy { return lhsDy < rhsDy } + // MARK: - Panel Operations - let lhsDx = abs(lhs.frame.x - sourceRightX) - let rhsDx = abs(rhs.frame.x - sourceRightX) - if lhsDx != rhsDx { return lhsDx < rhsDx } + func seedTerminalInheritanceFontPoints( + panelId: UUID, + configTemplate: ProgramaSurfaceConfigTemplate? + ) { + guard let fontPoints = configTemplate?.fontSize, fontPoints > 0 else { return } + terminalInheritanceFontPointsByPanelId[panelId] = fontPoints + lastTerminalConfigInheritanceFontPoints = fontPoints + } - if lhs.frame.x != rhs.frame.x { return lhs.frame.x < rhs.frame.x } - return lhs.id < rhs.id + func resolvedTerminalInheritanceFontPoints( + for terminalPanel: TerminalPanel, + sourceSurface: ghostty_surface_t, + inheritedConfig: ProgramaSurfaceConfigTemplate + ) -> Float? { + let runtimePoints = programaCurrentSurfaceFontSizePoints(sourceSurface) + if let rooted = terminalInheritanceFontPointsByPanelId[terminalPanel.id], rooted > 0 { + if let runtimePoints, abs(runtimePoints - rooted) > 0.05 { + // Runtime zoom changed after lineage was seeded (manual zoom on descendant); + // treat runtime as the new root for future descendants. + return runtimePoints } + return rooted + } + if inheritedConfig.fontSize > 0 { + return inheritedConfig.fontSize + } + return runtimePoints + } - for candidate in sorted { - guard let candidateUUID = UUID(uuidString: candidate.id), - candidateUUID != sourcePane.id, - let pane = bonsplitController.allPaneIds.first(where: { $0.id == candidateUUID }) else { - continue - } - return pane + func rememberTerminalConfigInheritanceSource(_ terminalPanel: TerminalPanel) { + lastTerminalConfigInheritancePanelId = terminalPanel.id + if terminalPanel.surface.isSurfaceLive, + let sourceSurface = terminalPanel.surface.surface, + let runtimePoints = programaCurrentSurfaceFontSizePoints(sourceSurface) { + let existing = terminalInheritanceFontPointsByPanelId[terminalPanel.id] + if existing == nil || abs((existing ?? runtimePoints) - runtimePoints) > 0.05 { + terminalInheritanceFontPointsByPanelId[terminalPanel.id] = runtimePoints } + lastTerminalConfigInheritanceFontPoints = + terminalInheritanceFontPointsByPanelId[terminalPanel.id] ?? runtimePoints } + } - return nil + func lastRememberedTerminalPanelForConfigInheritance() -> TerminalPanel? { + guard let panelId = lastTerminalConfigInheritancePanelId else { return nil } + return terminalPanel(for: panelId) } - /// Returns the top-right pane in the current split tree. - /// 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 - guard paneIds.count > 1 else { return nil } + func lastRememberedTerminalFontPointsForConfigInheritance() -> Float? { + lastTerminalConfigInheritanceFontPoints + } - let paneById = Dictionary(uniqueKeysWithValues: paneIds.map { ($0.id.uuidString, $0) }) - var paneBounds: [String: CGRect] = [:] - browserCollectNormalizedPaneBounds( - node: bonsplitController.treeSnapshot(), - availableRect: CGRect(x: 0, y: 0, width: 1, height: 1), - into: &paneBounds - ) + /// Candidate terminal panels used as the source when creating inherited Ghostty config. + /// Preference order: + /// 1) explicitly preferred terminal panel (when the caller has one), + /// 2) selected terminal in the target pane, + /// 3) currently focused terminal in the workspace, + /// 4) last remembered terminal source, + /// 5) first terminal tab in the target pane, + /// 6) deterministic workspace fallback. + func terminalPanelConfigInheritanceCandidates( + preferredPanelId: UUID? = nil, + inPane preferredPaneId: PaneID? = nil + ) -> [TerminalPanel] { + var candidates: [TerminalPanel] = [] + var seen: Set = [] - guard !paneBounds.isEmpty else { - return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first + func appendCandidate(_ panel: TerminalPanel?) { + guard let panel, seen.insert(panel.id).inserted else { return } + candidates.append(panel) } - let epsilon = 0.000_1 - let rightMostX = paneBounds.values.map(\.maxX).max() ?? 0 - - let sortedCandidates = paneBounds - .filter { _, rect in abs(rect.maxX - rightMostX) <= epsilon } - .sorted { lhs, rhs in - if abs(lhs.value.minY - rhs.value.minY) > epsilon { - return lhs.value.minY < rhs.value.minY - } - if abs(lhs.value.minX - rhs.value.minX) > epsilon { - return lhs.value.minX > rhs.value.minX - } - return lhs.key < rhs.key - } - - for candidate in sortedCandidates { - if let pane = paneById[candidate.key] { - return pane - } + if let preferredPanelId, + let terminalPanel = terminalPanel(for: preferredPanelId) { + appendCandidate(terminalPanel) } - return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first - } + if let preferredPaneId, + let selectedSurfaceId = bonsplitController.selectedTab(inPane: preferredPaneId)?.id, + let selectedPanelId = panelIdFromSurfaceId(selectedSurfaceId), + let selectedTerminalPanel = terminalPanel(for: selectedPanelId) { + appendCandidate(selectedTerminalPanel) + } - private enum BrowserPaneBranch { - case first - case second - } + if let focusedTerminalPanel { + appendCandidate(focusedTerminalPanel) + } - private struct BrowserPaneBreadcrumb { - let split: ExternalSplitNode - let branch: BrowserPaneBranch - } + if let rememberedTerminalPanel = lastRememberedTerminalPanelForConfigInheritance() { + appendCandidate(rememberedTerminalPanel) + } - private func browserPathToPane(targetPaneId: String, node: ExternalTreeNode) -> [BrowserPaneBreadcrumb]? { - switch node { - case .pane(let paneNode): - return paneNode.id == targetPaneId ? [] : nil - case .split(let splitNode): - if var path = browserPathToPane(targetPaneId: targetPaneId, node: splitNode.first) { - path.append(BrowserPaneBreadcrumb(split: splitNode, branch: .first)) - return path - } - if var path = browserPathToPane(targetPaneId: targetPaneId, node: splitNode.second) { - path.append(BrowserPaneBreadcrumb(split: splitNode, branch: .second)) - return path + if let preferredPaneId { + for tab in bonsplitController.tabs(inPane: preferredPaneId) { + guard let panelId = panelIdFromSurfaceId(tab.id), + let terminalPanel = terminalPanel(for: panelId) else { continue } + appendCandidate(terminalPanel) } - return nil } - } - private func browserCollectPaneNodes(node: ExternalTreeNode, into output: inout [ExternalPaneNode]) { - switch node { - case .pane(let paneNode): - output.append(paneNode) - case .split(let splitNode): - browserCollectPaneNodes(node: splitNode.first, into: &output) - browserCollectPaneNodes(node: splitNode.second, into: &output) + for terminalPanel in panels.values + .compactMap({ $0 as? TerminalPanel }) + .sorted(by: { $0.id.uuidString < $1.id.uuidString }) { + appendCandidate(terminalPanel) } + + return candidates } - private func browserCollectNormalizedPaneBounds( - node: ExternalTreeNode, - availableRect: CGRect, - into output: inout [String: CGRect] - ) { - switch node { - case .pane(let paneNode): - output[paneNode.id] = availableRect - case .split(let splitNode): - let divider = min(max(splitNode.dividerPosition, 0), 1) - let firstRect: CGRect - let secondRect: CGRect + /// Picks the first terminal panel candidate used as the inheritance source. + func terminalPanelForConfigInheritance( + preferredPanelId: UUID? = nil, + inPane preferredPaneId: PaneID? = nil + ) -> TerminalPanel? { + terminalPanelConfigInheritanceCandidates( + preferredPanelId: preferredPanelId, + inPane: preferredPaneId + ).first + } - if splitNode.orientation.lowercased() == "vertical" { - // Stacked split: first = top, second = bottom - firstRect = CGRect( - x: availableRect.minX, - y: availableRect.minY, - width: availableRect.width, - height: availableRect.height * divider - ) - secondRect = CGRect( - x: availableRect.minX, - y: availableRect.minY + (availableRect.height * divider), - width: availableRect.width, - height: availableRect.height * (1 - divider) - ) - } else { - // Side-by-side split: first = left, second = right - firstRect = CGRect( - x: availableRect.minX, - y: availableRect.minY, - width: availableRect.width * divider, - height: availableRect.height - ) - secondRect = CGRect( - x: availableRect.minX + (availableRect.width * divider), - y: availableRect.minY, - width: availableRect.width * (1 - divider), - height: availableRect.height - ) + func inheritedTerminalConfig( + preferredPanelId: UUID? = nil, + inPane preferredPaneId: PaneID? = nil + ) -> ProgramaSurfaceConfigTemplate? { + // Walk candidates in priority order and use the first panel that still exposes + // a runtime surface pointer. + for terminalPanel in terminalPanelConfigInheritanceCandidates( + preferredPanelId: preferredPanelId, + inPane: preferredPaneId + ) { + // Pin the panel and its TerminalSurface wrapper for the duration of + // this iteration. The raw ghostty_surface_t extracted below is owned + // by `surface` (the TerminalSurface) — ARC must not release it while + // ghostty_surface_inherited_config or programaCurrentSurfaceFontSizePoints + // is still reading through the pointer. + let surface = terminalPanel.surface + guard let sourceSurface = surface.surface else { continue } + var config = programaInheritedSurfaceConfig( + sourceSurface: sourceSurface, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT + ) + if let rootedFontPoints = resolvedTerminalInheritanceFontPoints( + for: terminalPanel, + sourceSurface: sourceSurface, + inheritedConfig: config + ), rootedFontPoints > 0 { + config.fontSize = rootedFontPoints + terminalInheritanceFontPointsByPanelId[terminalPanel.id] = rootedFontPoints + } + // Prevent ARC from releasing panel/surface before the C calls above complete. + withExtendedLifetime((terminalPanel, surface)) {} + rememberTerminalConfigInheritanceSource(terminalPanel) + if config.fontSize > 0 { + lastTerminalConfigInheritanceFontPoints = config.fontSize } + return config + } - browserCollectNormalizedPaneBounds(node: splitNode.first, availableRect: firstRect, into: &output) - browserCollectNormalizedPaneBounds(node: splitNode.second, availableRect: secondRect, into: &output) + if let fallbackFontPoints = lastTerminalConfigInheritanceFontPoints { + var config = ProgramaSurfaceConfigTemplate() + config.fontSize = fallbackFontPoints +#if DEBUG + dlog( + "zoom.inherit fallback=lastKnownFont context=split font=\(String(format: "%.2f", fallbackFontPoints))" + ) +#endif + return config } - } - private struct BrowserCloseFallbackPlan { - let orientation: SplitOrientation - let insertFirst: Bool - let anchorPaneId: UUID? + return nil } - private func stageClosedBrowserRestoreSnapshotIfNeeded(for tab: Bonsplit.Tab, inPane pane: PaneID) { - guard let panelId = panelIdFromSurfaceId(tab.id), - let browserPanel = browserPanel(for: panelId), - let tabIndex = bonsplitController.tabs(inPane: pane).firstIndex(where: { $0.id == tab.id }) else { - pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tab.id) - return + /// Create a new split with a terminal panel + @discardableResult + func newTerminalSplit( + from panelId: UUID, + orientation: SplitOrientation, + insertFirst: Bool = false, + focus: Bool = true + ) -> TerminalPanel? { + // 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 }) { + sourcePaneId = paneId + break + } } - let fallbackPlan = browserCloseFallbackPlan( - forPaneId: pane.id.uuidString, - in: bonsplitController.treeSnapshot() + guard let paneId = sourcePaneId else { return nil } + let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) + let remoteTerminalStartupCommand = remoteTerminalStartupCommand() + + // Inherit working directory: prefer the source panel's reported cwd, + // 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), + !panelDirectory.isEmpty { + return panelDirectory + } + if let requestedWorkingDirectory = terminalPanel(for: panelId)? + .requestedWorkingDirectory? + .trimmingCharacters(in: .whitespacesAndNewlines), + !requestedWorkingDirectory.isEmpty { + return requestedWorkingDirectory + } + let workspaceDirectory = currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + return workspaceDirectory.isEmpty ? nil : workspaceDirectory + }() +#if DEBUG + dlog( + "split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") requestedDir=\(terminalPanel(for: panelId)?.requestedWorkingDirectory ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")" ) - let resolvedURL = browserPanel.currentURL - ?? browserPanel.preferredURLStringForOmnibar().flatMap(URL.init(string:)) +#endif - pendingClosedBrowserRestoreSnapshots[tab.id] = ClosedBrowserPanelRestoreSnapshot( + // Create the new terminal panel. + let newPanel = TerminalPanel( workspaceId: id, - url: resolvedURL, - profileID: browserPanel.profileID, - originalPaneId: pane.id, - originalTabIndex: tabIndex, - fallbackSplitOrientation: fallbackPlan?.orientation, - fallbackSplitInsertFirst: fallbackPlan?.insertFirst ?? false, - fallbackAnchorPaneId: fallbackPlan?.anchorPaneId + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: inheritedConfig, + workingDirectory: splitWorkingDirectory, + portOrdinal: portOrdinal, + initialCommand: remoteTerminalStartupCommand ) - } - - private func clearStagedClosedBrowserRestoreSnapshot(for tabId: TabID) { - pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tabId) - } + configureTerminalPanel(newPanel) + panels[newPanel.id] = newPanel + panelTitles[newPanel.id] = newPanel.displayTitle + if remoteTerminalStartupCommand != nil { + trackRemoteTerminalSurface(newPanel.id) + } + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) - private func browserCloseFallbackPlan( - forPaneId targetPaneId: String, - in node: ExternalTreeNode - ) -> BrowserCloseFallbackPlan? { - switch node { - case .pane: - return nil - case .split(let splitNode): - if case .pane(let firstPane) = splitNode.first, firstPane.id == targetPaneId { - return BrowserCloseFallbackPlan( - orientation: splitNode.orientation.lowercased() == "vertical" ? .vertical : .horizontal, - insertFirst: true, - anchorPaneId: browserNearestPaneId( - in: splitNode.second, - targetCenter: browserPaneCenter(firstPane) - ) - ) - } + // 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 - if case .pane(let secondPane) = splitNode.second, secondPane.id == targetPaneId { - return BrowserCloseFallbackPlan( - orientation: splitNode.orientation.lowercased() == "vertical" ? .vertical : .horizontal, - insertFirst: false, - anchorPaneId: browserNearestPaneId( - in: splitNode.first, - targetCenter: browserPaneCenter(secondPane) - ) - ) - } + // Capture the source terminal's hosted view before bonsplit mutates focusedPaneId, + // so we can hand it to focusPanel as the "move focus FROM" view. + let previousHostedView = focusedTerminalPanel?.hostedView - if let nested = browserCloseFallbackPlan(forPaneId: targetPaneId, in: splitNode.first) { - return nested + // 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 { + panels.removeValue(forKey: newPanel.id) + panelTitles.removeValue(forKey: newPanel.id) + surfaceIdToPanelId.removeValue(forKey: newTab.id) + if remoteTerminalStartupCommand != nil { + untrackRemoteTerminalSurface(newPanel.id) } - return browserCloseFallbackPlan(forPaneId: targetPaneId, in: splitNode.second) - } - } - - private func browserPaneCenter(_ pane: ExternalPaneNode) -> (x: Double, y: Double) { - ( - x: pane.frame.x + (pane.frame.width * 0.5), - y: pane.frame.y + (pane.frame.height * 0.5) - ) - } + terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) + return nil + } - private func browserNearestPaneId( - in node: ExternalTreeNode, - targetCenter: (x: Double, y: Double)? - ) -> UUID? { - var panes: [ExternalPaneNode] = [] - browserCollectPaneNodes(node: node, into: &panes) - guard !panes.isEmpty else { return nil } +#if DEBUG + dlog("split.created pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation)") +#endif - let bestPane: ExternalPaneNode? - if let targetCenter { - bestPane = panes.min { lhs, rhs in - let lhsCenter = browserPaneCenter(lhs) - let rhsCenter = browserPaneCenter(rhs) - let lhsDistance = pow(lhsCenter.x - targetCenter.x, 2) + pow(lhsCenter.y - targetCenter.y, 2) - let rhsDistance = pow(rhsCenter.x - targetCenter.x, 2) + pow(rhsCenter.y - targetCenter.y, 2) - if lhsDistance != rhsDistance { - return lhsDistance < rhsDistance - } - return lhs.id < rhs.id + // Suppress the old view's becomeFirstResponder side-effects during SwiftUI reparenting. + // Without this, reparenting triggers onFocus + ghostty_surface_set_focus on the old view, + // stealing focus from the new panel and creating model/surface divergence. + if focus { + previousHostedView?.suppressReparentFocus() + focusPanel(newPanel.id, previousHostedView: previousHostedView) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + previousHostedView?.clearSuppressReparentFocus() } } else { - bestPane = panes.first + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: newPanel.id, + previousHostedView: previousHostedView + ) } - guard let bestPane else { return nil } - return UUID(uuidString: bestPane.id) + owningTabManager?.scheduleInitialWorkspaceGitMetadataRefreshIfPossible( + workspaceId: id, + panelId: newPanel.id, + reason: "splitCreate" + ) + + return newPanel } + /// Create a new surface (nested tab) in the specified pane with a terminal panel. + /// - Parameter focus: nil = focus only if the target pane is already focused (default UI behavior), + /// true = force focus/selection of the new surface, + /// false = never focus (used for internal placeholder repair paths). @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 } + func newTerminalSurface( + inPane paneId: PaneID, + focus: Bool? = nil, + workingDirectory: String? = nil, + startupEnvironment: [String: String] = [:] + ) -> TerminalPanel? { + let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) + let previousFocusedPanelId = focusedPanelId + let previousHostedView = focusedTerminalPanel?.hostedView - if focus { - bonsplitController.focusPane(paneId) - bonsplitController.selectTab(tabId) - focusPanel(panelId) - } else { - scheduleFocusReconcile() + let inheritedConfig = inheritedTerminalConfig(inPane: paneId) + let remoteTerminalStartupCommand = remoteTerminalStartupCommand() + + // Create new terminal panel + let newPanel = TerminalPanel( + workspaceId: id, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: inheritedConfig, + workingDirectory: workingDirectory, + portOrdinal: portOrdinal, + initialCommand: remoteTerminalStartupCommand, + additionalEnvironment: startupEnvironment + ) + configureTerminalPanel(newPanel) + panels[newPanel.id] = newPanel + panelTitles[newPanel.id] = newPanel.displayTitle + if remoteTerminalStartupCommand != nil { + trackRemoteTerminalSurface(newPanel.id) } - scheduleTerminalGeometryReconcile() - return true - } + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) - @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 } + // Create tab in bonsplit + guard let newTabId = bonsplitController.createTab( + title: newPanel.displayTitle, + icon: newPanel.displayIcon, + kind: SurfaceKind.terminal, + isDirty: newPanel.isDirty, + isPinned: false, + inPane: paneId + ) else { + panels.removeValue(forKey: newPanel.id) + panelTitles.removeValue(forKey: newPanel.id) + if remoteTerminalStartupCommand != nil { + untrackRemoteTerminalSurface(newPanel.id) + } + terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) + return nil + } - if let paneId = paneId(forPanelId: panelId) { - applyTabSelection(tabId: tabId, inPane: paneId) + surfaceIdToPanelId[newTabId] = newPanel.id + + // bonsplit'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) + newPanel.focus() + applyTabSelection(tabId: newTabId, inPane: paneId) } else { - scheduleFocusReconcile() + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: newPanel.id, + previousHostedView: previousHostedView + ) } - scheduleTerminalGeometryReconcile() - return true - } - func detachSurface(panelId: UUID) -> DetachedSurfaceTransfer? { - guard let tabId = surfaceIdFromPanelId(panelId) else { return nil } - guard panels[panelId] != nil else { return nil } - let shouldSkipControlMasterCleanupAfterDetach = - activeRemoteTerminalSurfaceIds.contains(panelId) - && activeRemoteTerminalSurfaceIds.count == 1 -#if DEBUG - let detachStart = ProcessInfo.processInfo.systemUptime - 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)" + owningTabManager?.scheduleInitialWorkspaceGitMetadataRefreshIfPossible( + workspaceId: id, + panelId: newPanel.id, + reason: "surfaceCreate" ) -#endif + return newPanel + } - detachingTabIds.insert(tabId) - forceCloseTabIds.insert(tabId) - activeDetachCloseTransactions += 1 - defer { activeDetachCloseTransactions = max(0, activeDetachCloseTransactions - 1) } - guard bonsplitController.closeTab(tabId) else { - detachingTabIds.remove(tabId) - pendingDetachedSurfaces.removeValue(forKey: tabId) - forceCloseTabIds.remove(tabId) -#if DEBUG - 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))" - ) -#endif + func remoteTerminalStartupCommand() -> String? { + guard let command = remoteConfiguration?.terminalStartupCommand? + .trimmingCharacters(in: .whitespacesAndNewlines), + !command.isEmpty else { return nil } + return command + } - var detached = pendingDetachedSurfaces.removeValue(forKey: tabId) - if shouldSkipControlMasterCleanupAfterDetach, let detachedTransfer = detached, detachedTransfer.isRemoteTerminal { - skipControlMasterCleanupAfterDetachedRemoteTransfer = true - if detachedTransfer.remoteCleanupConfiguration == nil { - detached = detachedTransfer.withRemoteCleanupConfiguration(remoteConfiguration) + /// Create a new browser panel split + @discardableResult + func newBrowserSplit( + from panelId: UUID, + orientation: SplitOrientation, + insertFirst: Bool = false, + url: URL? = nil, + preferredProfileID: UUID? = nil, + 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 }) { + sourcePaneId = paneId + break } } -#if DEBUG - 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))" + + guard let paneId = sourcePaneId else { return nil } + + // Create browser panel + let browserPanel = BrowserPanel( + workspaceId: id, + profileID: resolvedNewBrowserProfileID( + preferredProfileID: preferredProfileID, + sourcePanelId: panelId + ), + initialURL: url, + proxyEndpoint: remoteProxyEndpoint, + isRemoteWorkspace: isRemoteWorkspace, + remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil ) -#endif - return detached - } + panels[browserPanel.id] = browserPanel + panelTitles[browserPanel.id] = browserPanel.displayTitle - @discardableResult - func attachDetachedSurface( - _ detached: DetachedSurfaceTransfer, - inPane paneId: PaneID, - atIndex index: Int? = nil, - focus: Bool = true - ) -> UUID? { -#if DEBUG - let attachStart = ProcessInfo.processInfo.systemUptime - 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)" + // 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 ) -#endif - guard bonsplitController.allPaneIds.contains(paneId) else { -#if DEBUG - dlog( - "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + - "reason=invalidPane elapsedMs=\(debugElapsedMs(since: attachStart))" - ) -#endif - return nil - } - guard panels[detached.panelId] == nil else { -#if DEBUG - dlog( - "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + - "reason=panelExists elapsedMs=\(debugElapsedMs(since: attachStart))" - ) -#endif + surfaceIdToPanelId[newTab.id] = browserPanel.id + let previousFocusedPanelId = focusedPanelId + + // 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) + panels.removeValue(forKey: browserPanel.id) + panelTitles.removeValue(forKey: browserPanel.id) return nil } + setPreferredBrowserProfileID(browserPanel.profileID) - panels[detached.panelId] = detached.panel - if let terminalPanel = detached.panel as? TerminalPanel { - terminalPanel.updateWorkspaceId(id) - } else if let browserPanel = detached.panel as? BrowserPanel { - browserPanel.reattachToWorkspace( - id, - isRemoteWorkspace: isRemoteWorkspace, - remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil, - proxyEndpoint: remoteProxyEndpoint, - remoteStatus: browserRemoteWorkspaceStatusSnapshot() + // See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting. + let previousHostedView = focusedTerminalPanel?.hostedView + 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) } - if let directory = detached.directory { - panelDirectories[detached.panelId] = directory - } - if let ttyName = detached.ttyName?.trimmingCharacters(in: .whitespacesAndNewlines), !ttyName.isEmpty { - surfaceTTYNames[detached.panelId] = ttyName - } else { - surfaceTTYNames.removeValue(forKey: detached.panelId) - } - 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) - } + installBrowserPanelSubscription(browserPanel) + browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) + + return browserPanel + } + + /// Create a new browser surface in the specified pane. + /// - Parameter focus: nil = focus only if the target pane is already focused (default UI behavior), + /// true = force focus/selection of the new surface, + /// false = never focus (used for internal placeholder repair paths). + @discardableResult + func newBrowserSurface( + inPane paneId: PaneID, + url: URL? = nil, + focus: Bool? = nil, + insertAtEnd: Bool = false, + preferredProfileID: UUID? = nil, + bypassInsecureHTTPHostOnce: String? = nil + ) -> BrowserPanel? { + let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) + let sourcePanelId = effectiveSelectedPanelId(inPane: paneId) + let previousFocusedPanelId = focusedPanelId + let previousHostedView = focusedTerminalPanel?.hostedView + + let browserPanel = BrowserPanel( + workspaceId: id, + profileID: resolvedNewBrowserProfileID( + preferredProfileID: preferredProfileID, + sourcePanelId: sourcePanelId + ), + initialURL: url, + bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce, + proxyEndpoint: remoteProxyEndpoint, + isRemoteWorkspace: isRemoteWorkspace, + remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil + ) + panels[browserPanel.id] = browserPanel + panelTitles[browserPanel.id] = browserPanel.displayTitle guard let newTabId = bonsplitController.createTab( - 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, + title: browserPanel.displayTitle, + icon: browserPanel.displayIcon, + kind: SurfaceKind.browser, + isDirty: browserPanel.isDirty, + isLoading: browserPanel.isLoading, + isPinned: false, inPane: paneId ) else { - panels.removeValue(forKey: detached.panelId) - panelDirectories.removeValue(forKey: detached.panelId) - surfaceTTYNames.removeValue(forKey: 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 - dlog( - "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + - "reason=createTabFailed elapsedMs=\(debugElapsedMs(since: attachStart))" - ) -#endif + panels.removeValue(forKey: browserPanel.id) + panelTitles.removeValue(forKey: browserPanel.id) return nil } - surfaceIdToPanelId[newTabId] = detached.panelId - let didAdoptWorkspaceRemoteTracking = - detached.isRemoteTerminal - && detached.remoteRelayPort == remoteConfiguration?.relayPort - if didAdoptWorkspaceRemoteTracking { - trackRemoteTerminalSurface(detached.panelId) - } - if let cleanupConfiguration = detached.remoteCleanupConfiguration { - if didAdoptWorkspaceRemoteTracking { - transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: detached.panelId) - } else { - transferredRemoteCleanupConfigurationsByPanelId[detached.panelId] = cleanupConfiguration - } - } else { - transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: detached.panelId) - } - if let index { - _ = bonsplitController.reorderTab(newTabId, toIndex: index) + 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) } - syncPinnedStateForTab(newTabId, panelId: detached.panelId) - syncUnreadBadgeStateForPanel(detached.panelId) - normalizePinnedTabs(in: paneId) - if focus { + // Match terminal behavior: enforce deterministic selection + focus. + if shouldFocusNewTab { bonsplitController.focusPane(paneId) bonsplitController.selectTab(newTabId) - detached.panel.focus() + browserPanel.focus() applyTabSelection(tabId: newTabId, inPane: paneId) } else { - scheduleFocusReconcile() + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: browserPanel.id, + previousHostedView: previousHostedView + ) } - scheduleTerminalGeometryReconcile() -#if DEBUG - 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) " + - "elapsedMs=\(debugElapsedMs(since: attachStart))" - ) -#endif - return detached.panelId + installBrowserPanelSubscription(browserPanel) + browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) + + return browserPanel } - // MARK: - Focus Management - private func preserveFocusAfterNonFocusSplit( - preferredPanelId: UUID?, - splitPanelId: UUID, - previousHostedView: GhosttySurfaceScrollView? - ) { - guard let preferredPanelId, panels[preferredPanelId] != nil else { - clearNonFocusSplitFocusReassert() - scheduleFocusReconcile() - return + func newMarkdownSplit( + from panelId: UUID, + orientation: SplitOrientation, + insertFirst: Bool = false, + 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 }) { + sourcePaneId = paneId + break + } } - let generation = beginNonFocusSplitFocusReassert( - preferredPanelId: preferredPanelId, - splitPanelId: splitPanelId - ) + guard let paneId = sourcePaneId else { return nil } - // 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 - ) + let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath) + panels[markdownPanel.id] = markdownPanel + panelTitles[markdownPanel.id] = markdownPanel.displayTitle - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.reassertFocusAfterNonFocusSplit( - generation: generation, - preferredPanelId: preferredPanelId, - splitPanelId: splitPanelId, - previousHostedView: previousHostedView, - allowPreviousHostedView: false - ) + 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 - 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) + isProgrammaticSplit = true + defer { isProgrammaticSplit = false } + guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { + surfaceIdToPanelId.removeValue(forKey: newTab.id) + panels.removeValue(forKey: markdownPanel.id) + panelTitles.removeValue(forKey: markdownPanel.id) + return nil + } + + let previousHostedView = focusedTerminalPanel?.hostedView + 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) + return markdownPanel } - private func reassertFocusAfterNonFocusSplit( - generation: UInt64, - preferredPanelId: UUID, - splitPanelId: UUID, - previousHostedView: GhosttySurfaceScrollView?, - allowPreviousHostedView: Bool - ) { - guard matchesPendingNonFocusSplitFocusReassert( - generation: generation, - preferredPanelId: preferredPanelId, - splitPanelId: splitPanelId - ) else { - return - } + @discardableResult + func newMarkdownSurface( + inPane paneId: PaneID, + filePath: String, + focus: Bool? = nil + ) -> MarkdownPanel? { + let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) + let previousFocusedPanelId = focusedPanelId + let previousHostedView = focusedTerminalPanel?.hostedView - guard panels[preferredPanelId] != nil else { - clearNonFocusSplitFocusReassert(generation: generation) - return + let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath) + panels[markdownPanel.id] = markdownPanel + panelTitles[markdownPanel.id] = markdownPanel.displayTitle + + guard let newTabId = bonsplitController.createTab( + title: markdownPanel.displayTitle, + icon: markdownPanel.displayIcon, + kind: SurfaceKind.markdown, + isDirty: markdownPanel.isDirty, + isLoading: false, + isPinned: false, + inPane: paneId + ) else { + panels.removeValue(forKey: markdownPanel.id) + panelTitles.removeValue(forKey: markdownPanel.id) + return nil } - if focusedPanelId == splitPanelId { - focusPanel( - preferredPanelId, - previousHostedView: allowPreviousHostedView ? previousHostedView : nil + surfaceIdToPanelId[newTabId] = markdownPanel.id + if shouldFocusNewTab { + bonsplitController.focusPane(paneId) + bonsplitController.selectTab(newTabId) + applyTabSelection(tabId: newTabId, inPane: paneId) + } else { + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: markdownPanel.id, + previousHostedView: previousHostedView ) - return } - guard focusedPanelId == preferredPanelId, - let terminalPanel = terminalPanel(for: preferredPanelId) else { - return - } - terminalPanel.hostedView.ensureFocus(for: id, surfaceId: preferredPanelId) + installMarkdownPanelSubscription(markdownPanel) + return markdownPanel } - 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 triggerLabel = trigger == .terminalFirstResponder ? "firstResponder" : "standard" - dlog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane) trigger=\(triggerLabel)") - FocusLogStore.shared.append( - "Workspace.focusPanel panelId=\(panelId.uuidString) focusedPane=\(pane) trigger=\(triggerLabel)" - ) -#endif - guard let tabId = surfaceIdFromPanelId(panelId) else { return } - let currentlyFocusedPanelId = focusedPanelId + /// Tear down all panels in this workspace, freeing their Ghostty surfaces. + /// Called before the workspace is removed from TabManager to ensure child + /// processes receive SIGHUP even if ARC deallocation is delayed. + func teardownAllPanels() { + let panelEntries = Array(panels) + for (panelId, panel) in panelEntries { + panelSubscriptions.removeValue(forKey: panelId) + PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) + panel.close() + } - // Capture the currently focused terminal view so we can explicitly move AppKit first - // 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. - let previousTerminalHostedView = previousHostedView ?? focusedTerminalPanel?.hostedView + panels.removeAll(keepingCapacity: false) + surfaceIdToPanelId.removeAll(keepingCapacity: false) + panelSubscriptions.removeAll(keepingCapacity: false) + pendingRemoteTerminalChildExitSurfaceIds.removeAll(keepingCapacity: false) + pruneSurfaceMetadata(validSurfaceIds: []) + restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) + terminalInheritanceFontPointsByPanelId.removeAll(keepingCapacity: false) + lastTerminalConfigInheritancePanelId = nil + lastTerminalConfigInheritanceFontPoints = nil + } - // `selectTab` does not necessarily move bonsplit'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 selectionAlreadyConverged: Bool = { - guard let targetPaneId else { return false } - return bonsplitController.focusedPaneId == targetPaneId && - bonsplitController.selectedTab(inPane: targetPaneId)?.id == 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 } - .map { String($0.uuid.uuidString.prefix(5)) } ?? "nil" - let currentPanelShort = currentlyFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - dlog( - "focus.panel.begin workspace=\(id.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) trigger=\(String(describing: trigger)) " + - "targetPane=\(targetPaneShort) focusedPane=\(focusedPaneShort) selectedTab=\(selectedTabShort) " + - "converged=\(selectionAlreadyConverged ? 1 : 0) " + - "currentPanel=\(currentPanelShort)" - ) - if shouldSuppressReentrantRefocus { - dlog( - "focus.panel.skipReentrant panel=\(panelId.uuidString.prefix(5)) " + - "reason=firstResponderAlreadyConverged" - ) + /// Close a panel. + /// Returns true when a bonsplit 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) } -#endif - if let targetPaneId, !selectionAlreadyConverged { + // 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. + 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 { #if DEBUG dlog( - "focus.panel.focusPane workspace=\(id.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) pane=\(targetPaneId.id.uuidString.prefix(5))" + "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")" ) #endif - bonsplitController.focusPane(targetPaneId) + return false } - if !selectionAlreadyConverged { + if force { + forceCloseTabIds.insert(selected.id) + } + let closed = bonsplitController.closeTab(selected.id) #if DEBUG - dlog( - "focus.panel.selectTab workspace=\(id.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) tab=\(tabId.uuid.uuidString.prefix(5))" - ) + dlog( + "surface.close.fallback panel=\(panelId.uuidString.prefix(5)) " + + "selectedTab=\(String(describing: selected.id).prefix(5)) " + + "closed=\(closed ? 1 : 0)" + ) #endif - bonsplitController.selectTab(tabId) - } - - if let targetPaneId { - let activationIntent = panels[panelId]?.preferredFocusIntentForActivation() - applyTabSelection( - tabId: tabId, - inPane: targetPaneId, - reassertAppKitFocus: !shouldSuppressReentrantRefocus, - focusIntent: activationIntent, - previousTerminalHostedView: previousTerminalHostedView - ) - } + return closed + } - if let browserPanel = panels[panelId] as? BrowserPanel { - maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger) + 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 }) } + } - if trigger == .terminalFirstResponder, - panels[panelId] is TerminalPanel { - beginEventDrivenLayoutFollowUp( - reason: "workspace.focusPanel.terminal", - terminalFocusPanelId: panelId - ) - } + 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 }) } - private func maybeAutoFocusBrowserAddressBarOnPanelFocus( - _ browserPanel: BrowserPanel, - trigger: FocusPanelTrigger - ) { - guard trigger == .standard else { return } - guard !isCommandPaletteVisibleForWorkspaceWindow() else { return } - guard !browserPanel.shouldSuppressOmnibarAutofocus() else { return } - guard browserPanel.isShowingNewTabPage || browserPanel.preferredURLStringForOmnibar() == nil else { return } + /// Returns the nearest right-side sibling pane for browser placement. + /// The search is local to the source pane's ancestry in the split tree: + /// use the closest horizontal ancestor where the source is in the first (left) branch. + func preferredBrowserTargetPane(fromPanelId panelId: UUID) -> PaneID? { + guard let sourcePane = paneId(forPanelId: panelId) else { return nil } + let sourcePaneId = sourcePane.id.uuidString + let tree = bonsplitController.treeSnapshot() + guard let path = browserPathToPane(targetPaneId: sourcePaneId, node: tree) else { return nil } + + let layout = bonsplitController.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 + let sourceRightX = sourceFrame.map { $0.x + $0.width } ?? 0 + + for crumb in path { + guard crumb.split.orientation == "horizontal", crumb.branch == .first else { continue } + var candidateNodes: [ExternalPaneNode] = [] + browserCollectPaneNodes(node: crumb.split.second, into: &candidateNodes) + if candidateNodes.isEmpty { continue } + + let sorted = candidateNodes.sorted { lhs, rhs in + let lhsDy = abs((lhs.frame.y + (lhs.frame.height * 0.5)) - sourceCenterY) + let rhsDy = abs((rhs.frame.y + (rhs.frame.height * 0.5)) - sourceCenterY) + if lhsDy != rhsDy { return lhsDy < rhsDy } - _ = browserPanel.requestAddressBarFocus() - NotificationCenter.default.post(name: .browserFocusAddressBar, object: browserPanel.id) - } + let lhsDx = abs(lhs.frame.x - sourceRightX) + let rhsDx = abs(rhs.frame.x - sourceRightX) + if lhsDx != rhsDx { return lhsDx < rhsDx } - private func isCommandPaletteVisibleForWorkspaceWindow() -> Bool { - guard let app = AppDelegate.shared else { - return false - } + if lhs.frame.x != rhs.frame.x { return lhs.frame.x < rhs.frame.x } + return lhs.id < rhs.id + } - if let manager = app.tabManagerFor(tabId: id), - let windowId = app.windowId(for: manager), - let window = app.mainWindow(for: windowId), - app.isCommandPaletteVisible(for: window) { - return true + for candidate in sorted { + guard let candidateUUID = UUID(uuidString: candidate.id), + candidateUUID != sourcePane.id, + let pane = bonsplitController.allPaneIds.first(where: { $0.id == candidateUUID }) else { + continue + } + return pane + } } - if let keyWindow = NSApp.keyWindow, app.isCommandPaletteVisible(for: keyWindow) { - return true - } - if let mainWindow = NSApp.mainWindow, app.isCommandPaletteVisible(for: mainWindow) { - return true - } - return false + return nil } - func moveFocus(direction: NavigationDirection) { - // If a pane is zoomed, un-zoom before navigating so the target - // pane becomes visible — matches tmux behavior (#1605). - if bonsplitController.isSplitZoomed { - _ = clearSplitZoom(reason: "workspace.moveFocus") - } - let previousFocusedPanelId = focusedPanelId + /// Returns the top-right pane in the current split tree. + /// 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 + guard paneIds.count > 1 else { return nil } - // Unfocus the currently-focused panel before navigating. - if let prevPanelId = previousFocusedPanelId, let prev = panels[prevPanelId] { - prev.unfocus() + let paneById = Dictionary(uniqueKeysWithValues: paneIds.map { ($0.id.uuidString, $0) }) + var paneBounds: [String: CGRect] = [:] + browserCollectNormalizedPaneBounds( + node: bonsplitController.treeSnapshot(), + availableRect: CGRect(x: 0, y: 0, width: 1, height: 1), + into: &paneBounds + ) + + guard !paneBounds.isEmpty else { + return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first } - bonsplitController.navigateFocus(direction: direction) + let epsilon = 0.000_1 + let rightMostX = paneBounds.values.map(\.maxX).max() ?? 0 - // 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 { - applyTabSelection(tabId: tabId, inPane: paneId) + let sortedCandidates = paneBounds + .filter { _, rect in abs(rect.maxX - rightMostX) <= epsilon } + .sorted { lhs, rhs in + if abs(lhs.value.minY - rhs.value.minY) > epsilon { + return lhs.value.minY < rhs.value.minY + } + if abs(lhs.value.minX - rhs.value.minX) > epsilon { + return lhs.value.minX > rhs.value.minX + } + return lhs.key < rhs.key + } + + for candidate in sortedCandidates { + if let pane = paneById[candidate.key] { + return pane + } } + return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first } - // MARK: - Surface Navigation + enum BrowserPaneBranch { + case first + case second + } - /// Select the next surface in the currently focused pane - func selectNextSurface() { - bonsplitController.selectNextTab() + struct BrowserPaneBreadcrumb { + let split: ExternalSplitNode + let branch: BrowserPaneBranch + } - if let paneId = bonsplitController.focusedPaneId, - let tabId = bonsplitController.selectedTab(inPane: paneId)?.id { - applyTabSelection(tabId: tabId, inPane: paneId) + func browserPathToPane(targetPaneId: String, node: ExternalTreeNode) -> [BrowserPaneBreadcrumb]? { + switch node { + case .pane(let paneNode): + return paneNode.id == targetPaneId ? [] : nil + case .split(let splitNode): + if var path = browserPathToPane(targetPaneId: targetPaneId, node: splitNode.first) { + path.append(BrowserPaneBreadcrumb(split: splitNode, branch: .first)) + return path + } + if var path = browserPathToPane(targetPaneId: targetPaneId, node: splitNode.second) { + path.append(BrowserPaneBreadcrumb(split: splitNode, branch: .second)) + return path + } + return nil } } - /// Select the previous surface in the currently focused pane - func selectPreviousSurface() { - bonsplitController.selectPreviousTab() - - if let paneId = bonsplitController.focusedPaneId, - let tabId = bonsplitController.selectedTab(inPane: paneId)?.id { - applyTabSelection(tabId: tabId, inPane: paneId) + func browserCollectPaneNodes(node: ExternalTreeNode, into output: inout [ExternalPaneNode]) { + switch node { + case .pane(let paneNode): + output.append(paneNode) + case .split(let splitNode): + browserCollectPaneNodes(node: splitNode.first, into: &output) + browserCollectPaneNodes(node: splitNode.second, into: &output) } } - /// 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) + func browserCollectNormalizedPaneBounds( + node: ExternalTreeNode, + availableRect: CGRect, + into output: inout [String: CGRect] + ) { + switch node { + case .pane(let paneNode): + output[paneNode.id] = availableRect + case .split(let splitNode): + let divider = min(max(splitNode.dividerPosition, 0), 1) + let firstRect: CGRect + let secondRect: CGRect - if let tabId = bonsplitController.selectedTab(inPane: focusedPaneId)?.id { - applyTabSelection(tabId: tabId, inPane: focusedPaneId) + if splitNode.orientation.lowercased() == "vertical" { + // Stacked split: first = top, second = bottom + firstRect = CGRect( + x: availableRect.minX, + y: availableRect.minY, + width: availableRect.width, + height: availableRect.height * divider + ) + secondRect = CGRect( + x: availableRect.minX, + y: availableRect.minY + (availableRect.height * divider), + width: availableRect.width, + height: availableRect.height * (1 - divider) + ) + } else { + // Side-by-side split: first = left, second = right + firstRect = CGRect( + x: availableRect.minX, + y: availableRect.minY, + width: availableRect.width * divider, + height: availableRect.height + ) + secondRect = CGRect( + x: availableRect.minX + (availableRect.width * divider), + y: availableRect.minY, + width: availableRect.width * (1 - divider), + height: availableRect.height + ) + } + + browserCollectNormalizedPaneBounds(node: splitNode.first, availableRect: firstRect, into: &output) + browserCollectNormalizedPaneBounds(node: splitNode.second, availableRect: secondRect, into: &output) } } - /// 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) + struct BrowserCloseFallbackPlan { + let orientation: SplitOrientation + let insertFirst: Bool + let anchorPaneId: UUID? + } - if let tabId = bonsplitController.selectedTab(inPane: focusedPaneId)?.id { - applyTabSelection(tabId: tabId, inPane: focusedPaneId) + func stageClosedBrowserRestoreSnapshotIfNeeded(for tab: Bonsplit.Tab, inPane pane: PaneID) { + guard let panelId = panelIdFromSurfaceId(tab.id), + let browserPanel = browserPanel(for: panelId), + let tabIndex = bonsplitController.tabs(inPane: pane).firstIndex(where: { $0.id == tab.id }) else { + pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tab.id) + return } + + let fallbackPlan = browserCloseFallbackPlan( + forPaneId: pane.id.uuidString, + in: bonsplitController.treeSnapshot() + ) + let resolvedURL = browserPanel.currentURL + ?? browserPanel.preferredURLStringForOmnibar().flatMap(URL.init(string:)) + + pendingClosedBrowserRestoreSnapshots[tab.id] = ClosedBrowserPanelRestoreSnapshot( + workspaceId: id, + url: resolvedURL, + profileID: browserPanel.profileID, + originalPaneId: pane.id, + originalTabIndex: tabIndex, + fallbackSplitOrientation: fallbackPlan?.orientation, + fallbackSplitInsertFirst: fallbackPlan?.insertFirst ?? false, + fallbackAnchorPaneId: fallbackPlan?.anchorPaneId + ) } - /// Create a new terminal surface in the currently focused pane - @discardableResult - func newTerminalSurfaceInFocusedPane(focus: Bool? = nil) -> TerminalPanel? { - guard let focusedPaneId = bonsplitController.focusedPaneId else { return nil } - return newTerminalSurface(inPane: focusedPaneId, focus: focus) + func clearStagedClosedBrowserRestoreSnapshot(for tabId: TabID) { + pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tabId) } - @discardableResult - func clearSplitZoom(reason: String = "workspace.clearSplitZoom") -> Bool { - // Capture the zoomed pane's browser panel (if any) before clearing zoom, - // so we can prime its portal host replacement afterward. - let zoomedBrowser: (paneId: PaneID, panel: BrowserPanel)? = { - guard let zoomedPaneId = bonsplitController.zoomedPaneId, - let tabId = bonsplitController.selectedTab(inPane: zoomedPaneId)?.id, - let panelId = panelIdFromSurfaceId(tabId), - let browser = browserPanel(for: panelId) else { return nil } - return (zoomedPaneId, browser) - }() + func browserCloseFallbackPlan( + forPaneId targetPaneId: String, + in node: ExternalTreeNode + ) -> BrowserCloseFallbackPlan? { + switch node { + case .pane: + return nil + case .split(let splitNode): + if case .pane(let firstPane) = splitNode.first, firstPane.id == targetPaneId { + return BrowserCloseFallbackPlan( + orientation: splitNode.orientation.lowercased() == "vertical" ? .vertical : .horizontal, + insertFirst: true, + anchorPaneId: browserNearestPaneId( + in: splitNode.second, + targetCenter: browserPaneCenter(firstPane) + ) + ) + } + + if case .pane(let secondPane) = splitNode.second, secondPane.id == targetPaneId { + return BrowserCloseFallbackPlan( + orientation: splitNode.orientation.lowercased() == "vertical" ? .vertical : .horizontal, + insertFirst: false, + anchorPaneId: browserNearestPaneId( + in: splitNode.first, + targetCenter: browserPaneCenter(secondPane) + ) + ) + } - guard bonsplitController.clearPaneZoom() else { return false } - if let zoomedBrowser { - zoomedBrowser.panel.preparePortalHostReplacementForNextDistinctClaim( - inPane: zoomedBrowser.paneId, - reason: reason - ) + if let nested = browserCloseFallbackPlan(forPaneId: targetPaneId, in: splitNode.first) { + return nested + } + return browserCloseFallbackPlan(forPaneId: targetPaneId, in: splitNode.second) } - reconcileTerminalPortalVisibilityForCurrentRenderedLayout() - reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: reason) - beginEventDrivenLayoutFollowUp(reason: reason, includeGeometry: true) - return true } - @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 } - focusPanel(panelId) - if !bonsplitController.isSplitZoomed { - // Un-zooming: use centralized reconciliation - 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 + func browserPaneCenter(_ pane: ExternalPaneNode) -> (x: Double, y: Double) { + ( + x: pane.frame.x + (pane.frame.width * 0.5), + y: pane.frame.y + (pane.frame.height * 0.5) ) - return true } - // MARK: - Context Menu Shortcuts + func browserNearestPaneId( + in node: ExternalTreeNode, + targetCenter: (x: Double, y: Double)? + ) -> UUID? { + var panes: [ExternalPaneNode] = [] + browserCollectPaneNodes(node: node, into: &panes) + guard !panes.isEmpty else { return nil } - static func buildContextMenuShortcuts() -> [TabContextAction: KeyboardShortcut] { - var shortcuts: [TabContextAction: KeyboardShortcut] = [:] - let mappings: [(TabContextAction, KeyboardShortcutSettings.Action)] = [ - (.rename, .renameTab), - (.toggleZoom, .toggleSplitZoom), - (.newTerminalToRight, .newSurface), - ] - for (contextAction, settingsAction) in mappings { - let stored = KeyboardShortcutSettings.shortcut(for: settingsAction) - if let key = stored.keyEquivalent { - shortcuts[contextAction] = KeyboardShortcut(key, modifiers: stored.eventModifiers) + let bestPane: ExternalPaneNode? + if let targetCenter { + bestPane = panes.min { lhs, rhs in + let lhsCenter = browserPaneCenter(lhs) + let rhsCenter = browserPaneCenter(rhs) + let lhsDistance = pow(lhsCenter.x - targetCenter.x, 2) + pow(lhsCenter.y - targetCenter.y, 2) + let rhsDistance = pow(rhsCenter.x - targetCenter.x, 2) + pow(rhsCenter.y - targetCenter.y, 2) + if lhsDistance != rhsDistance { + return lhsDistance < rhsDistance + } + return lhs.id < rhs.id } + } else { + bestPane = panes.first } - return shortcuts - } - - // MARK: - Flash/Notification Support - func triggerFocusFlash(panelId: UUID) { - requestAttentionFlash(panelId: panelId, reason: .navigation) + guard let bestPane else { return nil } + return UUID(uuidString: bestPane.id) } - func triggerNotificationFocusFlash( - panelId: UUID, - requiresSplit: Bool = false, - shouldFocus: Bool = true - ) { - guard terminalPanel(for: panelId) != nil else { return } - if shouldFocus { + @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 } + + if focus { + bonsplitController.focusPane(paneId) + bonsplitController.selectTab(tabId) focusPanel(panelId) + } else { + scheduleFocusReconcile() } - let isSplit = bonsplitController.allPaneIds.count > 1 || panels.count > 1 - if requiresSplit && !isSplit { - return - } - requestAttentionFlash(panelId: panelId, reason: .notificationArrival) + scheduleTerminalGeometryReconcile() + return true } - func triggerNotificationDismissFlash(panelId: UUID) { - guard terminalPanel(for: panelId) != nil else { return } - requestAttentionFlash(panelId: panelId, reason: .notificationDismiss) - } + @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 } - func triggerDebugFlash(panelId: UUID) { - guard panels[panelId] != nil else { return } - focusPanel(panelId) - requestAttentionFlash(panelId: panelId, reason: .debug) + if let paneId = paneId(forPanelId: panelId) { + applyTabSelection(tabId: tabId, inPane: paneId) + } else { + scheduleFocusReconcile() + } + scheduleTerminalGeometryReconcile() + return true } - // MARK: - Portal Lifecycle + func detachSurface(panelId: UUID) -> DetachedSurfaceTransfer? { + guard let tabId = surfaceIdFromPanelId(panelId) else { return nil } + guard panels[panelId] != nil else { return nil } + let shouldSkipControlMasterCleanupAfterDetach = + activeRemoteTerminalSurfaceIds.contains(panelId) + && activeRemoteTerminalSurfaceIds.count == 1 +#if DEBUG + let detachStart = ProcessInfo.processInfo.systemUptime + 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)" + ) +#endif - /// 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) + detachingTabIds.insert(tabId) + forceCloseTabIds.insert(tabId) + activeDetachCloseTransactions += 1 + defer { activeDetachCloseTransactions = max(0, activeDetachCloseTransactions - 1) } + guard bonsplitController.closeTab(tabId) else { + detachingTabIds.remove(tabId) + pendingDetachedSurfaces.removeValue(forKey: tabId) + forceCloseTabIds.remove(tabId) +#if DEBUG + 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))" + ) +#endif + return nil } - } - func hideAllBrowserPortalViews() { - for panel in panels.values { - guard let browser = panel as? BrowserPanel else { continue } - browser.hideBrowserPortalView(source: "workspaceRetire") + var detached = pendingDetachedSurfaces.removeValue(forKey: tabId) + if shouldSkipControlMasterCleanupAfterDetach, let detachedTransfer = detached, detachedTransfer.isRemoteTerminal { + skipControlMasterCleanupAfterDetachedRemoteTransfer = true + if detachedTransfer.remoteCleanupConfiguration == nil { + detached = detachedTransfer.withRemoteCleanupConfiguration(remoteConfiguration) + } } +#if DEBUG + 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))" + ) +#endif + return detached } - // MARK: - Utility - - /// Create a new terminal panel (used when replacing the last panel) @discardableResult - func createReplacementTerminalPanel() -> TerminalPanel { - let inheritedConfig = inheritedTerminalConfig( - preferredPanelId: focusedPanelId, - inPane: bonsplitController.focusedPaneId - ) - let newPanel = TerminalPanel( - workspaceId: id, - context: GHOSTTY_SURFACE_CONTEXT_TAB, - configTemplate: inheritedConfig, - portOrdinal: portOrdinal + func attachDetachedSurface( + _ detached: DetachedSurfaceTransfer, + inPane paneId: PaneID, + atIndex index: Int? = nil, + focus: Bool = true + ) -> UUID? { +#if DEBUG + let attachStart = ProcessInfo.processInfo.systemUptime + 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)" ) - configureTerminalPanel(newPanel) - panels[newPanel.id] = newPanel - panelTitles[newPanel.id] = newPanel.displayTitle - seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) +#endif + guard bonsplitController.allPaneIds.contains(paneId) else { +#if DEBUG + dlog( + "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + + "reason=invalidPane elapsedMs=\(debugElapsedMs(since: attachStart))" + ) +#endif + return nil + } + guard panels[detached.panelId] == nil else { +#if DEBUG + dlog( + "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + + "reason=panelExists elapsedMs=\(debugElapsedMs(since: attachStart))" + ) +#endif + return nil + } - // Create tab in bonsplit - if let newTabId = bonsplitController.createTab( - title: newPanel.displayTitle, - icon: newPanel.displayIcon, - kind: SurfaceKind.terminal, - isDirty: newPanel.isDirty, - isPinned: false - ) { - surfaceIdToPanelId[newTabId] = newPanel.id + panels[detached.panelId] = detached.panel + if let terminalPanel = detached.panel as? TerminalPanel { + terminalPanel.updateWorkspaceId(id) + } else if let browserPanel = detached.panel as? BrowserPanel { + browserPanel.reattachToWorkspace( + id, + isRemoteWorkspace: isRemoteWorkspace, + remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil, + proxyEndpoint: remoteProxyEndpoint, + remoteStatus: browserRemoteWorkspaceStatusSnapshot() + ) + installBrowserPanelSubscription(browserPanel) } - return newPanel - } - - /// Check if any panel needs close confirmation - func needsConfirmClose() -> Bool { - for (panelId, panel) in panels { - if let terminalPanel = panel as? TerminalPanel, - panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) { - return true - } + if let directory = detached.directory { + panelDirectories[detached.panelId] = directory } - return false - } - - private func reconcileFocusState() { - guard !isReconcilingFocusState else { return } - isReconcilingFocusState = true - defer { isReconcilingFocusState = false } - - // Source of truth: bonsplit 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), - panels[mappedPanelId] != nil { - targetPanelId = mappedPanelId + if let ttyName = detached.ttyName?.trimmingCharacters(in: .whitespacesAndNewlines), !ttyName.isEmpty { + surfaceTTYNames[detached.panelId] = ttyName } 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 - } + surfaceTTYNames.removeValue(forKey: detached.panelId) } - - 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) - } + syncRemotePortScanTTYs() + if let cachedTitle = detached.cachedTitle { + panelTitles[detached.panelId] = cachedTitle } - - guard let targetPanelId, let targetPanel = panels[targetPanelId] else { return } - - for (panelId, panel) in panels where panelId != targetPanelId { - panel.unfocus() + if let customTitle = detached.customTitle { + panelCustomTitles[detached.panelId] = customTitle } - - targetPanel.focus() - if let terminalPanel = targetPanel as? TerminalPanel { - terminalPanel.hostedView.ensureFocus(for: id, surfaceId: targetPanelId) + if detached.isPinned { + pinnedPanelIds.insert(detached.panelId) + } else { + pinnedPanelIds.remove(detached.panelId) } - if let dir = panelDirectories[targetPanelId] { - currentDirectory = dir + if detached.manuallyUnread { + manualUnreadPanelIds.insert(detached.panelId) + manualUnreadMarkedAt[detached.panelId] = .distantPast + } else { + manualUnreadPanelIds.remove(detached.panelId) + manualUnreadMarkedAt.removeValue(forKey: detached.panelId) } - 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() { + guard let newTabId = bonsplitController.createTab( + 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 + ) else { + panels.removeValue(forKey: detached.panelId) + panelDirectories.removeValue(forKey: detached.panelId) + surfaceTTYNames.removeValue(forKey: 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 - if isDetachingCloseTransaction { - debugFocusReconcileScheduledDuringDetachCount += 1 - } + dlog( + "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + + "reason=createTabFailed elapsedMs=\(debugElapsedMs(since: attachStart))" + ) #endif - guard !focusReconcileScheduled else { return } - focusReconcileScheduled = true - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.focusReconcileScheduled = false - self.reconcileFocusState() + return nil } - } - private func beginEventDrivenLayoutFollowUp( - reason: String, - browserPanelId: UUID? = nil, - browserExitFocusPanelId: UUID? = nil, - terminalFocusPanelId: UUID? = nil, - includeGeometry: Bool = false - ) { - layoutFollowUpReason = reason - if let browserPanelId { - layoutFollowUpBrowserPanelId = browserPanelId + surfaceIdToPanelId[newTabId] = detached.panelId + let didAdoptWorkspaceRemoteTracking = + detached.isRemoteTerminal + && detached.remoteRelayPort == remoteConfiguration?.relayPort + if didAdoptWorkspaceRemoteTracking { + trackRemoteTerminalSurface(detached.panelId) } - if let browserExitFocusPanelId { - layoutFollowUpBrowserExitFocusPanelId = browserExitFocusPanelId + if let cleanupConfiguration = detached.remoteCleanupConfiguration { + if didAdoptWorkspaceRemoteTracking { + transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: detached.panelId) + } else { + transferredRemoteCleanupConfigurationsByPanelId[detached.panelId] = cleanupConfiguration + } + } else { + transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: detached.panelId) } - if let terminalFocusPanelId { - layoutFollowUpTerminalFocusPanelId = terminalFocusPanelId + if let index { + _ = bonsplitController.reorderTab(newTabId, toIndex: index) } - 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 + syncPinnedStateForTab(newTabId, panelId: detached.panelId) + syncUnreadBadgeStateForPanel(detached.panelId) + normalizePinnedTabs(in: paneId) - if layoutFollowUpTimeoutWorkItem == nil { - installLayoutFollowUpObservers() + if focus { + bonsplitController.focusPane(paneId) + bonsplitController.selectTab(newTabId) + detached.panel.focus() + applyTabSelection(tabId: newTabId, inPane: paneId) + } else { + scheduleFocusReconcile() } - 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() - } + scheduleTerminalGeometryReconcile() - private func installLayoutFollowUpObservers() { - guard layoutFollowUpTimeoutWorkItem == nil else { return } +#if DEBUG + 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) " + + "elapsedMs=\(debugElapsedMs(since: attachStart))" + ) +#endif + return detached.panelId + } + // MARK: - Focus Management - let enqueueAttempt: () -> Void = { [weak self] in - self?.scheduleLayoutFollowUpAttempt() + func preserveFocusAfterNonFocusSplit( + preferredPanelId: UUID?, + splitPanelId: UUID, + previousHostedView: GhosttySurfaceScrollView? + ) { + guard let preferredPanelId, panels[preferredPanelId] != nil else { + clearNonFocusSplitFocusReassert() + scheduleFocusReconcile() + return } - 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() + 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 refreshLayoutFollowUpTimeout() { - layoutFollowUpTimeoutWorkItem?.cancel() - let workItem = DispatchWorkItem { [weak self] in - self?.clearLayoutFollowUp() + func reassertFocusAfterNonFocusSplit( + generation: UInt64, + preferredPanelId: UUID, + splitPanelId: UUID, + previousHostedView: GhosttySurfaceScrollView?, + allowPreviousHostedView: Bool + ) { + guard matchesPendingNonFocusSplitFocusReassert( + generation: generation, + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId + ) else { + return } - 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 - } + guard panels[preferredPanelId] != nil else { + clearNonFocusSplitFocusReassert(generation: generation) + return + } - private func scheduleLayoutFollowUpAttempt() { - guard layoutFollowUpTimeoutWorkItem != nil else { return } - guard !layoutFollowUpAttemptScheduled else { return } + if focusedPanelId == splitPanelId { + focusPanel( + preferredPanelId, + previousHostedView: allowPreviousHostedView ? previousHostedView : nil + ) + 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() + guard focusedPanelId == preferredPanelId, + let terminalPanel = terminalPanel(for: preferredPanelId) else { + return } + terminalPanel.hostedView.ensureFocus(for: id, surfaceId: preferredPanelId) } - 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))) - } + 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 triggerLabel = trigger == .terminalFirstResponder ? "firstResponder" : "standard" + dlog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane) trigger=\(triggerLabel)") + FocusLogStore.shared.append( + "Workspace.focusPanel panelId=\(panelId.uuidString) focusedPane=\(pane) trigger=\(triggerLabel)" + ) +#endif + guard let tabId = surfaceIdFromPanelId(panelId) else { return } + let currentlyFocusedPanelId = focusedPanelId - private func flushWorkspaceWindowLayouts() { - for window in NSApp.windows { - window.contentView?.layoutSubtreeIfNeeded() - window.contentView?.displayIfNeeded() + // Capture the currently focused terminal view so we can explicitly move AppKit first + // 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. + let previousTerminalHostedView = previousHostedView ?? focusedTerminalPanel?.hostedView + + // `selectTab` does not necessarily move bonsplit'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 selectionAlreadyConverged: Bool = { + guard let targetPaneId else { return false } + return bonsplitController.focusedPaneId == targetPaneId && + bonsplitController.selectedTab(inPane: targetPaneId)?.id == 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 } + .map { String($0.uuid.uuidString.prefix(5)) } ?? "nil" + let currentPanelShort = currentlyFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "focus.panel.begin workspace=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) trigger=\(String(describing: trigger)) " + + "targetPane=\(targetPaneShort) focusedPane=\(focusedPaneShort) selectedTab=\(selectedTabShort) " + + "converged=\(selectionAlreadyConverged ? 1 : 0) " + + "currentPanel=\(currentPanelShort)" + ) + if shouldSuppressReentrantRefocus { + dlog( + "focus.panel.skipReentrant panel=\(panelId.uuidString.prefix(5)) " + + "reason=firstResponderAlreadyConverged" + ) } - } +#endif - 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 - } + if let targetPaneId, !selectionAlreadyConverged { +#if DEBUG + dlog( + "focus.panel.focusPane workspace=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) pane=\(targetPaneId.id.uuidString.prefix(5))" + ) +#endif + bonsplitController.focusPane(targetPaneId) + } - 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) - } + if !selectionAlreadyConverged { +#if DEBUG + dlog( + "focus.panel.selectTab workspace=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) tab=\(tabId.uuid.uuidString.prefix(5))" + ) +#endif + bonsplitController.selectTab(tabId) + } - private func browserSplitZoomExitFocusNeedsFollowUp(panelId: UUID) -> Bool { - guard let browserPanel = browserPanel(for: panelId), - let paneId = paneId(forPanelId: panelId), - let tabId = surfaceIdFromPanelId(panelId) else { - return false + if let targetPaneId { + let activationIntent = panels[panelId]?.preferredFocusIntentForActivation() + applyTabSelection( + tabId: tabId, + inPane: targetPaneId, + reassertAppKitFocus: !shouldSuppressReentrantRefocus, + focusIntent: activationIntent, + previousTerminalHostedView: previousTerminalHostedView + ) } - 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 + if let browserPanel = panels[panelId] as? BrowserPanel { + maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger) } - return focusedPanelId != panelId || !terminalPanel.hostedView.isSurfaceViewFirstResponder() - } - private func browserPanelNeedsFollowUp() -> Bool { - guard let panelId = layoutFollowUpBrowserPanelId, - let browserPanel = browserPanel(for: panelId) else { - return false + if trigger == .terminalFirstResponder, + panels[panelId] is TerminalPanel { + beginEventDrivenLayoutFollowUp( + reason: "workspace.focusPanel.terminal", + terminalFocusPanelId: panelId + ) } - return !browserPortalReady(for: browserPanel) } - private func attemptEventDrivenLayoutFollowUp() { - guard layoutFollowUpTimeoutWorkItem != nil, !isAttemptingLayoutFollowUp else { return } - isAttemptingLayoutFollowUp = true - defer { isAttemptingLayoutFollowUp = false } + func maybeAutoFocusBrowserAddressBarOnPanelFocus( + _ browserPanel: BrowserPanel, + trigger: FocusPanelTrigger + ) { + guard trigger == .standard else { return } + guard !isCommandPaletteVisibleForWorkspaceWindow() else { return } + guard !browserPanel.shouldSuppressOmnibarAutofocus() else { return } + guard browserPanel.isShowingNewTabPage || browserPanel.preferredURLStringForOmnibar() == nil else { return } - flushWorkspaceWindowLayouts() + _ = browserPanel.requestAddressBarFocus() + NotificationCenter.default.post(name: .browserFocusAddressBar, object: browserPanel.id) + } - let geometryPendingBefore = layoutFollowUpNeedsGeometryPass - let terminalPortalPendingBefore = terminalPortalVisibilityNeedsFollowUp() - let browserVisibilityPendingBefore = browserPortalVisibilityNeedsFollowUp() - let terminalFocusPendingBefore = terminalFocusNeedsFollowUp() - let browserPanelPendingBefore = browserPanelNeedsFollowUp() - let browserExitPendingBefore = layoutFollowUpBrowserExitFocusPanelId != nil + func isCommandPaletteVisibleForWorkspaceWindow() -> Bool { + guard let app = AppDelegate.shared else { + return false + } - if layoutFollowUpNeedsGeometryPass { - layoutFollowUpNeedsGeometryPass = reconcileTerminalGeometryPass() + if let manager = app.tabManagerFor(tabId: id), + let windowId = app.windowId(for: manager), + let window = app.mainWindow(for: windowId), + app.isCommandPaletteVisible(for: window) { + return true + } + + if let keyWindow = NSApp.keyWindow, app.isCommandPaletteVisible(for: keyWindow) { + return true + } + if let mainWindow = NSApp.mainWindow, app.isCommandPaletteVisible(for: mainWindow) { + return true } + return false + } - 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 - } + func moveFocus(direction: NavigationDirection) { + // If a pane is zoomed, un-zoom before navigating so the target + // pane becomes visible — matches tmux behavior (#1605). + if bonsplitController.isSplitZoomed { + _ = clearSplitZoom(reason: "workspace.moveFocus") } + let previousFocusedPanelId = focusedPanelId - reconcileTerminalPortalVisibilityForCurrentRenderedLayout() - let terminalPortalPending = terminalPortalVisibilityNeedsFollowUp() + // Unfocus the currently-focused panel before navigating. + if let prevPanelId = previousFocusedPanelId, let prev = panels[prevPanelId] { + prev.unfocus() + } - let reason = layoutFollowUpReason ?? "workspace.layout" - reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: reason) - let browserVisibilityPending = browserPortalVisibilityNeedsFollowUp() + bonsplitController.navigateFocus(direction: direction) - 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 - } + // 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 { + applyTabSelection(tabId: tabId, inPane: paneId) } - if let browserExitFocusPanelId = layoutFollowUpBrowserExitFocusPanelId { - if browserSplitZoomExitFocusNeedsFollowUp(panelId: browserExitFocusPanelId) { - if browserPanel(for: browserExitFocusPanelId) != nil { - focusPanel(browserExitFocusPanelId) - scheduleFocusReconcile() - } else { - layoutFollowUpBrowserExitFocusPanelId = nil - } - } else { - layoutFollowUpBrowserExitFocusPanelId = nil - } + } + + // MARK: - Surface Navigation + + /// Select the next surface in the currently focused pane + func selectNextSurface() { + bonsplitController.selectNextTab() + + if let paneId = bonsplitController.focusedPaneId, + let tabId = bonsplitController.selectedTab(inPane: paneId)?.id { + applyTabSelection(tabId: tabId, inPane: paneId) } + } - let terminalFocusPending = terminalFocusNeedsFollowUp() - let browserPanelPending = browserPanelNeedsFollowUp() - let browserExitPending = layoutFollowUpBrowserExitFocusPanelId != nil - let needsMoreWork = - layoutFollowUpNeedsGeometryPass || - terminalPortalPending || - browserVisibilityPending || - terminalFocusPending || - browserPanelPending || - browserExitPending + /// Select the previous surface in the currently focused pane + func selectPreviousSurface() { + bonsplitController.selectPreviousTab() - if !needsMoreWork { - clearLayoutFollowUp() - return + if let paneId = bonsplitController.focusedPaneId, + let tabId = bonsplitController.selectedTab(inPane: paneId)?.id { + applyTabSelection(tabId: tabId, inPane: paneId) } + } - let didMakeProgress = - (geometryPendingBefore && !layoutFollowUpNeedsGeometryPass) || - (terminalPortalPendingBefore && !terminalPortalPending) || - (browserVisibilityPendingBefore && !browserVisibilityPending) || - (terminalFocusPendingBefore && !terminalFocusPending) || - (browserPanelPendingBefore && !browserPanelPending) || - (browserExitPendingBefore && !browserExitPending) + /// 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) - if didMakeProgress { - layoutFollowUpStalledAttemptCount = 0 - scheduleLayoutFollowUpAttempt() - } else { - layoutFollowUpStalledAttemptCount += 1 + if let tabId = bonsplitController.selectedTab(inPane: focusedPaneId)?.id { + applyTabSelection(tabId: tabId, inPane: focusedPaneId) } } - /// 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 + /// 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) - // Flush pending AppKit layout first so terminal-host bounds reflect latest split topology. - for window in NSApp.windows { - window.contentView?.layoutSubtreeIfNeeded() + if let tabId = bonsplitController.selectedTab(inPane: focusedPaneId)?.id { + applyTabSelection(tabId: tabId, inPane: focusedPaneId) } + } - 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 + /// Create a new terminal surface in the currently focused pane + @discardableResult + func newTerminalSurfaceInFocusedPane(focus: Bool? = nil) -> TerminalPanel? { + guard let focusedPaneId = bonsplitController.focusedPaneId else { return nil } + return newTerminalSurface(inPane: focusedPaneId, focus: focus) + } - // 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 - } + @discardableResult + func clearSplitZoom(reason: String = "workspace.clearSplitZoom") -> Bool { + // Capture the zoomed pane's browser panel (if any) before clearing zoom, + // so we can prime its portal host replacement afterward. + let zoomedBrowser: (paneId: PaneID, panel: BrowserPanel)? = { + guard let zoomedPaneId = bonsplitController.zoomedPaneId, + let tabId = bonsplitController.selectedTab(inPane: zoomedPaneId)?.id, + let panelId = panelIdFromSurfaceId(tabId), + let browser = browserPanel(for: panelId) else { return nil } + return (zoomedPaneId, browser) + }() - 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 - } + guard bonsplitController.clearPaneZoom() else { return false } + if let zoomedBrowser { + zoomedBrowser.panel.preparePortalHostReplacementForNextDistinctClaim( + inPane: zoomedBrowser.paneId, + reason: reason + ) } - - return needsFollowUpPass + reconcileTerminalPortalVisibilityForCurrentRenderedLayout() + reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: reason) + beginEventDrivenLayoutFollowUp(reason: reason, includeGeometry: true) + return true } - private func scheduleTerminalGeometryReconcile() { + @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 } + focusPanel(panelId) + if !bonsplitController.isSplitZoomed { + // Un-zooming: use centralized reconciliation + reconcileTerminalPortalVisibilityForCurrentRenderedLayout() + reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: "workspace.toggleSplitZoom") + } + if let browserPanel = browserPanel(for: panelId) { + browserPanel.preparePortalHostReplacementForNextDistinctClaim( + inPane: paneId, + reason: "workspace.toggleSplitZoom" + ) + } beginEventDrivenLayoutFollowUp( - reason: "workspace.geometry", + reason: "workspace.toggleSplitZoom", + browserPanelId: browserPanel(for: panelId) != nil ? panelId : nil, + browserExitFocusPanelId: (wasSplitZoomed && !bonsplitController.isSplitZoomed) ? panelId : nil, includeGeometry: true ) + return true } - private func renderedVisiblePanelIdsForCurrentLayout() -> Set { - let renderedPaneIds = bonsplitController.zoomedPaneId.map { [$0] } ?? bonsplitController.allPaneIds - var visiblePanelIds: Set = [] + // MARK: - Context Menu Shortcuts - 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 + static func buildContextMenuShortcuts() -> [TabContextAction: KeyboardShortcut] { + var shortcuts: [TabContextAction: KeyboardShortcut] = [:] + let mappings: [(TabContextAction, KeyboardShortcutSettings.Action)] = [ + (.rename, .renameTab), + (.toggleZoom, .toggleSplitZoom), + (.newTerminalToRight, .newSurface), + ] + for (contextAction, settingsAction) in mappings { + let stored = KeyboardShortcutSettings.shortcut(for: settingsAction) + if let key = stored.keyEquivalent { + shortcuts[contextAction] = KeyboardShortcut(key, modifiers: stored.eventModifiers) } - visiblePanelIds.insert(panelId) } + return shortcuts + } - if let focusedPanelId, - panels[focusedPanelId] != nil, - let focusedPaneId = paneId(forPanelId: focusedPanelId), - renderedPaneIds.contains(where: { $0.id == focusedPaneId.id }) { - visiblePanelIds.insert(focusedPanelId) + // MARK: - Flash/Notification Support + + func triggerFocusFlash(panelId: UUID) { + requestAttentionFlash(panelId: panelId, reason: .navigation) + } + + func triggerNotificationFocusFlash( + panelId: UUID, + requiresSplit: Bool = false, + shouldFocus: Bool = true + ) { + guard terminalPanel(for: panelId) != nil else { return } + if shouldFocus { + focusPanel(panelId) + } + let isSplit = bonsplitController.allPaneIds.count > 1 || panels.count > 1 + if requiresSplit && !isSplit { + return } + requestAttentionFlash(panelId: panelId, reason: .notificationArrival) + } - return visiblePanelIds + func triggerNotificationDismissFlash(panelId: UUID) { + guard terminalPanel(for: panelId) != nil else { return } + requestAttentionFlash(panelId: panelId, reason: .notificationDismiss) } - @discardableResult - private func reconcileTerminalPortalVisibilityForCurrentRenderedLayout() -> Bool { - let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() - var didChange = false + func triggerDebugFlash(panelId: UUID) { + guard panels[panelId] != nil else { return } + focusPanel(panelId) + 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 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 - ) + guard let terminal = panel as? TerminalPanel else { continue } + terminal.hostedView.setVisibleInUI(false) + TerminalWindowPortalRegistry.hideHostedView(terminal.hostedView) } - - return didChange } - private func terminalPortalVisibilityNeedsFollowUp() -> Bool { - let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() - + func hideAllBrowserPortalViews() { 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 - } + guard let browser = panel as? BrowserPanel else { continue } + browser.hideBrowserPortalView(source: "workspaceRetire") } - - return false } + // MARK: - Utility + + /// Create a new terminal panel (used when replacing the last panel) @discardableResult - private func reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: String) -> Bool { - let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() - var didChange = false + func createReplacementTerminalPanel() -> TerminalPanel { + let inheritedConfig = inheritedTerminalConfig( + preferredPanelId: focusedPanelId, + inPane: bonsplitController.focusedPaneId + ) + let newPanel = TerminalPanel( + workspaceId: id, + context: GHOSTTY_SURFACE_CONTEXT_TAB, + configTemplate: inheritedConfig, + portOrdinal: portOrdinal + ) + configureTerminalPanel(newPanel) + panels[newPanel.id] = newPanel + panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) - 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 - } - } + // Create tab in bonsplit + if let newTabId = bonsplitController.createTab( + title: newPanel.displayTitle, + icon: newPanel.displayIcon, + kind: SurfaceKind.terminal, + isDirty: newPanel.isDirty, + isPinned: false + ) { + surfaceIdToPanelId[newTabId] = newPanel.id } - return didChange + return newPanel } - private func browserPortalVisibilityNeedsFollowUp() -> Bool { - let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() - - 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) { + /// Check if any panel needs close confirmation + func needsConfirmClose() -> Bool { + for (panelId, panel) in panels { + if let terminalPanel = panel as? TerminalPanel, + panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) { return true } } - return false } - private func scheduleMovedTerminalRefresh(panelId: UUID) { - guard terminalPanel(for: panelId) != nil else { return } + func reconcileFocusState() { + guard !isReconcilingFocusState else { return } + isReconcilingFocusState = true + defer { isReconcilingFocusState = false } - // 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() + // Source of truth: bonsplit focused pane + selected tab. + // AppKit first responder must converge to this model state, not the other way around. + var targetPanelId: UUID? - 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() - } + if let focusedPane = bonsplitController.focusedPaneId, + let focusedTab = bonsplitController.selectedTab(inPane: focusedPane), + let mappedPanelId = panelIdFromSurfaceId(focusedTab.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 } } - // 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) { - continue + 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) } - _ = bonsplitController.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)) - } - - 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)) - } - private func tabIdsToCloseOthers(of anchorTabId: TabID, inPane paneId: PaneID) -> [TabID] { - bonsplitController.tabs(inPane: paneId) - .map(\.id) - .filter { $0 != anchorTabId } - } + guard let targetPanelId, let targetPanel = panels[targetPanelId] else { return } - 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 } - _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex) - } + for (panelId, panel) in panels where panelId != targetPanelId { + panel.unfocus() + } - 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( - inPane: paneId, - url: url, - focus: true, - preferredProfileID: preferredProfileID - ) else { return } - _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex) + 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] } - private func duplicateBrowserToRight(anchorTabId: TabID, inPane paneId: PaneID) { - guard let panelId = panelIdFromSurfaceId(anchorTabId), - let browser = browserPanel(for: panelId) else { return } - let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId) - guard let newPanel = newBrowserSurface( - inPane: paneId, - url: browser.currentURL, - focus: true, - preferredProfileID: browser.profileID - ) else { return } - _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex) + /// Reconcile focus/first-responder convergence. + /// Coalesce to the next main-queue turn so bonsplit selection/pane mutations settle first. + 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 promptRenamePanel(tabId: TabID) { - guard let panelId = panelIdFromSurfaceId(tabId), - let panel = panels[panelId] 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 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) - alert.accessoryView = input - alert.addButton(withTitle: String(localized: "alert.renameTab.rename", defaultValue: "Rename")) - alert.addButton(withTitle: String(localized: "alert.cancel", defaultValue: "Cancel")) - let alertWindow = alert.window - alertWindow.initialFirstResponder = input - DispatchQueue.main.async { - alertWindow.makeFirstResponder(input) - input.selectText(nil) + func beginEventDrivenLayoutFollowUp( + reason: String, + browserPanelId: UUID? = nil, + browserExitFocusPanelId: UUID? = nil, + terminalFocusPanelId: UUID? = nil, + includeGeometry: Bool = false + ) { + layoutFollowUpReason = reason + if let browserPanelId { + layoutFollowUpBrowserPanelId = browserPanelId } - let response = alert.runModal() - guard response == .alertFirstButtonReturn else { return } - setPanelCustomTitle(panelId: panelId, title: input.stringValue) - } + 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 - private enum PanelMoveDestination { - case newWorkspaceInCurrentWindow - case selectedWorkspaceInNewWindow - case existingWorkspace(UUID) + 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 promptMovePanel(tabId: TabID) { - guard let panelId = panelIdFromSurfaceId(tabId), - let app = AppDelegate.shared else { return } + func installLayoutFollowUpObservers() { + guard layoutFollowUpTimeoutWorkItem == nil else { return } - let currentWindowId = app.tabManagerFor(tabId: id).flatMap { app.windowId(for: $0) } - let workspaceTargets = app.workspaceMoveTargets( - excludingWorkspaceId: id, - referenceWindowId: currentWindowId - ) + let enqueueAttempt: () -> Void = { [weak self] in + self?.scheduleLayoutFollowUpAttempt() + } - var options: [(title: String, destination: PanelMoveDestination)] = [ - (String(localized: "alert.moveTab.newWorkspaceInCurrentWindow", defaultValue: "New Workspace in Current Window"), .newWorkspaceInCurrentWindow), - (String(localized: "alert.moveTab.selectedWorkspaceInNewWindow", defaultValue: "Selected Workspace in New Window"), .selectedWorkspaceInNewWindow), - ] - options.append(contentsOf: workspaceTargets.map { target in - (target.label, .existingWorkspace(target.workspaceId)) + 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() + } + } - let alert = NSAlert() - alert.messageText = String(localized: "alert.moveTab.title", defaultValue: "Move Tab") - alert.informativeText = String(localized: "alert.moveTab.message", defaultValue: "Choose a destination for this tab.") - let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 320, height: 26), pullsDown: false) - for option in options { - popup.addItem(withTitle: option.title) + func refreshLayoutFollowUpTimeout() { + layoutFollowUpTimeoutWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.clearLayoutFollowUp() } - popup.selectItem(at: 0) - alert.accessoryView = popup - alert.addButton(withTitle: String(localized: "alert.moveTab.move", defaultValue: "Move")) - alert.addButton(withTitle: String(localized: "alert.cancel", defaultValue: "Cancel")) - - guard alert.runModal() == .alertFirstButtonReturn else { return } - let selectedIndex = max(0, min(popup.indexOfSelectedItem, options.count - 1)) - let destination = options[selectedIndex].destination - - let moved: Bool - switch destination { - case .newWorkspaceInCurrentWindow: - guard let manager = app.tabManagerFor(tabId: id) else { return } - let workspace = manager.addWorkspace(select: true) - moved = app.moveSurface( - panelId: panelId, - toWorkspace: workspace.id, - focus: true, - focusWindow: false - ) + layoutFollowUpTimeoutWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: workItem) + } - case .selectedWorkspaceInNewWindow: - let newWindowId = app.createMainWindow() - guard let destinationManager = app.tabManagerFor(windowId: newWindowId), - let destinationWorkspaceId = destinationManager.selectedTabId else { - return - } - moved = app.moveSurface( - panelId: panelId, - toWorkspace: destinationWorkspaceId, - focus: true, - focusWindow: true - ) - if !moved { - _ = app.closeMainWindow(windowId: newWindowId) - } + 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 + } - case .existingWorkspace(let workspaceId): - moved = app.moveSurface( - panelId: panelId, - toWorkspace: workspaceId, - focus: true, - focusWindow: true - ) - } + func scheduleLayoutFollowUpAttempt() { + guard layoutFollowUpTimeoutWorkItem != nil else { return } + guard !layoutFollowUpAttemptScheduled else { return } - if !moved { - let failure = NSAlert() - failure.alertStyle = .warning - failure.messageText = String(localized: "alert.moveTab.failed.title", defaultValue: "Move Failed") - failure.informativeText = String(localized: "alert.moveTab.failed.message", defaultValue: "Programa could not move this tab to the selected destination.") - failure.addButton(withTitle: String(localized: "alert.ok", defaultValue: "OK")) - _ = failure.runModal() + 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 handleExternalTabDrop(_ request: BonsplitController.ExternalTabDropRequest) -> Bool { - guard let app = AppDelegate.shared else { return false } -#if DEBUG - let dropStart = ProcessInfo.processInfo.systemUptime -#endif - - let targetPane: PaneID - let targetIndex: Int? - let splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? -#if DEBUG - let destinationLabel: String -#endif + 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))) + } - switch request.destination { - case .insert(let paneId, let index): - targetPane = paneId - targetIndex = index - splitTarget = nil -#if DEBUG - destinationLabel = "insert pane=\(paneId.id.uuidString.prefix(5)) index=\(index.map(String.init) ?? "nil")" -#endif - case .split(let paneId, let orientation, let insertFirst): - targetPane = paneId - targetIndex = nil - splitTarget = (orientation, insertFirst) -#if DEBUG - destinationLabel = "split pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation.rawValue) insertFirst=\(insertFirst ? 1 : 0)" -#endif + func flushWorkspaceWindowLayouts() { + for window in NSApp.windows { + window.contentView?.layoutSubtreeIfNeeded() + window.contentView?.displayIfNeeded() } + } - #if DEBUG - 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( - tabId: request.tabId.uuid, - toWorkspace: id, - targetPane: targetPane, - targetIndex: targetIndex, - splitTarget: splitTarget, - focus: true, - focusWindow: true - ) -#if DEBUG - dlog( - "split.externalDrop.end ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " + - "moved=\(moved ? 1 : 0) elapsedMs=\(debugElapsedMs(since: dropStart))" - ) -#endif - return moved + 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 } -} + func browserPortalReady(for browserPanel: BrowserPanel) -> Bool { + browserPortalAnchorReady(for: browserPanel) && + browserPanel.webView.window != nil && + browserPanel.webView.superview != nil && + BrowserWindowPortalRegistry.isWebView(browserPanel.webView, boundTo: browserPanel.portalAnchorView) + } -// MARK: - BonsplitDelegate + 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) + } -extension Workspace: BonsplitDelegate { - @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, - let manager, - manager.tabs.contains(where: { $0.id == id }) else { + func terminalFocusNeedsFollowUp() -> Bool { + guard let panelId = layoutFollowUpTerminalFocusPanelId, + let terminalPanel = terminalPanel(for: panelId) else { return false } - return true + return focusedPanelId != panelId || !terminalPanel.hostedView.isSurfaceViewFirstResponder() } - @MainActor - private func confirmClosePanel(for tabId: TabID) async -> Bool { - let alert = NSAlert() + func browserPanelNeedsFollowUp() -> Bool { + guard let panelId = layoutFollowUpBrowserPanelId, + let browserPanel = browserPanel(for: panelId) else { + return false + } + return !browserPortalReady(for: browserPanel) + } - alert.messageText = String(localized: "dialog.closeTab.title", defaultValue: "Close tab?") + func attemptEventDrivenLayoutFollowUp() { + guard layoutFollowUpTimeoutWorkItem != nil, !isAttemptingLayoutFollowUp else { return } + isAttemptingLayoutFollowUp = true + defer { isAttemptingLayoutFollowUp = false } - let panelName: String? = { - guard let panelId = panelIdFromSurfaceId(tabId) else { return nil } - if let custom = panelCustomTitles[panelId], !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return custom - } - if let title = panelTitles[panelId], !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return title - } - if let dir = panelDirectories[panelId], !dir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return (dir as NSString).lastPathComponent - } - return nil - }() + flushWorkspaceWindowLayouts() - if let panelName { - alert.informativeText = String(localized: "dialog.closeTab.messageNamed", defaultValue: "This will close \"\(panelName)\".") - } else { - alert.informativeText = String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab.") - } - alert.alertStyle = .warning - alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close")) - alert.addButton(withTitle: String(localized: "dialog.closeTab.cancel", defaultValue: "Cancel")) + let geometryPendingBefore = layoutFollowUpNeedsGeometryPass + let terminalPortalPendingBefore = terminalPortalVisibilityNeedsFollowUp() + let browserVisibilityPendingBefore = browserPortalVisibilityNeedsFollowUp() + let terminalFocusPendingBefore = terminalFocusNeedsFollowUp() + let browserPanelPendingBefore = browserPanelNeedsFollowUp() + let browserExitPendingBefore = layoutFollowUpBrowserExitFocusPanelId != nil - if let closeButton = alert.buttons.first { - closeButton.keyEquivalent = "\r" - closeButton.keyEquivalentModifierMask = [] - alert.window.defaultButtonCell = closeButton.cell as? NSButtonCell - alert.window.initialFirstResponder = closeButton - } - if let cancelButton = alert.buttons.dropFirst().first { - cancelButton.keyEquivalent = "\u{1b}" + if layoutFollowUpNeedsGeometryPass { + layoutFollowUpNeedsGeometryPass = reconcileTerminalGeometryPass() } - // Prefer a sheet if we can find a window, otherwise fall back to modal. - if let window = NSApp.keyWindow ?? NSApp.mainWindow { - return await withCheckedContinuation { continuation in - alert.beginSheetModal(for: window) { response in - continuation.resume(returning: response == .alertFirstButtonReturn) + 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 } } - return alert.runModal() == .alertFirstButtonReturn - } + reconcileTerminalPortalVisibilityForCurrentRenderedLayout() + let terminalPortalPending = terminalPortalVisibilityNeedsFollowUp() - /// 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). - private func applyTabSelection( - tabId: TabID, - inPane pane: PaneID, - reassertAppKitFocus: Bool = true, - focusIntent: PanelFocusIntent? = nil, - previousTerminalHostedView: GhosttySurfaceScrollView? = nil - ) { - pendingTabSelection = PendingTabSelectionRequest( - tabId: tabId, - pane: pane, - reassertAppKitFocus: reassertAppKitFocus, - focusIntent: focusIntent, - previousTerminalHostedView: previousTerminalHostedView - ) - guard !isApplyingTabSelection else { return } - isApplyingTabSelection = true - defer { - isApplyingTabSelection = false - pendingTabSelection = nil - } - - var iterations = 0 - while let request = pendingTabSelection { - pendingTabSelection = nil - iterations += 1 - if iterations > 8 { break } - applyTabSelectionNow( - tabId: request.tabId, - inPane: request.pane, - reassertAppKitFocus: request.reassertAppKitFocus, - focusIntent: request.focusIntent, - previousTerminalHostedView: request.previousTerminalHostedView - ) - } - } + let reason = layoutFollowUpReason ?? "workspace.layout" + reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: reason) + let browserVisibilityPending = browserPortalVisibilityNeedsFollowUp() - /// 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") + 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 + } } - } - private func applyTabSelectionNow( - tabId: TabID, - inPane pane: PaneID, - reassertAppKitFocus: Bool, - focusIntent: PanelFocusIntent?, - previousTerminalHostedView: GhosttySurfaceScrollView? - ) { - 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 } - .map { String($0.uuid.uuidString.prefix(5)) } ?? "nil" - 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 bonsplitController.tabs(inPane: pane).contains(where: { $0.id == tabId }), - bonsplitController.selectedTab(inPane: pane)?.id != tabId { - bonsplitController.selectTab(tabId) + if let browserExitFocusPanelId = layoutFollowUpBrowserExitFocusPanelId { + if browserSplitZoomExitFocusNeedsFollowUp(panelId: browserExitFocusPanelId) { + if browserPanel(for: browserExitFocusPanelId) != nil { + focusPanel(browserExitFocusPanelId) + scheduleFocusReconcile() + } else { + layoutFollowUpBrowserExitFocusPanelId = nil + } + } else { + layoutFollowUpBrowserExitFocusPanelId = nil } } - let focusedPane: PaneID - let selectedTabId: TabID - if let currentPane = bonsplitController.focusedPaneId, - let currentTabId = bonsplitController.selectedTab(inPane: currentPane)?.id { - focusedPane = currentPane - selectedTabId = currentTabId - } else if bonsplitController.tabs(inPane: pane).contains(where: { $0.id == tabId }) { - focusedPane = pane - selectedTabId = tabId - bonsplitController.focusPane(focusedPane) - bonsplitController.selectTab(selectedTabId) - } else { - return - } + let terminalFocusPending = terminalFocusNeedsFollowUp() + let browserPanelPending = browserPanelNeedsFollowUp() + let browserExitPending = layoutFollowUpBrowserExitFocusPanelId != nil + let needsMoreWork = + layoutFollowUpNeedsGeometryPass || + terminalPortalPending || + browserVisibilityPending || + terminalFocusPending || + browserPanelPending || + browserExitPending - // 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 { - return - } - let effectiveFocusedPanelId = effectiveSelectedPanelId(inPane: focusedPane) ?? selectedPanelId - guard let panel = panels[effectiveFocusedPanelId] else { + if !needsMoreWork { + clearLayoutFollowUp() return } - if debugStressPreloadSelectionDepth > 0 { - if let terminalPanel = panel as? TerminalPanel { - terminalPanel.requestViewReattach() - scheduleTerminalGeometryReconcile() - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() - } - return - } + let didMakeProgress = + (geometryPendingBefore && !layoutFollowUpNeedsGeometryPass) || + (terminalPortalPendingBefore && !terminalPortalPending) || + (browserVisibilityPendingBefore && !browserVisibilityPending) || + (terminalFocusPendingBefore && !terminalFocusPending) || + (browserPanelPendingBefore && !browserPanelPending) || + (browserExitPendingBefore && !browserExitPending) - if shouldTreatCurrentEventAsExplicitFocusIntent() { - markExplicitFocusIntent(on: effectiveFocusedPanelId) + if didMakeProgress { + layoutFollowUpStalledAttemptCount = 0 + scheduleLayoutFollowUpAttempt() + } else { + layoutFollowUpStalledAttemptCount += 1 } - let activationIntent = focusIntent ?? panel.preferredFocusIntentForActivation() - panel.prepareFocusIntentForActivation(activationIntent) - let panelId = effectiveFocusedPanelId + } - syncPinnedStateForTab(selectedTabId, panelId: selectedPanelId) - syncUnreadBadgeStateForPanel(selectedPanelId) + /// Reconcile remaining terminal view geometries after split topology changes. + /// This keeps AppKit bounds and Ghostty surface sizes in sync in the next runloop turn. + func reconcileTerminalGeometryPass() -> Bool { + var needsFollowUpPass = false - // Unfocus all other panels - for (id, p) in panels where id != effectiveFocusedPanelId { - p.unfocus() + // Flush pending AppKit layout first so terminal-host bounds reflect latest split topology. + for window in NSApp.windows { + window.contentView?.layoutSubtreeIfNeeded() } - // Explicitly hide browser portals for deselected tabs in this pane. - // Bonsplit'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. - hideBrowserPortalsForDeselectedTabs(inPane: focusedPane, selectedTabId: selectedTabId) - - if let focusWindow = activationWindow(for: panel) { - yieldForeignOwnedFocusIfNeeded( - in: focusWindow, - targetPanelId: panelId, - targetIntent: activationIntent - ) - } + 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 - activatePanel( - panel, - focusIntent: activationIntent, - reassertAppKitFocus: reassertAppKitFocus - ) - let focusIntentAllowsBrowserOmnibarAutofocus = - shouldTreatCurrentEventAsExplicitFocusIntent() || - TerminalController.socketCommandAllowsInAppFocusMutations() - if let browserPanel = panel as? BrowserPanel, - shouldAllowBrowserOmnibarAutofocus(for: activationIntent), - previousFocusedPanelId != panelId || focusIntentAllowsBrowserOmnibarAutofocus { - maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: .standard) - } - if let terminalPanel = panel as? TerminalPanel { - rememberTerminalConfigInheritanceSource(terminalPanel) - } - let isManuallyUnread = manualUnreadPanelIds.contains(panelId) - let markedAt = manualUnreadMarkedAt[panelId] - if Self.shouldClearManualUnread( - previousFocusedPanelId: previousFocusedPanelId, - nextFocusedPanelId: panelId, - isManuallyUnread: isManuallyUnread, - markedAt: markedAt - ) { - triggerFocusFlash(panelId: panelId) - let clearDelay = Self.manualUnreadClearDelayAfterFocusFlash - if clearDelay <= 0 { - clearManualUnread(panelId: panelId) - } else { - DispatchQueue.main.asyncAfter(deadline: .now() + clearDelay) { [weak self] in - self?.clearManualUnread(panelId: panelId) - } + // 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 } - } - // Converge AppKit first responder with bonsplit'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 - dlog( - "focus.split.moveFocus workspace=\(id.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) previousExists=\(previousExists) " + - "to=\(panelId.uuidString.prefix(5))" - ) -#endif - terminalPanel.hostedView.moveFocus(from: previousTerminalHostedView) + 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 } -#if DEBUG - 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))" - ) -#endif - terminalPanel.hostedView.ensureFocus(for: id, surfaceId: panelId) - } - - if shouldRestoreFocusIntentAfterActivation(activationIntent) { - _ = panel.restoreFocusIntent(activationIntent) } - // Update current directory if this is a terminal - if let dir = panelDirectories[panelId] { - currentDirectory = dir - } - gitBranch = panelGitBranches[panelId] - pullRequest = panelPullRequests[panelId] + return needsFollowUpPass + } - // Post notification - NotificationCenter.default.post( - name: .ghosttyDidFocusSurface, - object: nil, - userInfo: [ - GhosttyNotificationKey.tabId: self.id, - GhosttyNotificationKey.surfaceId: panelId - ] - ) -#if DEBUG - let prevPanelShort = previousFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - 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)) " + - "prevPanel=\(prevPanelShort)" + func scheduleTerminalGeometryReconcile() { + beginEventDrivenLayoutFollowUp( + reason: "workspace.geometry", + includeGeometry: true ) -#endif } - private func activatePanel( - _ panel: any Panel, - focusIntent: PanelFocusIntent, - reassertAppKitFocus: Bool - ) { - if let terminalPanel = panel as? TerminalPanel { - let shouldFocusTerminalSurface = shouldMoveTerminalSurfaceFocus(for: focusIntent) - terminalPanel.surface.setFocus(shouldFocusTerminalSurface) - terminalPanel.hostedView.setActive(true) - if reassertAppKitFocus && shouldFocusTerminalSurface { - terminalPanel.focus() + 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 } - return - } - - if let browserPanel = panel as? BrowserPanel { - guard shouldFocusBrowserWebView(for: focusIntent) else { return } - browserPanel.focus() - return + visiblePanelIds.insert(panelId) } - if reassertAppKitFocus { - panel.focus() + if let focusedPanelId, + panels[focusedPanelId] != nil, + let focusedPaneId = paneId(forPanelId: focusedPanelId), + renderedPaneIds.contains(where: { $0.id == focusedPaneId.id }) { + visiblePanelIds.insert(focusedPanelId) } - } - private func activationWindow(for panel: any Panel) -> NSWindow? { - if let terminalPanel = panel as? TerminalPanel { - return terminalPanel.hostedView.window ?? NSApp.keyWindow ?? NSApp.mainWindow - } - if let browserPanel = panel as? BrowserPanel { - return browserPanel.webView.window ?? browserPanel.portalAnchorView.window ?? NSApp.keyWindow ?? NSApp.mainWindow - } - return NSApp.keyWindow ?? NSApp.mainWindow + return visiblePanelIds } - private func yieldForeignOwnedFocusIfNeeded( - in window: NSWindow, - targetPanelId: UUID, - targetIntent: PanelFocusIntent - ) { - guard let firstResponder = window.firstResponder else { return } + @discardableResult + func reconcileTerminalPortalVisibilityForCurrentRenderedLayout() -> Bool { + let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() + var didChange = false - for (panelId, panel) in panels where panelId != targetPanelId { - guard let ownedIntent = panel.ownedFocusIntent(for: firstResponder, in: window) else { continue } -#if DEBUG - 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))" + 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 ) -#endif - _ = panel.yieldFocusIntent(ownedIntent, in: window) - return } - } - private func shouldMoveTerminalSurfaceFocus(for intent: PanelFocusIntent) -> Bool { - switch intent { - case .terminal(.findField): - return false - default: - return true - } + return didChange } - private func shouldFocusBrowserWebView(for intent: PanelFocusIntent) -> Bool { - switch intent { - case .browser(.addressBar), .browser(.findField): - return false - default: - return true - } - } + func terminalPortalVisibilityNeedsFollowUp() -> Bool { + let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() - private func shouldAllowBrowserOmnibarAutofocus(for intent: PanelFocusIntent) -> Bool { - switch intent { - case .browser(.webView), .panel: - return true - default: - return false - } - } + for panel in panels.values { + guard let terminalPanel = panel as? TerminalPanel else { continue } + let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id) + let hostedView = terminalPanel.hostedView - private func shouldRestoreFocusIntentAfterActivation(_ intent: PanelFocusIntent) -> Bool { - switch intent { - case .browser(.addressBar), .browser(.findField), .terminal(.findField): - return true - case .panel, .browser(.webView), .terminal(.surface): - return false + if shouldBeVisible { + if hostedView.isHidden || hostedView.window == nil || hostedView.superview == nil { + return true + } + } else if !hostedView.isHidden { + return true + } } - } - - 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 + return false } - private func clearNonFocusSplitFocusReassert(generation: UInt64? = nil) { - guard let pending = pendingNonFocusSplitFocusReassert else { return } - if let generation, pending.generation != generation { return } - pendingNonFocusSplitFocusReassert = nil - } + @discardableResult + func reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: String) -> Bool { + let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() + var didChange = false - private func shouldTreatCurrentEventAsExplicitFocusIntent() -> Bool { - guard let eventType = NSApp.currentEvent?.type else { return false } - switch eventType { - case .leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp, - .otherMouseDown, .otherMouseUp, .keyDown, .keyUp, .scrollWheel, - .gesture, .magnify, .rotate, .swipe: - return true - default: - return 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 + } + } } - } - private func markExplicitFocusIntent(on panelId: UUID) { - guard let pending = pendingNonFocusSplitFocusReassert, - pending.splitPanelId == panelId else { - return - } - pendingNonFocusSplitFocusReassert = nil + return didChange } - func splitTabBar(_ controller: BonsplitController, shouldCloseTab tab: Bonsplit.Tab, 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) - return - } - - let target: TabID? = { - if idx + 1 < tabs.count { return tabs[idx + 1].id } - if idx > 0 { return tabs[idx - 1].id } - return nil - }() + func browserPortalVisibilityNeedsFollowUp() -> Bool { + let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() - if let target { - postCloseSelectTabId[tab.id] = target - } else { - postCloseSelectTabId.removeValue(forKey: tab.id) + 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 } } - let explicitUserClose = explicitUserCloseTabIds.remove(tab.id) != nil + return false + } - if forceCloseTabIds.contains(tab.id) { - stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane) - recordPostCloseSelection() - return true - } + func scheduleMovedTerminalRefresh(panelId: UUID) { + guard terminalPanel(for: panelId) != nil else { return } - if let panelId = panelIdFromSurfaceId(tab.id), - pinnedPanelIds.contains(panelId) { - clearStagedClosedBrowserRestoreSnapshot(for: tab.id) - NSSound.beep() - return false - } + // 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() - if explicitUserClose && shouldCloseWorkspaceOnLastSurface(for: tab.id) { - clearStagedClosedBrowserRestoreSnapshot(for: tab.id) - owningTabManager?.closeWorkspaceWithConfirmation(self) - return false + 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() + } + } } - // Check if the panel needs close confirmation - guard let panelId = panelIdFromSurfaceId(tab.id), - let terminalPanel = terminalPanel(for: panelId) else { - stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane) - recordPostCloseSelection() - return true - } + // 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) + } - // If confirmation is required, Bonsplit 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 + func closeTabs(_ tabIds: [TabID], skipPinned: Bool = true) { + for tabId in tabIds { + if skipPinned, + let panelId = panelIdFromSurfaceId(tabId), + pinnedPanelIds.contains(panelId) { + continue } + _ = bonsplitController.closeTab(tabId) + } + } - pendingCloseConfirmTabIds.insert(tab.id) - let tabId = tab.id - DispatchQueue.main.async { [weak self] in - guard let self else { return } - Task { @MainActor in - defer { self.pendingCloseConfirmTabIds.remove(tabId) } + 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)) + } + + 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)) + } - // If the tab disappeared while we were scheduling, do nothing. - guard self.panelIdFromSurfaceId(tabId) != nil else { return } + func tabIdsToCloseOthers(of anchorTabId: TabID, inPane paneId: PaneID) -> [TabID] { + bonsplitController.tabs(inPane: paneId) + .map(\.id) + .filter { $0 != anchorTabId } + } - let confirmed = await self.confirmClosePanel(for: tabId) - guard confirmed else { return } + 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 } + _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex) + } - self.forceCloseTabIds.insert(tabId) - self.bonsplitController.closeTab(tabId) - } - } + 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( + inPane: paneId, + url: url, + focus: true, + preferredProfileID: preferredProfileID + ) else { return } + _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex) + } + + func duplicateBrowserToRight(anchorTabId: TabID, inPane paneId: PaneID) { + guard let panelId = panelIdFromSurfaceId(anchorTabId), + let browser = browserPanel(for: panelId) else { return } + let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId) + guard let newPanel = newBrowserSurface( + inPane: paneId, + url: browser.currentURL, + focus: true, + preferredProfileID: browser.profileID + ) else { return } + _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex) + } - return false + func promptRenamePanel(tabId: TabID) { + guard let panelId = panelIdFromSurfaceId(tabId), + let panel = panels[panelId] 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 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) + alert.accessoryView = input + alert.addButton(withTitle: String(localized: "alert.renameTab.rename", defaultValue: "Rename")) + alert.addButton(withTitle: String(localized: "alert.cancel", defaultValue: "Cancel")) + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) } + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { return } + setPanelCustomTitle(panelId: panelId, title: input.stringValue) + } - clearStagedClosedBrowserRestoreSnapshot(for: tab.id) - recordPostCloseSelection() - return true + enum PanelMoveDestination { + case newWorkspaceInCurrentWindow + case selectedWorkspaceInNewWindow + case existingWorkspace(UUID) } - func splitTabBar(_ controller: BonsplitController, 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 + func promptMovePanel(tabId: TabID) { + guard let panelId = panelIdFromSurfaceId(tabId), + let app = AppDelegate.shared else { return } - // Clean up our panel - guard let panelId = panelIdFromSurfaceId(tabId) else { - #if DEBUG - NSLog("[Workspace] didCloseTab: no panelId for tabId") - #endif - scheduleTerminalGeometryReconcile() - if !isDetaching { - scheduleFocusReconcile() - } - return - } + let currentWindowId = app.tabManagerFor(tabId: id).flatMap { app.windowId(for: $0) } + let workspaceTargets = app.workspaceMoveTargets( + excludingWorkspaceId: id, + referenceWindowId: currentWindowId + ) - #if DEBUG - NSLog("[Workspace] didCloseTab panelId=\(panelId) remainingPanels=\(panels.count - 1) remainingPanes=\(controller.allPaneIds.count)") - #endif + var options: [(title: String, destination: PanelMoveDestination)] = [ + (String(localized: "alert.moveTab.newWorkspaceInCurrentWindow", defaultValue: "New Workspace in Current Window"), .newWorkspaceInCurrentWindow), + (String(localized: "alert.moveTab.selectedWorkspaceInNewWindow", defaultValue: "Selected Workspace in New Window"), .selectedWorkspaceInNewWindow), + ] + options.append(contentsOf: workspaceTargets.map { target in + (target.label, .existingWorkspace(target.workspaceId)) + }) + + let alert = NSAlert() + alert.messageText = String(localized: "alert.moveTab.title", defaultValue: "Move Tab") + alert.informativeText = String(localized: "alert.moveTab.message", defaultValue: "Choose a destination for this tab.") + let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 320, height: 26), pullsDown: false) + for option in options { + popup.addItem(withTitle: option.title) + } + popup.selectItem(at: 0) + alert.accessoryView = popup + alert.addButton(withTitle: String(localized: "alert.moveTab.move", defaultValue: "Move")) + alert.addButton(withTitle: String(localized: "alert.cancel", defaultValue: "Cancel")) - let panel = panels[panelId] - let transferredRemoteCleanupConfiguration = transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: panelId) + guard alert.runModal() == .alertFirstButtonReturn else { return } + let selectedIndex = max(0, min(popup.indexOfSelectedItem, options.count - 1)) + let destination = options[selectedIndex].destination - if isDetaching, let panel { - let browserPanel = panel as? BrowserPanel - let cachedTitle = panelTitles[panelId] - let transferFallbackTitle = cachedTitle ?? panel.displayTitle - pendingDetachedSurfaces[tabId] = DetachedSurfaceTransfer( + let moved: Bool + switch destination { + case .newWorkspaceInCurrentWindow: + guard let manager = app.tabManagerFor(tabId: id) else { return } + let workspace = manager.addWorkspace(select: true) + moved = app.moveSurface( 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], - cachedTitle: cachedTitle, - customTitle: panelCustomTitles[panelId], - manuallyUnread: manualUnreadPanelIds.contains(panelId), - isRemoteTerminal: activeRemoteTerminalSurfaceIds.contains(panelId), - remoteRelayPort: activeRemoteTerminalSurfaceIds.contains(panelId) - ? remoteConfiguration?.relayPort - : nil, - remoteCleanupConfiguration: transferredRemoteCleanupConfiguration + toWorkspace: workspace.id, + focus: true, + focusWindow: false ) - } else { - if let closedBrowserRestoreSnapshot { - onClosedBrowserPanel?(closedBrowserRestoreSnapshot) - } - panel?.close() - } - - panels.removeValue(forKey: panelId) - untrackRemoteTerminalSurface(panelId) - pendingRemoteTerminalChildExitSurfaceIds.remove(panelId) - surfaceIdToPanelId.removeValue(forKey: tabId) - removeSurfaceMetadata(panelId: panelId) - syncRemotePortScanTTYs() - recomputeListeningPorts() - clearRemoteConfigurationIfWorkspaceBecameLocal() - if !isDetaching, let transferredRemoteCleanupConfiguration { - Self.requestSSHControlMasterCleanupIfNeeded(configuration: transferredRemoteCleanupConfiguration) - } - // Keep the workspace invariant for normal close paths. - // Detach/move flows intentionally allow a temporary empty workspace so AppDelegate can - // prune the source workspace/window after the tab is attached elsewhere. - if panels.isEmpty { - if isDetaching { - scheduleTerminalGeometryReconcile() + case .selectedWorkspaceInNewWindow: + let newWindowId = app.createMainWindow() + guard let destinationManager = app.tabManagerFor(windowId: newWindowId), + let destinationWorkspaceId = destinationManager.selectedTabId else { return } - - let replacement = createReplacementTerminalPanel() - if let replacementTabId = surfaceIdFromPanelId(replacement.id), - let replacementPane = bonsplitController.allPaneIds.first { - bonsplitController.focusPane(replacementPane) - bonsplitController.selectTab(replacementTabId) - applyTabSelection(tabId: replacementTabId, inPane: replacementPane) + moved = app.moveSurface( + panelId: panelId, + toWorkspace: destinationWorkspaceId, + focus: true, + focusWindow: true + ) + if !moved { + _ = app.closeMainWindow(windowId: newWindowId) } - scheduleTerminalGeometryReconcile() - scheduleFocusReconcile() - return - } - - if let selectTabId, - bonsplitController.allPaneIds.contains(pane), - bonsplitController.tabs(inPane: pane).contains(where: { $0.id == selectTabId }), - bonsplitController.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) - applyTabSelection(tabId: selectTabId, inPane: pane) - } else if let focusedPane = bonsplitController.focusedPaneId, - let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id { - // When closing the last tab in a pane, Bonsplit may focus a different pane and skip - // emitting didSelectTab. Re-apply the focused selection so sidebar state stays in sync. - applyTabSelection(tabId: focusedTabId, inPane: focusedPane) - } - if bonsplitController.allPaneIds.contains(pane) { - normalizePinnedTabs(in: pane) - } - scheduleTerminalGeometryReconcile() - if !isDetaching { - scheduleFocusReconcile() + case .existingWorkspace(let workspaceId): + moved = app.moveSurface( + panelId: panelId, + toWorkspace: workspaceId, + focus: true, + focusWindow: true + ) } - } - - func splitTabBar(_ controller: BonsplitController, didSelectTab tab: Bonsplit.Tab, inPane pane: PaneID) { - applyTabSelection(tabId: tab.id, inPane: pane) - } - func splitTabBar(_ controller: BonsplitController, didMoveTab tab: Bonsplit.Tab, fromPane source: PaneID, toPane destination: PaneID) { -#if DEBUG - let now = ProcessInfo.processInfo.systemUptime - let sincePrev: String - if debugLastDidMoveTabTimestamp > 0 { - sincePrev = String(format: "%.2f", (now - debugLastDidMoveTabTimestamp) * 1000) - } else { - sincePrev = "first" - } - debugLastDidMoveTabTimestamp = now - debugDidMoveTabEventCount += 1 - let movedPanelId = panelIdFromSurfaceId(tab.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 focusedPanelBefore = focusedPanelId?.uuidString.prefix(5) ?? "nil" - 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)" - ) - dlog( - "split.moveTab.state.before idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " + - "destSelected=\(selectedBefore) focusedPane=\(focusedPaneBefore) focusedPanel=\(focusedPanelBefore)" - ) -#endif - applyTabSelection(tabId: tab.id, inPane: destination) -#if DEBUG - let movedPanelIdAfter = panelIdFromSurfaceId(tab.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 focusedPanelAfter = focusedPanelId?.uuidString.prefix(5) ?? "nil" - let movedPanelFocused = (movedPanelIdAfter != nil && movedPanelIdAfter == focusedPanelId) ? 1 : 0 - dlog( - "split.moveTab.state.after idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " + - "destSelected=\(selectedAfter) focusedPane=\(focusedPaneAfter) focusedPanel=\(focusedPanelAfter) " + - "movedFocused=\(movedPanelFocused)" - ) -#endif - normalizePinnedTabs(in: source) - normalizePinnedTabs(in: destination) - scheduleTerminalGeometryReconcile() - if !isDetachingCloseTransaction { - scheduleFocusReconcile() + if !moved { + let failure = NSAlert() + failure.alertStyle = .warning + failure.messageText = String(localized: "alert.moveTab.failed.title", defaultValue: "Move Failed") + failure.informativeText = String(localized: "alert.moveTab.failed.message", defaultValue: "Programa could not move this tab to the selected destination.") + failure.addButton(withTitle: String(localized: "alert.ok", defaultValue: "OK")) + _ = failure.runModal() } } - func splitTabBar(_ controller: BonsplitController, didFocusPane pane: PaneID) { - // When a pane is focused, focus its selected tab's panel - guard let tab = controller.selectedTab(inPane: pane) else { return } + func handleExternalTabDrop(_ request: BonsplitController.ExternalTabDropRequest) -> Bool { + guard let app = AppDelegate.shared else { return false } #if DEBUG - FocusLogStore.shared.append( - "Workspace.didFocusPane paneId=\(pane.id.uuidString) tabId=\(tab.id) focusedPane=\(controller.focusedPaneId?.id.uuidString ?? "nil")" - ) + let dropStart = ProcessInfo.processInfo.systemUptime #endif - applyTabSelection(tabId: tab.id, inPane: pane) - - // Apply window background for terminal - if let panelId = panelIdFromSurfaceId(tab.id), - let terminalPanel = panels[panelId] as? TerminalPanel { - terminalPanel.applyWindowBackgroundIfActive() - } - } - - /// Canonical per-panel metadata teardown shared by every close path. - /// The single-surface and pane-close paths previously hand-copied this - /// list and drifted (pane close leaked inheritance font points and never - /// cleared notifications). Aggregate recomputes (syncRemotePortScanTTYs, - /// recomputeListeningPorts) stay at the call sites. - private func removeSurfaceMetadata(panelId: UUID) { - 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) - panelSubscriptions.removeValue(forKey: panelId) - panelShellActivityStates.removeValue(forKey: panelId) - surfaceTTYNames.removeValue(forKey: panelId) - surfaceListeningPorts.removeValue(forKey: panelId) - restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) - terminalInheritanceFontPointsByPanelId.removeValue(forKey: panelId) - if lastTerminalConfigInheritancePanelId == panelId { - lastTerminalConfigInheritancePanelId = nil - } - PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) - AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId) - } - - func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) { - let closedPanelIds = pendingPaneClosePanelIds.removeValue(forKey: paneId.id) ?? [] - let shouldScheduleFocusReconcile = !isDetachingCloseTransaction - - if !closedPanelIds.isEmpty { - for panelId in closedPanelIds { - panels[panelId]?.close() - panels.removeValue(forKey: panelId) - untrackRemoteTerminalSurface(panelId) - pendingRemoteTerminalChildExitSurfaceIds.remove(panelId) - removeSurfaceMetadata(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 { - applyTabSelection(tabId: focusedTabId, inPane: focusedPane) - } else if shouldScheduleFocusReconcile { - scheduleFocusReconcile() - } - } - - scheduleTerminalGeometryReconcile() - if shouldScheduleFocusReconcile { - scheduleFocusReconcile() - } - } - - func splitTabBar(_ controller: BonsplitController, 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), - panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) { - pendingPaneClosePanelIds.removeValue(forKey: pane.id) - return false - } - } - pendingPaneClosePanelIds[pane.id] = tabs.compactMap { panelIdFromSurfaceId($0.id) } - return true - } - func splitTabBar(_ controller: BonsplitController, didSplitPane originalPane: PaneID, newPane: PaneID, orientation: SplitOrientation) { + let targetPane: PaneID + let targetIndex: Int? + let splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? #if DEBUG - let panelKindForTab: (TabID) -> String = { tabId in - guard let panelId = self.panelIdFromSurfaceId(tabId), - 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)) - }.joined(separator: ",") - } - let originalSelectedKind = controller.selectedTab(inPane: originalPane).map { panelKindForTab($0.id) } ?? "none" - let newSelectedKind = controller.selectedTab(inPane: newPane).map { panelKindForTab($0.id) } ?? "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) " + - "originalSelected=\(originalSelectedKind) newSelected=\(newSelectedKind) " + - "originalKinds=[\(paneKindSummary(originalPane))] newKinds=[\(paneKindSummary(newPane))]" - ) + let destinationLabel: String #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 { - continue - } - browserPanel.preparePortalHostReplacementForNextDistinctClaim( - inPane: paneId, - reason: reason - ) - } - } - 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. - 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 DEBUG - 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) " + - "originalKinds=[\(paneKindSummary(originalPane))] newKinds=[\(paneKindSummary(newPane))]" - ) -#endif - if !hasRealSurface { - let placeholderTabs = originalTabs.filter { panelIdFromSurfaceId($0.id) == nil } + switch request.destination { + case .insert(let paneId, let index): + targetPane = paneId + targetIndex = index + splitTarget = nil #if DEBUG - dlog( - "split.placeholderRepair pane=\(originalPane.id.uuidString.prefix(5)) " + - "action=reusePlaceholder placeholderCount=\(placeholderTabs.count)" - ) + destinationLabel = "insert pane=\(paneId.id.uuidString.prefix(5)) index=\(index.map(String.init) ?? "nil")" #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 { + case .split(let paneId, let orientation, let insertFirst): + targetPane = paneId + targetIndex = nil + splitTarget = (orientation, insertFirst) #if DEBUG - dlog( - "split.placeholderRepair pane=\(originalPane.id.uuidString.prefix(5)) " + - "fallback=createTerminalAndDropPlaceholders" - ) + destinationLabel = "split pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation.rawValue) insertFirst=\(insertFirst ? 1 : 0)" #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) } - -#if DEBUG + #if DEBUG 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")" - ) -#endif - - let inheritedConfig = inheritedTerminalConfig( - preferredPanelId: sourcePanelId, - inPane: originalPane + "split.externalDrop.begin ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " + + "sourcePane=\(request.sourcePaneId.id.uuidString.prefix(5)) destination=\(destinationLabel)" ) - - let newPanel = TerminalPanel( - workspaceId: id, - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: inheritedConfig, - portOrdinal: portOrdinal + #endif + let moved = app.moveBonsplitTab( + tabId: request.tabId.uuid, + toWorkspace: id, + targetPane: targetPane, + targetIndex: targetIndex, + splitTarget: splitTarget, + focus: true, + focusWindow: true ) - configureTerminalPanel(newPanel) - panels[newPanel.id] = newPanel - panelTitles[newPanel.id] = newPanel.displayTitle - seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) - - guard let newTabId = bonsplitController.createTab( - title: newPanel.displayTitle, - icon: newPanel.displayIcon, - kind: SurfaceKind.terminal, - isDirty: newPanel.isDirty, - isPinned: false, - inPane: newPane - ) else { - panels.removeValue(forKey: newPanel.id) - panelTitles.removeValue(forKey: newPanel.id) - terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) - return - } - - surfaceIdToPanelId[newTabId] = newPanel.id - normalizePinnedTabs(in: newPane) #if DEBUG dlog( - "split.didSplit.autoCreate.done pane=\(newPane.id.uuidString.prefix(5)) " + - "panel=\(newPanel.id.uuidString.prefix(5))" + "split.externalDrop.end ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " + + "moved=\(moved ? 1 : 0) elapsedMs=\(debugElapsedMs(since: dropStart))" ) #endif - - // `createTab` selects the new tab but does not emit didSelectTab; schedule an explicit - // 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) - } - self.scheduleTerminalGeometryReconcile() - self.scheduleFocusReconcile() - } - } - - 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 splitTabBar(_ controller: BonsplitController, didRequestTabContextAction action: TabContextAction, for tab: Bonsplit.Tab, inPane pane: PaneID) { - switch action { - case .rename: - promptRenamePanel(tabId: tab.id) - case .clearName: - guard let panelId = panelIdFromSurfaceId(tab.id) else { return } - setPanelCustomTitle(panelId: panelId, title: nil) - case .closeToLeft: - closeTabs(tabIdsToLeft(of: tab.id, inPane: pane)) - case .closeToRight: - closeTabs(tabIdsToRight(of: tab.id, inPane: pane)) - case .closeOthers: - closeTabs(tabIdsToCloseOthers(of: tab.id, inPane: pane)) - case .move: - promptMovePanel(tabId: tab.id) - case .newTerminalToRight: - createTerminalToRight(of: tab.id, inPane: pane) - case .newBrowserToRight: - createBrowserToRight(of: tab.id, inPane: pane) - case .reload: - guard let panelId = panelIdFromSurfaceId(tab.id), - let browser = browserPanel(for: panelId) else { return } - browser.reload() - case .duplicate: - duplicateBrowserToRight(anchorTabId: tab.id, inPane: pane) - case .togglePin: - guard let panelId = panelIdFromSurfaceId(tab.id) else { return } - let shouldPin = !pinnedPanelIds.contains(panelId) - setPanelPinned(panelId: panelId, pinned: shouldPin) - case .markAsRead: - guard let panelId = panelIdFromSurfaceId(tab.id) else { return } - clearManualUnread(panelId: panelId) - case .markAsUnread: - guard let panelId = panelIdFromSurfaceId(tab.id) else { return } - markPanelUnread(panelId) - case .toggleZoom: - guard let panelId = panelIdFromSurfaceId(tab.id) else { return } - toggleSplitZoom(panelId: panelId) - @unknown default: - break - } - } - - func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) { - tmuxLayoutSnapshot = snapshot - scheduleTerminalGeometryReconcile() - if !isDetachingCloseTransaction { - scheduleFocusReconcile() - } + return moved } - // No post-close polling refresh loop: we rely on view invariants and Ghostty's wakeups. } diff --git a/Sources/WorkspaceRemoteCLIRelayServer.swift b/Sources/WorkspaceRemoteCLIRelayServer.swift new file mode 100644 index 00000000000..77e77d0c621 --- /dev/null +++ b/Sources/WorkspaceRemoteCLIRelayServer.swift @@ -0,0 +1,491 @@ +// Extracted from WorkspaceRemoteDaemon.swift (nuclear-review #98): the CLI relay server exposed to the remote daemon. + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +final class WorkspaceRemoteCLIRelayServer { + private final class Session { + private enum Phase { + case awaitingAuth + case awaitingCommand + case forwarding + case closed + } + + private let connection: NWConnection + private let localSocketPath: String + private let relayID: String + private let relayToken: Data + private let queue: DispatchQueue + private let onClose: () -> Void + private let challengeProtocol = "programa-relay-auth" + private let challengeVersion = 1 + private let minimumFailureDelay: TimeInterval = 0.05 + private let maximumFrameBytes = 16 * 1024 + + private var buffer = Data() + private var phase: Phase = .awaitingAuth + private var challengeNonce = "" + private var challengeSentAt = Date() + private var isClosed = false + + init( + connection: NWConnection, + localSocketPath: String, + relayID: String, + relayToken: Data, + queue: DispatchQueue, + onClose: @escaping () -> Void + ) { + self.connection = connection + self.localSocketPath = localSocketPath + self.relayID = relayID + self.relayToken = relayToken + self.queue = queue + self.onClose = onClose + } + + func start() { + connection.stateUpdateHandler = { [weak self] state in + self?.queue.async { + self?.handleState(state) + } + } + connection.start(queue: queue) + } + + func stop() { + close() + } + + private func handleState(_ state: NWConnection.State) { + guard !isClosed else { return } + switch state { + case .ready: + sendChallenge() + receive() + case .failed, .cancelled: + close() + default: + break + } + } + + private func sendChallenge() { + challengeSentAt = Date() + challengeNonce = Self.randomHex(byteCount: 16) + let challenge: [String: Any] = [ + "protocol": challengeProtocol, + "version": challengeVersion, + "relay_id": relayID, + "nonce": challengeNonce, + ] + sendJSONLine(challenge) { _ in } + } + + private func receive() { + guard !isClosed else { return } + connection.receive(minimumIncompleteLength: 1, maximumLength: maximumFrameBytes) { [weak self] data, _, isComplete, error in + guard let self else { return } + self.queue.async { + if error != nil { + self.close() + return + } + if let data, !data.isEmpty { + self.buffer.append(data) + if self.buffer.count > self.maximumFrameBytes { + self.sendFailureAndClose() + return + } + self.processBufferedLines() + } + if isComplete { + self.close() + return + } + if !self.isClosed { + self.receive() + } + } + } + } + + private func processBufferedLines() { + while let newlineIndex = buffer.firstIndex(of: 0x0A), !isClosed { + let lineData = buffer.prefix(upTo: newlineIndex) + buffer.removeSubrange(...newlineIndex) + let line = String(data: lineData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + switch phase { + case .awaitingAuth: + handleAuthLine(line) + case .awaitingCommand: + handleCommandLine(Data(lineData) + Data([0x0A])) + case .forwarding, .closed: + return + } + } + } + + private func handleAuthLine(_ line: String) { + guard let data = line.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let receivedRelayID = object["relay_id"] as? String, + receivedRelayID == relayID, + let macHex = object["mac"] as? String, + let receivedMAC = Self.hexData(from: macHex) + else { + sendFailureAndClose() + return + } + + let message = Self.authMessage(relayID: relayID, nonce: challengeNonce, version: challengeVersion) + let expectedMAC = Self.authMAC(token: relayToken, message: message) + guard Self.constantTimeEqual(receivedMAC, expectedMAC) else { + sendFailureAndClose() + return + } + + phase = .awaitingCommand + sendJSONLine(["ok": true]) { [weak self] _ in + self?.queue.async { + self?.processBufferedLines() + } + } + } + + private func handleCommandLine(_ commandLine: Data) { + guard !commandLine.isEmpty else { + sendFailureAndClose() + return + } + phase = .forwarding + DispatchQueue.global(qos: .utility).async { [localSocketPath, commandLine, queue] in + let result = Result { try Self.roundTripUnixSocket(socketPath: localSocketPath, request: commandLine) } + queue.async { [weak self] in + guard let self else { return } + switch result { + case .success(let response): + self.connection.send(content: response, completion: .contentProcessed { [weak self] _ in + self?.queue.async { + self?.close() + } + }) + case .failure: + self.sendFailureAndClose() + } + } + } + } + + private func sendFailureAndClose() { + let elapsed = Date().timeIntervalSince(challengeSentAt) + let delay = max(0, minimumFailureDelay - elapsed) + phase = .closed + queue.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.sendJSONLine(["ok": false]) { [weak self] _ in + self?.queue.async { + self?.close() + } + } + } + } + + private func sendJSONLine(_ object: [String: Any], completion: @escaping (NWError?) -> Void) { + guard !isClosed else { + completion(nil) + return + } + guard let payload = try? JSONSerialization.data(withJSONObject: object) else { + completion(nil) + return + } + connection.send(content: payload + Data([0x0A]), completion: .contentProcessed(completion)) + } + + private func close() { + guard !isClosed else { return } + isClosed = true + phase = .closed + connection.stateUpdateHandler = nil + connection.cancel() + onClose() + } + + private static func authMessage(relayID: String, nonce: String, version: Int) -> Data { + Data("relay_id=\(relayID)\nnonce=\(nonce)\nversion=\(version)".utf8) + } + + private static func authMAC(token: Data, message: Data) -> Data { + let key = SymmetricKey(data: token) + let code = HMAC.authenticationCode(for: message, using: key) + return Data(code) + } + + private static func constantTimeEqual(_ lhs: Data, _ rhs: Data) -> Bool { + guard lhs.count == rhs.count else { return false } + var diff: UInt8 = 0 + for index in lhs.indices { + diff |= lhs[index] ^ rhs[index] + } + return diff == 0 + } + + fileprivate static func hexData(from string: String) -> Data? { + let normalized = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalized.count.isMultiple(of: 2), !normalized.isEmpty else { return nil } + var data = Data(capacity: normalized.count / 2) + var cursor = normalized.startIndex + while cursor < normalized.endIndex { + let next = normalized.index(cursor, offsetBy: 2) + guard let byte = UInt8(normalized[cursor.. String { + var bytes = [UInt8](repeating: 0, count: byteCount) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + return bytes.map { String(format: "%02x", $0) }.joined() + } + + private static func roundTripUnixSocket(socketPath: String, request: Data) throws -> Data { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw NSError(domain: "programa.remote.relay", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "failed to create local relay socket", + ]) + } + defer { Darwin.close(fd) } + + var timeout = timeval(tv_sec: 15, tv_usec: 0) + withUnsafePointer(to: &timeout) { pointer in + _ = setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, pointer, socklen_t(MemoryLayout.size)) + _ = setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, pointer, socklen_t(MemoryLayout.size)) + } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let pathBytes = Array(socketPath.utf8CString) + guard pathBytes.count <= MemoryLayout.size(ofValue: address.sun_path) else { + throw NSError(domain: "programa.remote.relay", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "local relay socket path is too long", + ]) + } + let sunPathOffset = MemoryLayout.offset(of: \.sun_path) ?? 0 + withUnsafeMutableBytes(of: &address) { rawBuffer in + let destination = rawBuffer.baseAddress!.advanced(by: sunPathOffset) + pathBytes.withUnsafeBytes { pathBuffer in + destination.copyMemory(from: pathBuffer.baseAddress!, byteCount: pathBytes.count) + } + } + + let addressLength = socklen_t(MemoryLayout.size(ofValue: address.sun_family) + pathBytes.count) + let connectResult = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(fd, $0, addressLength) + } + } + guard connectResult == 0 else { + throw NSError(domain: "programa.remote.relay", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "failed to connect to local cmux socket", + ]) + } + + try request.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return } + var bytesRemaining = rawBuffer.count + var pointer = baseAddress + while bytesRemaining > 0 { + let written = Darwin.write(fd, pointer, bytesRemaining) + if written <= 0 { + throw NSError(domain: "programa.remote.relay", code: 4, userInfo: [ + NSLocalizedDescriptionKey: "failed to write relay request", + ]) + } + bytesRemaining -= written + pointer = pointer.advanced(by: written) + } + } + _ = shutdown(fd, SHUT_WR) + + var response = Data() + var scratch = [UInt8](repeating: 0, count: 4096) + while true { + let count = Darwin.read(fd, &scratch, scratch.count) + if count > 0 { + response.append(scratch, count: count) + continue + } + if count == 0 { + break + } + + if errno == EAGAIN || errno == EWOULDBLOCK { + if !response.isEmpty { + break + } + throw NSError(domain: "programa.remote.relay", code: 5, userInfo: [ + NSLocalizedDescriptionKey: "timed out waiting for local cmux response", + ]) + } + throw NSError(domain: "programa.remote.relay", code: 6, userInfo: [ + NSLocalizedDescriptionKey: "failed to read local cmux response", + ]) + } + return response + } + } + + private let localSocketPath: String + private let relayID: String + private let relayToken: Data + private let queue = DispatchQueue(label: "com.cmux.remote-ssh.cli-relay.\(UUID().uuidString)", qos: .utility) + + private var listener: NWListener? + private var sessions: [UUID: Session] = [:] + private var isStopped = false + private(set) var localPort: Int? + + init(localSocketPath: String, relayID: String, relayTokenHex: String) throws { + guard let relayToken = Session.hexData(from: relayTokenHex), !relayToken.isEmpty else { + throw NSError(domain: "programa.remote.relay", code: 7, userInfo: [ + NSLocalizedDescriptionKey: "invalid relay token", + ]) + } + self.localSocketPath = localSocketPath + self.relayID = relayID + self.relayToken = relayToken + } + + func start() throws -> Int { + if let existingPort = queue.sync(execute: { localPort }) { + return existingPort + } + + let listener = try Self.makeLoopbackListener() + let readySemaphore = DispatchSemaphore(value: 0) + let stateLock = NSLock() + var capturedError: Error? + var boundPort: Int? + + listener.newConnectionHandler = { [weak self] connection in + self?.queue.async { + self?.acceptConnectionLocked(connection) + } + } + listener.stateUpdateHandler = { listenerState in + switch listenerState { + case .ready: + stateLock.lock() + boundPort = listener.port.map { Int($0.rawValue) } + stateLock.unlock() + readySemaphore.signal() + case .failed(let error): + stateLock.lock() + capturedError = error + stateLock.unlock() + readySemaphore.signal() + default: + break + } + } + listener.start(queue: queue) + + let waitResult = readySemaphore.wait(timeout: .now() + 5.0) + stateLock.lock() + let startupError = capturedError + let startupPort = boundPort + stateLock.unlock() + + if waitResult != .success { + listener.newConnectionHandler = nil + listener.stateUpdateHandler = nil + listener.cancel() + throw NSError(domain: "programa.remote.relay", code: 8, userInfo: [ + NSLocalizedDescriptionKey: "timed out waiting for local relay listener", + ]) + } + if let startupError { + listener.newConnectionHandler = nil + listener.stateUpdateHandler = nil + listener.cancel() + throw startupError + } + guard let startupPort, startupPort > 0 else { + listener.newConnectionHandler = nil + listener.stateUpdateHandler = nil + listener.cancel() + throw NSError(domain: "programa.remote.relay", code: 8, userInfo: [ + NSLocalizedDescriptionKey: "failed to bind local relay listener", + ]) + } + + return queue.sync { + if let localPort { + listener.newConnectionHandler = nil + listener.stateUpdateHandler = nil + listener.cancel() + return localPort + } + self.listener = listener + self.localPort = startupPort + return startupPort + } + } + + func stop() { + queue.sync { + guard !isStopped else { return } + isStopped = true + listener?.newConnectionHandler = nil + listener?.stateUpdateHandler = nil + listener?.cancel() + listener = nil + localPort = nil + let activeSessions = sessions.values + sessions.removeAll() + for session in activeSessions { + session.stop() + } + } + } + + private func acceptConnectionLocked(_ connection: NWConnection) { + guard !isStopped else { + connection.cancel() + return + } + let sessionID = UUID() + let session = Session( + connection: connection, + localSocketPath: localSocketPath, + relayID: relayID, + relayToken: relayToken, + queue: queue + ) { [weak self] in + self?.sessions.removeValue(forKey: sessionID) + } + sessions[sessionID] = session + session.start() + } + + private static func makeLoopbackListener() throws -> NWListener { + let tcpOptions = NWProtocolTCP.Options() + tcpOptions.noDelay = true + let parameters = NWParameters(tls: nil, tcp: tcpOptions) + parameters.allowLocalEndpointReuse = true + parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: .any) + return try NWListener(using: parameters) + } +} diff --git a/Sources/WorkspaceRemoteDaemon.swift b/Sources/WorkspaceRemoteDaemon.swift deleted file mode 100644 index 7e97c7e9701..00000000000 --- a/Sources/WorkspaceRemoteDaemon.swift +++ /dev/null @@ -1,2300 +0,0 @@ -// Extracted from Workspace.swift (nuclear-review N5): the remote-session -// infrastructure below is self-contained — its only edge back to Workspace -// is the weak reference held by WorkspaceRemoteSessionController. - -import Foundation -import SwiftUI -import AppKit -import Bonsplit -import Combine -import CryptoKit -import Darwin -import Network -import CoreText - -final class WorkspaceRemoteDaemonPendingCallRegistry { - final class PendingCall { - let id: Int - fileprivate let semaphore = DispatchSemaphore(value: 0) - fileprivate var response: [String: Any]? - fileprivate var failureMessage: String? - - fileprivate init(id: Int) { - self.id = id - } - } - - enum WaitOutcome { - case response([String: Any]) - case failure(String) - case missing - case timedOut - } - - private let queue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.pending.\(UUID().uuidString)") - private var nextRequestID = 1 - private var pendingCalls: [Int: PendingCall] = [:] - - func reset() { - queue.sync { - nextRequestID = 1 - pendingCalls.removeAll(keepingCapacity: false) - } - } - - func register() -> PendingCall { - queue.sync { - let call = PendingCall(id: nextRequestID) - nextRequestID += 1 - pendingCalls[call.id] = call - return call - } - } - - @discardableResult - func resolve(id: Int, payload: [String: Any]) -> Bool { - queue.sync { - guard let pendingCall = pendingCalls[id] else { return false } - pendingCall.response = payload - pendingCall.semaphore.signal() - return true - } - } - - func failAll(_ message: String) { - queue.sync { - let calls = Array(pendingCalls.values) - for call in calls { - guard call.response == nil, call.failureMessage == nil else { continue } - call.failureMessage = message - call.semaphore.signal() - } - } - } - - func remove(_ call: PendingCall) { - _ = queue.sync { - pendingCalls.removeValue(forKey: call.id) - } - } - - func wait(for call: PendingCall, timeout: TimeInterval) -> WaitOutcome { - if call.semaphore.wait(timeout: .now() + timeout) == .timedOut { - _ = queue.sync { - pendingCalls.removeValue(forKey: call.id) - } - // A response can win the race immediately before timeout cleanup removes the call. - // Drain any late signal so DispatchSemaphore is not deallocated with a positive count. - _ = call.semaphore.wait(timeout: .now()) - return .timedOut - } - - return queue.sync { - guard let pendingCall = pendingCalls.removeValue(forKey: call.id) else { - return .missing - } - if let failure = pendingCall.failureMessage { - return .failure(failure) - } - guard let response = pendingCall.response else { - return .missing - } - return .response(response) - } - } -} - -enum WorkspaceRemoteSSHBatchCommandBuilder { - private static let batchSSHControlOptionKeys: Set = [ - "controlmaster", - "controlpersist", - ] - - static func daemonTransportArguments( - configuration: WorkspaceRemoteConfiguration, - remotePath: String - ) -> [String] { - let script = "exec \(shellSingleQuoted(remotePath)) serve --stdio" - let command = "sh -c \(shellSingleQuoted(script))" - return ["-T"] - + batchArguments(configuration: configuration) - + ["-o", "RequestTTY=no", configuration.destination, command] - } - - static func reverseRelayControlMasterArguments( - configuration: WorkspaceRemoteConfiguration, - controlCommand: String, - forwardSpec: String - ) -> [String]? { - guard let controlPath = sshOptionValue(named: "ControlPath", in: configuration.sshOptions)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !controlPath.isEmpty, - controlPath.lowercased() != "none" else { - return nil - } - - var args = batchArguments(configuration: configuration) - args += ["-O", controlCommand, "-R", forwardSpec, configuration.destination] - return args - } - - private static func batchArguments(configuration: WorkspaceRemoteConfiguration) -> [String] { - let effectiveSSHOptions = backgroundSSHOptions(configuration.sshOptions) - var args: [String] = [ - "-o", "ConnectTimeout=6", - "-o", "ServerAliveInterval=20", - "-o", "ServerAliveCountMax=2", - ] - if !hasSSHOptionKey(effectiveSSHOptions, key: "StrictHostKeyChecking") { - args += ["-o", "StrictHostKeyChecking=accept-new"] - } - args += ["-o", "BatchMode=yes"] - // Batch helpers may reuse an existing ControlPath, but must not negotiate a new master. - args += ["-o", "ControlMaster=no"] - if let port = configuration.port { - args += ["-p", String(port)] - } - if let identityFile = configuration.identityFile, - !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args += ["-i", identityFile] - } - for option in effectiveSSHOptions { - args += ["-o", option] - } - return args - } - - private static func hasSSHOptionKey(_ options: [String], key: String) -> Bool { - let loweredKey = key.lowercased() - for option in options { - if sshOptionKey(option) == loweredKey { - return true - } - } - return false - } - - private static func normalizedSSHOptions(_ options: [String]) -> [String] { - options.compactMap { option in - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - } - } - - private static func backgroundSSHOptions(_ options: [String]) -> [String] { - normalizedSSHOptions(options).filter { option in - guard let key = sshOptionKey(option) else { return false } - return !batchSSHControlOptionKeys.contains(key) - } - } - - private static func sshOptionValue(named key: String, in options: [String]) -> String? { - let loweredKey = key.lowercased() - for option in normalizedSSHOptions(options) { - let parts = option.split( - maxSplits: 1, - omittingEmptySubsequences: true, - whereSeparator: { $0 == "=" || $0.isWhitespace } - ) - guard parts.count == 2, parts[0].lowercased() == loweredKey else { - continue - } - let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - if !value.isEmpty { - return value - } - } - return nil - } - - private static func sshOptionKey(_ option: String) -> String? { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) - .first - .map(String.init)? - .lowercased() - } - - private static func shellSingleQuoted(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" - } -} - -final class WorkspaceRemoteDaemonRPCClient { - private static let maxStdoutBufferBytes = 256 * 1024 - static let requiredProxyStreamCapability = "proxy.stream.push" - - enum StreamEvent { - case data(Data) - case eof(Data) - case error(String) - } - - private struct StreamSubscription { - let queue: DispatchQueue - let handler: (StreamEvent) -> Void - } - - private let configuration: WorkspaceRemoteConfiguration - private let remotePath: String - private let onUnexpectedTermination: (String) -> Void - private let writeQueue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.write.\(UUID().uuidString)") - private let stateQueue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.state.\(UUID().uuidString)") - private let pendingCalls = WorkspaceRemoteDaemonPendingCallRegistry() - - private var process: Process? - private var stdinPipe: Pipe? - private var stdoutPipe: Pipe? - private var stderrPipe: Pipe? - private var stdinHandle: FileHandle? - private var stdoutHandle: FileHandle? - private var stderrHandle: FileHandle? - private var isClosed = true - private var shouldReportTermination = true - - private var stdoutBuffer = Data() - private var stderrBuffer = "" - private var streamSubscriptions: [String: StreamSubscription] = [:] - - init( - configuration: WorkspaceRemoteConfiguration, - remotePath: String, - onUnexpectedTermination: @escaping (String) -> Void - ) { - self.configuration = configuration - self.remotePath = remotePath - self.onUnexpectedTermination = onUnexpectedTermination - } - - func start() throws { - let process = Process() - let stdinPipe = Pipe() - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - - stateQueue.sync { - self.stdinPipe = stdinPipe - self.stdoutPipe = stdoutPipe - self.stderrPipe = stderrPipe - } - - process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") - process.arguments = Self.daemonArguments(configuration: configuration, remotePath: remotePath) - process.standardInput = stdinPipe - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - self?.stateQueue.async { - self?.consumeStdoutData(data) - } - } - stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - self?.stateQueue.async { - self?.consumeStderrData(data) - } - } - process.terminationHandler = { [weak self] terminated in - self?.stateQueue.async { - self?.handleProcessTermination(terminated) - } - } - - do { - try process.run() - } catch { - throw NSError(domain: "programa.remote.daemon.rpc", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Failed to launch SSH daemon transport: \(error.localizedDescription)", - ]) - } - - stateQueue.sync { - self.process = process - self.stdinHandle = stdinPipe.fileHandleForWriting - self.stdoutHandle = stdoutPipe.fileHandleForReading - self.stderrHandle = stderrPipe.fileHandleForReading - self.isClosed = false - self.shouldReportTermination = true - self.stdoutBuffer = Data() - self.stderrBuffer = "" - self.streamSubscriptions.removeAll(keepingCapacity: false) - } - pendingCalls.reset() - - do { - let hello = try call(method: "hello", params: [:], timeout: 8.0) - let capabilities = (hello["capabilities"] as? [String]) ?? [] - guard capabilities.contains(Self.requiredProxyStreamCapability) else { - throw NSError(domain: "programa.remote.daemon.rpc", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon missing required capability \(Self.requiredProxyStreamCapability)", - ]) - } - } catch { - stop(suppressTerminationCallback: true) - throw error - } - } - - func stop() { - stop(suppressTerminationCallback: true) - } - - func openStream(host: String, port: Int, timeoutMs: Int = 10000) throws -> String { - let result = try call( - method: "proxy.open", - params: [ - "host": host, - "port": port, - "timeout_ms": timeoutMs, - ], - timeout: 12.0 - ) - let streamID = (result["stream_id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !streamID.isEmpty else { - throw NSError(domain: "programa.remote.daemon.rpc", code: 3, userInfo: [ - NSLocalizedDescriptionKey: "proxy.open missing stream_id", - ]) - } - return streamID - } - - func writeStream(streamID: String, data: Data) throws { - _ = try call( - method: "proxy.write", - params: [ - "stream_id": streamID, - "data_base64": data.base64EncodedString(), - ], - timeout: 8.0 - ) - } - - func attachStream( - streamID: String, - queue: DispatchQueue, - onEvent: @escaping (StreamEvent) -> Void - ) throws { - let trimmedStreamID = streamID.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedStreamID.isEmpty else { - throw NSError(domain: "programa.remote.daemon.rpc", code: 17, userInfo: [ - NSLocalizedDescriptionKey: "proxy.stream.subscribe requires stream_id", - ]) - } - - stateQueue.sync { - streamSubscriptions[trimmedStreamID] = StreamSubscription(queue: queue, handler: onEvent) - } - - do { - _ = try call( - method: "proxy.stream.subscribe", - params: ["stream_id": trimmedStreamID], - timeout: 8.0 - ) - } catch { - unregisterStream(streamID: trimmedStreamID) - throw error - } - } - - func unregisterStream(streamID: String) { - let trimmedStreamID = streamID.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedStreamID.isEmpty else { return } - _ = stateQueue.sync { - streamSubscriptions.removeValue(forKey: trimmedStreamID) - } - } - - func closeStream(streamID: String) { - unregisterStream(streamID: streamID) - _ = try? call( - method: "proxy.close", - params: ["stream_id": streamID], - timeout: 4.0 - ) - } - - private func call(method: String, params: [String: Any], timeout: TimeInterval) throws -> [String: Any] { - let pendingCall = pendingCalls.register() - let requestID = pendingCall.id - - let payload: Data - do { - payload = try Self.encodeJSON([ - "id": requestID, - "method": method, - "params": params, - ]) - } catch { - pendingCalls.remove(pendingCall) - throw NSError(domain: "programa.remote.daemon.rpc", code: 10, userInfo: [ - NSLocalizedDescriptionKey: "failed to encode daemon RPC request \(method): \(error.localizedDescription)", - ]) - } - - do { - try writeQueue.sync { - try writePayload(payload) - } - } catch { - pendingCalls.remove(pendingCall) - throw error - } - - let response: [String: Any] - switch pendingCalls.wait(for: pendingCall, timeout: timeout) { - case .timedOut: - stop(suppressTerminationCallback: false) - throw NSError(domain: "programa.remote.daemon.rpc", code: 11, userInfo: [ - NSLocalizedDescriptionKey: "daemon RPC timeout waiting for \(method) response", - ]) - case .failure(let failure): - throw NSError(domain: "programa.remote.daemon.rpc", code: 12, userInfo: [ - NSLocalizedDescriptionKey: failure, - ]) - case .missing: - throw NSError(domain: "programa.remote.daemon.rpc", code: 13, userInfo: [ - NSLocalizedDescriptionKey: "daemon RPC \(method) returned empty response", - ]) - case .response(let pendingResponse): - response = pendingResponse - } - - let ok = (response["ok"] as? Bool) ?? false - if ok { - return (response["result"] as? [String: Any]) ?? [:] - } - - let errorObject = (response["error"] as? [String: Any]) ?? [:] - let code = (errorObject["code"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "rpc_error" - let message = (errorObject["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "daemon RPC call failed" - throw NSError(domain: "programa.remote.daemon.rpc", code: 14, userInfo: [ - NSLocalizedDescriptionKey: "\(method) failed (\(code)): \(message)", - ]) - } - - private func writePayload(_ payload: Data) throws { - let stdinHandle: FileHandle = stateQueue.sync { - self.stdinHandle ?? FileHandle.nullDevice - } - if stdinHandle === FileHandle.nullDevice { - throw NSError(domain: "programa.remote.daemon.rpc", code: 15, userInfo: [ - NSLocalizedDescriptionKey: "daemon transport is not connected", - ]) - } - do { - try stdinHandle.write(contentsOf: payload) - try stdinHandle.write(contentsOf: Data([0x0A])) - } catch { - stop(suppressTerminationCallback: false) - throw NSError(domain: "programa.remote.daemon.rpc", code: 16, userInfo: [ - NSLocalizedDescriptionKey: "failed writing daemon RPC request: \(error.localizedDescription)", - ]) - } - } - - private func consumeStdoutData(_ data: Data) { - guard !data.isEmpty else { - signalPendingFailureLocked("daemon transport closed stdout") - return - } - - stdoutBuffer.append(data) - if stdoutBuffer.count > Self.maxStdoutBufferBytes { - stdoutBuffer.removeAll(keepingCapacity: false) - signalPendingFailureLocked("daemon transport stdout exceeded \(Self.maxStdoutBufferBytes) bytes without message framing") - process?.terminate() - return - } - while let newlineIndex = stdoutBuffer.firstIndex(of: 0x0A) { - var lineData = Data(stdoutBuffer[.. 8192 { - stderrBuffer.removeFirst(stderrBuffer.count - 8192) - } - } - - private func consumeEventPayload(_ payload: [String: Any]) { - guard let eventName = (payload["event"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !eventName.isEmpty, - let streamID = (payload["stream_id"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !streamID.isEmpty else { - return - } - - let subscription: StreamSubscription? - let event: StreamEvent? - switch eventName { - case "proxy.stream.data": - subscription = streamSubscriptions[streamID] - event = .data(Self.decodeBase64Data(payload["data_base64"])) - - case "proxy.stream.eof": - subscription = streamSubscriptions.removeValue(forKey: streamID) - event = .eof(Self.decodeBase64Data(payload["data_base64"])) - - case "proxy.stream.error": - subscription = streamSubscriptions.removeValue(forKey: streamID) - let detail = ((payload["error"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 } - ?? "stream error" - event = .error(detail) - - default: - return - } - - guard let subscription, let event else { return } - subscription.queue.async { - subscription.handler(event) - } - } - - private func handleProcessTermination(_ process: Process) { - let shouldNotify: Bool = { - guard self.process === process else { return false } - return !isClosed && shouldReportTermination - }() - let detail = Self.bestErrorLine(stderr: stderrBuffer) ?? "daemon transport exited with status \(process.terminationStatus)" - - isClosed = true - self.process = nil - stdinPipe = nil - stdoutPipe = nil - stderrPipe = nil - stdinHandle = nil - stdoutHandle?.readabilityHandler = nil - stdoutHandle = nil - stderrHandle?.readabilityHandler = nil - stderrHandle = nil - streamSubscriptions.removeAll(keepingCapacity: false) - signalPendingFailureLocked(detail) - - guard shouldNotify else { return } - onUnexpectedTermination(detail) - } - - private func stop(suppressTerminationCallback: Bool) { - let captured: (Process?, FileHandle?, FileHandle?, FileHandle?, Bool, String) = stateQueue.sync { - let detail = Self.bestErrorLine(stderr: stderrBuffer) ?? "daemon transport stopped" - let shouldNotify = !suppressTerminationCallback && !isClosed - shouldReportTermination = !suppressTerminationCallback - if isClosed { - return (nil, nil, nil, nil, false, detail) - } - - isClosed = true - signalPendingFailureLocked("daemon transport stopped") - let capturedProcess = process - let capturedStdin = stdinHandle - let capturedStdout = stdoutHandle - let capturedStderr = stderrHandle - - process = nil - stdinPipe = nil - stdoutPipe = nil - stderrPipe = nil - stdinHandle = nil - stdoutHandle = nil - stderrHandle = nil - streamSubscriptions.removeAll(keepingCapacity: false) - return (capturedProcess, capturedStdin, capturedStdout, capturedStderr, shouldNotify, detail) - } - - captured.2?.readabilityHandler = nil - captured.3?.readabilityHandler = nil - try? captured.1?.close() - try? captured.2?.close() - try? captured.3?.close() - if let process = captured.0, process.isRunning { - process.terminate() - } - if captured.4 { - onUnexpectedTermination(captured.5) - } - } - - private func signalPendingFailureLocked(_ message: String) { - pendingCalls.failAll(message) - } - - private static func responseID(in payload: [String: Any]) -> Int? { - if let intValue = payload["id"] as? Int { - return intValue - } - if let numberValue = payload["id"] as? NSNumber { - return numberValue.intValue - } - return nil - } - - private static func decodeBase64Data(_ value: Any?) -> Data { - guard let encoded = value as? String, !encoded.isEmpty else { return Data() } - return Data(base64Encoded: encoded) ?? Data() - } - - private static func encodeJSON(_ object: [String: Any]) throws -> Data { - try JSONSerialization.data(withJSONObject: object, options: []) - } - - private static func daemonArguments(configuration: WorkspaceRemoteConfiguration, remotePath: String) -> [String] { - WorkspaceRemoteSSHBatchCommandBuilder.daemonTransportArguments( - configuration: configuration, - remotePath: remotePath - ) - } - - private static func shellSingleQuoted(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" - } - - private static func bestErrorLine(stderr: String) -> String? { - let lines = stderr - .split(separator: "\n") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - - for line in lines.reversed() where !isNoiseLine(line) { - return line - } - return lines.last - } - - private static func isNoiseLine(_ line: String) -> Bool { - let lowered = line.lowercased() - if lowered.hasPrefix("warning: permanently added") { return true } - if lowered.hasPrefix("debug") { return true } - if lowered.hasPrefix("transferred:") { return true } - if lowered.hasPrefix("openbsd_") { return true } - if lowered.contains("pseudo-terminal will not be allocated") { return true } - return false - } -} - -enum RemoteLoopbackHTTPRequestRewriter { - private static let headerDelimiter = Data([0x0d, 0x0a, 0x0d, 0x0a]) - private static let canonicalLoopbackHost = "localhost" - private static let requestLineMethods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "PRI"] - - static func rewriteIfNeeded(data: Data, aliasHost: String) -> Data { - rewriteIfNeeded(data: data, aliasHost: aliasHost, allowIncompleteHeadersAtEOF: false) - } - - static func rewriteIfNeeded(data: Data, aliasHost: String, allowIncompleteHeadersAtEOF: Bool) -> Data { - let headerData: Data - let remainder: Data - - if let headerRange = data.range(of: headerDelimiter) { - headerData = Data(data[.. Bool { - let trimmed = requestLine.trimmingCharacters(in: .whitespacesAndNewlines) - let method = trimmed.split(separator: " ", maxSplits: 1).first.map(String.init)?.uppercased() ?? "" - return requestLineMethods.contains(method) - } - - private static func rewriteRequestLine(_ requestLine: String, aliasHost: String) -> String { - let trimmed = requestLine.trimmingCharacters(in: .whitespacesAndNewlines) - let parts = trimmed.split(separator: " ", omittingEmptySubsequences: false) - guard parts.count >= 3 else { return requestLine } - - var components = URLComponents(string: String(parts[1])) - guard let host = components?.host, - BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else { - return requestLine - } - components?.host = canonicalLoopbackHost - guard let rewrittenURL = components?.string else { return requestLine } - - var rewritten = parts - rewritten[1] = Substring(rewrittenURL) - let leadingTrivia = requestLine.prefix { $0.isWhitespace || $0.isNewline } - let trailingTrivia = String(requestLine.reversed().prefix { $0.isWhitespace || $0.isNewline }.reversed()) - return String(leadingTrivia) + rewritten.joined(separator: " ") + trailingTrivia - } - - private static func rewriteHeaderLine(_ line: String, aliasHost: String) -> String { - guard let colonIndex = line.firstIndex(of: ":") else { return line } - let name = line[.. String? { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - if trimmed.hasPrefix("["), - let closing = trimmed.firstIndex(of: "]") { - let host = String(trimmed[trimmed.index(after: trimmed.startIndex).. String? { - var components = URLComponents(string: value) - guard let host = components?.host, - BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else { - return nil - } - components?.host = canonicalLoopbackHost - return components?.string - } -} - -struct RemoteLoopbackHTTPRequestStreamRewriter { - private static let maxHeaderBytes = 64 * 1024 - private static let headerDelimiter = Data([0x0D, 0x0A, 0x0D, 0x0A]) - - private let aliasHost: String - private var pendingHeaderBytes = Data() - private var hasForwardedHeaders = false - - init(aliasHost: String) { - self.aliasHost = aliasHost - } - - mutating func rewriteNextChunk(_ data: Data, eof: Bool) -> Data { - guard !hasForwardedHeaders else { return data } - - pendingHeaderBytes.append(data) - if pendingHeaderBytes.count > Self.maxHeaderBytes { - hasForwardedHeaders = true - let payload = pendingHeaderBytes - pendingHeaderBytes = Data() - return RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( - data: payload, - aliasHost: aliasHost, - allowIncompleteHeadersAtEOF: true - ) - } - - guard pendingHeaderBytes.range(of: Self.headerDelimiter) != nil else { - guard eof else { return Data() } - hasForwardedHeaders = true - let payload = pendingHeaderBytes - pendingHeaderBytes = Data() - return RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( - data: payload, - aliasHost: aliasHost, - allowIncompleteHeadersAtEOF: true - ) - } - - hasForwardedHeaders = true - let payload = pendingHeaderBytes - pendingHeaderBytes = Data() - return RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( - data: payload, - aliasHost: aliasHost - ) - } -} - -enum RemoteLoopbackHTTPResponseRewriter { - private static let headerDelimiter = Data([0x0d, 0x0a, 0x0d, 0x0a]) - private static let canonicalLoopbackHost = "localhost" - - static func rewriteIfNeeded(data: Data, aliasHost: String) -> Data { - guard let headerRange = data.range(of: headerDelimiter) else { return data } - let headerData = Data(data[.. String { - guard let colonIndex = line.firstIndex(of: ":") else { return line } - let name = line[.. String? { - var components = URLComponents(string: value) - guard let host = components?.host, - BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(canonicalLoopbackHost) else { - return nil - } - components?.host = aliasHost - return components?.string - } - - private static func rewriteCookieValue(_ value: String, aliasHost: String) -> String? { - let parts = value.split(separator: ";", omittingEmptySubsequences: false).map(String.init) - guard !parts.isEmpty else { return nil } - - var didRewrite = false - let rewrittenParts = parts.map { part -> String in - let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.lowercased().hasPrefix("domain=") else { return part } - let domainValue = String(trimmed.dropFirst("domain=".count)) - guard BrowserInsecureHTTPSettings.normalizeHost(domainValue) == BrowserInsecureHTTPSettings.normalizeHost(canonicalLoopbackHost) else { - return part - } - didRewrite = true - let leadingWhitespace = part.prefix { $0.isWhitespace } - return "\(leadingWhitespace)Domain=\(aliasHost)" - } - - return didRewrite ? rewrittenParts.joined(separator: ";") : nil - } -} - -private final class WorkspaceRemoteDaemonProxyTunnel { - private final class ProxySession { - private static let maxHandshakeBytes = 64 * 1024 - private static let remoteLoopbackProxyAliasHost = "programa-loopback.localtest.me" - - private enum HandshakeProtocol { - case undecided - case socks5 - case connect - } - - private enum SocksStage { - case greeting - case request - } - - private struct SocksRequest { - let host: String - let port: Int - let command: UInt8 - let consumedBytes: Int - } - - let id = UUID() - - private let connection: NWConnection - private let rpcClient: WorkspaceRemoteDaemonRPCClient - private let queue: DispatchQueue - private let onClose: (UUID) -> Void - - private var isClosed = false - private var protocolKind: HandshakeProtocol = .undecided - private var socksStage: SocksStage = .greeting - private var handshakeBuffer = Data() - private var streamID: String? - private var localInputEOF = false - private var rewritesLoopbackHTTPHeaders = false - private var loopbackRequestHeaderRewriter: RemoteLoopbackHTTPRequestStreamRewriter? - private var pendingRemoteHTTPHeaderBytes = Data() - private var hasForwardedRemoteHTTPHeaders = false - - init( - connection: NWConnection, - rpcClient: WorkspaceRemoteDaemonRPCClient, - queue: DispatchQueue, - onClose: @escaping (UUID) -> Void - ) { - self.connection = connection - self.rpcClient = rpcClient - self.queue = queue - self.onClose = onClose - } - - func start() { - connection.stateUpdateHandler = { [weak self] state in - guard let self else { return } - switch state { - case .failed(let error): - self.close(reason: "proxy client connection failed: \(error)") - case .cancelled: - self.close(reason: nil) - default: - break - } - } - connection.start(queue: queue) - receiveNext() - } - - func stop() { - close(reason: nil) - } - - private func receiveNext() { - guard !isClosed else { return } - connection.receive(minimumIncompleteLength: 1, maximumLength: 32768) { [weak self] data, _, isComplete, error in - guard let self, !self.isClosed else { return } - - if let data, !data.isEmpty { - if self.streamID == nil { - if self.handshakeBuffer.count + data.count > Self.maxHandshakeBytes { - self.close(reason: "proxy handshake exceeded \(Self.maxHandshakeBytes) bytes") - return - } - self.handshakeBuffer.append(data) - self.processHandshakeBuffer() - } else { - self.forwardToRemote(data, eof: isComplete) - } - } - - if isComplete { - // Treat local EOF as a half-close: keep remote read loop alive so we can - // drain upstream response bytes (for example curl closing write-side after - // sending an HTTP request through SOCKS/CONNECT). - self.localInputEOF = true - if self.streamID != nil, data?.isEmpty ?? true { - self.forwardToRemote(Data(), eof: true, allowAfterEOF: true) - } - if self.streamID == nil { - self.close(reason: nil) - } - return - } - if let error { - self.close(reason: "proxy client receive error: \(error)") - return - } - - self.receiveNext() - } - } - - private func processHandshakeBuffer() { - guard !isClosed else { return } - while streamID == nil { - switch protocolKind { - case .undecided: - guard let first = handshakeBuffer.first else { return } - protocolKind = (first == 0x05) ? .socks5 : .connect - case .socks5: - if !processSocksHandshakeStep() { - return - } - case .connect: - if !processConnectHandshakeStep() { - return - } - } - } - } - - private func processSocksHandshakeStep() -> Bool { - switch socksStage { - case .greeting: - guard handshakeBuffer.count >= 2 else { return false } - let methodCount = Int(handshakeBuffer[1]) - let total = 2 + methodCount - guard handshakeBuffer.count >= total else { return false } - - let methods = [UInt8](handshakeBuffer[2.. request.consumedBytes - ? Data(handshakeBuffer[request.consumedBytes...]) - : Data() - handshakeBuffer = Data() - guard request.command == 0x01 else { - sendAndClose(Data([0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) - return false - } - - openRemoteStream( - host: request.host, - port: request.port, - successResponse: Data([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]), - failureResponse: Data([0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]), - pendingPayload: pending - ) - return false - } - } - - private func parseSocksRequest(from data: Data) throws -> SocksRequest? { - let bytes = [UInt8](data) - guard bytes.count >= 4 else { return nil } - guard bytes[0] == 0x05 else { - throw NSError(domain: "programa.remote.proxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS version"]) - } - - let command = bytes[1] - let addressType = bytes[3] - var cursor = 4 - let host: String - - switch addressType { - case 0x01: - guard bytes.count >= cursor + 4 + 2 else { return nil } - let octets = bytes[cursor..<(cursor + 4)].map { String($0) } - host = octets.joined(separator: ".") - cursor += 4 - - case 0x03: - guard bytes.count >= cursor + 1 else { return nil } - let length = Int(bytes[cursor]) - cursor += 1 - guard bytes.count >= cursor + length + 2 else { return nil } - let hostData = Data(bytes[cursor..<(cursor + length)]) - host = String(data: hostData, encoding: .utf8) ?? "" - cursor += length - - case 0x04: - guard bytes.count >= cursor + 16 + 2 else { return nil } - var address = in6_addr() - withUnsafeMutableBytes(of: &address) { target in - for i in 0..<16 { - target[i] = bytes[cursor + i] - } - } - var text = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) - let pointer = withUnsafePointer(to: &address) { - inet_ntop(AF_INET6, UnsafeRawPointer($0), &text, socklen_t(INET6_ADDRSTRLEN)) - } - host = pointer != nil ? String(cString: text) : "" - cursor += 16 - - default: - throw NSError(domain: "programa.remote.proxy", code: 2, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS address type"]) - } - - guard !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw NSError(domain: "programa.remote.proxy", code: 3, userInfo: [NSLocalizedDescriptionKey: "empty SOCKS host"]) - } - guard bytes.count >= cursor + 2 else { return nil } - let port = Int(UInt16(bytes[cursor]) << 8 | UInt16(bytes[cursor + 1])) - cursor += 2 - - guard port > 0 && port <= 65535 else { - throw NSError(domain: "programa.remote.proxy", code: 4, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS port"]) - } - - return SocksRequest(host: host, port: port, command: command, consumedBytes: cursor) - } - - private func processConnectHandshakeStep() -> Bool { - let marker = Data([0x0D, 0x0A, 0x0D, 0x0A]) - guard let headerRange = handshakeBuffer.range(of: marker) else { return false } - - let headerData = Data(handshakeBuffer[..= 2, parts[0].uppercased() == "CONNECT" else { - sendAndClose(Self.httpResponse(status: "400 Bad Request")) - return false - } - - guard let (host, port) = Self.parseConnectAuthority(parts[1]) else { - sendAndClose(Self.httpResponse(status: "400 Bad Request")) - return false - } - - openRemoteStream( - host: host, - port: port, - successResponse: Self.httpResponse(status: "200 Connection Established", closeAfterResponse: false), - failureResponse: Self.httpResponse(status: "502 Bad Gateway", closeAfterResponse: true), - pendingPayload: pending - ) - return false - } - - private func openRemoteStream( - host: String, - port: Int, - successResponse: Data, - failureResponse: Data, - pendingPayload: Data - ) { - guard !isClosed else { return } - do { - rewritesLoopbackHTTPHeaders = - BrowserInsecureHTTPSettings.normalizeHost(host) - == BrowserInsecureHTTPSettings.normalizeHost(Self.remoteLoopbackProxyAliasHost) - loopbackRequestHeaderRewriter = rewritesLoopbackHTTPHeaders - ? RemoteLoopbackHTTPRequestStreamRewriter(aliasHost: Self.remoteLoopbackProxyAliasHost) - : nil - pendingRemoteHTTPHeaderBytes = Data() - hasForwardedRemoteHTTPHeaders = false - let targetHost = Self.normalizedProxyTargetHost(host) - let streamID = try rpcClient.openStream(host: targetHost, port: port) - self.streamID = streamID - try rpcClient.attachStream(streamID: streamID, queue: queue) { [weak self] event in - self?.handleRemoteStreamEvent(streamID: streamID, event: event) - } - connection.send(content: successResponse, completion: .contentProcessed { [weak self] error in - guard let self else { return } - if let error { - self.close(reason: "proxy client send error: \(error)") - return - } - if !pendingPayload.isEmpty { - self.forwardToRemote(pendingPayload, allowAfterEOF: true) - } - }) - } catch { - sendAndClose(failureResponse) - } - } - - private func forwardToRemote(_ data: Data, eof: Bool = false, allowAfterEOF: Bool = false) { - guard !isClosed else { return } - guard !localInputEOF || allowAfterEOF else { return } - guard let streamID else { return } - do { - let outgoingData: Data - if rewritesLoopbackHTTPHeaders { - outgoingData = loopbackRequestHeaderRewriter?.rewriteNextChunk(data, eof: eof) ?? data - } else { - outgoingData = data - } - guard !outgoingData.isEmpty else { return } - try rpcClient.writeStream(streamID: streamID, data: outgoingData) - } catch { - close(reason: "proxy.write failed: \(error.localizedDescription)") - } - } - - private func handleRemoteStreamEvent( - streamID: String, - event: WorkspaceRemoteDaemonRPCClient.StreamEvent - ) { - guard !isClosed else { return } - guard self.streamID == streamID else { return } - - switch event { - case .data(let data): - forwardRemotePayloadToLocal(data, eof: false) - - case .eof(let data): - forwardRemotePayloadToLocal(data, eof: true) - - case .error(let detail): - close(reason: "proxy.stream failed: \(detail)") - } - } - - private func forwardRemotePayloadToLocal(_ data: Data, eof: Bool) { - let localData = rewriteRemoteResponseIfNeeded(data, eof: eof) - if !localData.isEmpty { - connection.send(content: localData, completion: .contentProcessed { [weak self] error in - guard let self else { return } - if let error { - self.close(reason: "proxy client send error: \(error)") - return - } - if eof { - self.close(reason: nil) - } - }) - return - } - - if eof { - close(reason: nil) - } - } - - private func rewriteRemoteResponseIfNeeded(_ data: Data, eof: Bool) -> Data { - guard rewritesLoopbackHTTPHeaders else { return data } - guard !data.isEmpty else { return data } - guard !hasForwardedRemoteHTTPHeaders else { return data } - - pendingRemoteHTTPHeaderBytes.append(data) - let marker = Data([0x0D, 0x0A, 0x0D, 0x0A]) - guard pendingRemoteHTTPHeaderBytes.range(of: marker) != nil else { - guard eof else { return Data() } - hasForwardedRemoteHTTPHeaders = true - let payload = pendingRemoteHTTPHeaderBytes - pendingRemoteHTTPHeaderBytes = Data() - return payload - } - - hasForwardedRemoteHTTPHeaders = true - let payload = pendingRemoteHTTPHeaderBytes - pendingRemoteHTTPHeaderBytes = Data() - return RemoteLoopbackHTTPResponseRewriter.rewriteIfNeeded( - data: payload, - aliasHost: Self.remoteLoopbackProxyAliasHost - ) - } - - private func close(reason: String?) { - guard !isClosed else { return } - isClosed = true - - let streamID = self.streamID - self.streamID = nil - - if let streamID { - rpcClient.closeStream(streamID: streamID) - } - connection.cancel() - onClose(id) - } - - private func sendLocal(_ data: Data) { - guard !isClosed else { return } - connection.send(content: data, completion: .contentProcessed { [weak self] error in - guard let self else { return } - if let error { - self.close(reason: "proxy client send error: \(error)") - } - }) - } - - private func sendAndClose(_ data: Data) { - guard !isClosed else { return } - connection.send(content: data, completion: .contentProcessed { [weak self] _ in - self?.close(reason: nil) - }) - } - - private static func parseConnectAuthority(_ authority: String) -> (host: String, port: Int)? { - let trimmed = authority.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - if trimmed.hasPrefix("[") { - guard let closing = trimmed.firstIndex(of: "]") else { return nil } - let host = String(trimmed[trimmed.index(after: trimmed.startIndex).. 0, port <= 65535 else { return nil } - return (host, port) - } - - guard let colon = trimmed.lastIndex(of: ":") else { return nil } - let host = String(trimmed[.. 0, port <= 65535 else { return nil } - return (host, port) - } - - private static func normalizedProxyTargetHost(_ host: String) -> String { - let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) - let normalized = trimmed - .trimmingCharacters(in: CharacterSet(charactersIn: ".")) - .lowercased() - // BrowserPanel rewrites loopback URLs to this alias so proxy routing works. - // Resolve it back to true loopback before dialing from the remote daemon. - if normalized == remoteLoopbackProxyAliasHost { - return "127.0.0.1" - } - return host - } - - private static func httpResponse(status: String, closeAfterResponse: Bool = true) -> Data { - var text = "HTTP/1.1 \(status)\r\nProxy-Agent: cmux\r\n" - if closeAfterResponse { - text += "Connection: close\r\n" - } - text += "\r\n" - return Data(text.utf8) - } - } - - private let configuration: WorkspaceRemoteConfiguration - private let remotePath: String - private let localPort: Int - private let onFatalError: (String) -> Void - private let queue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-tunnel.\(UUID().uuidString)", qos: .utility) - - private var listener: NWListener? - private var rpcClient: WorkspaceRemoteDaemonRPCClient? - private var sessions: [UUID: ProxySession] = [:] - private var isStopped = false - - init( - configuration: WorkspaceRemoteConfiguration, - remotePath: String, - localPort: Int, - onFatalError: @escaping (String) -> Void - ) { - self.configuration = configuration - self.remotePath = remotePath - self.localPort = localPort - self.onFatalError = onFatalError - } - - func start() throws { - var capturedError: Error? - queue.sync { - guard !isStopped else { - capturedError = NSError(domain: "programa.remote.proxy", code: 20, userInfo: [ - NSLocalizedDescriptionKey: "proxy tunnel already stopped", - ]) - return - } - do { - let client = WorkspaceRemoteDaemonRPCClient( - configuration: configuration, - remotePath: remotePath - ) { [weak self] detail in - self?.queue.async { - self?.failLocked("Remote daemon transport failed: \(detail)") - } - } - try client.start() - - let listener = try Self.makeLoopbackListener(port: localPort) - listener.newConnectionHandler = { [weak self] connection in - self?.queue.async { - self?.acceptConnectionLocked(connection) - } - } - listener.stateUpdateHandler = { [weak self] state in - self?.queue.async { - self?.handleListenerStateLocked(state) - } - } - - self.rpcClient = client - self.listener = listener - listener.start(queue: queue) - } catch { - capturedError = error - stopLocked(notify: false) - } - } - if let capturedError { - throw capturedError - } - } - - func stop() { - queue.sync { - stopLocked(notify: false) - } - } - - private func handleListenerStateLocked(_ state: NWListener.State) { - guard !isStopped else { return } - switch state { - case .failed(let error): - failLocked("Local proxy listener failed: \(error)") - default: - break - } - } - - private func acceptConnectionLocked(_ connection: NWConnection) { - guard !isStopped else { - connection.cancel() - return - } - guard let rpcClient else { - connection.cancel() - return - } - - let session = ProxySession( - connection: connection, - rpcClient: rpcClient, - queue: queue - ) { [weak self] id in - self?.queue.async { - self?.sessions.removeValue(forKey: id) - } - } - sessions[session.id] = session - session.start() - } - - private func failLocked(_ detail: String) { - guard !isStopped else { return } - stopLocked(notify: false) - onFatalError(detail) - } - - private func stopLocked(notify: Bool) { - guard !isStopped else { return } - isStopped = true - - listener?.stateUpdateHandler = nil - listener?.newConnectionHandler = nil - listener?.cancel() - listener = nil - - let activeSessions = sessions.values - sessions.removeAll() - for session in activeSessions { - session.stop() - } - - rpcClient?.stop() - rpcClient = nil - } - - private static func makeLoopbackListener(port: Int) throws -> NWListener { - guard let localPort = NWEndpoint.Port(rawValue: UInt16(port)) else { - throw NSError(domain: "programa.remote.proxy", code: 21, userInfo: [ - NSLocalizedDescriptionKey: "invalid local proxy port \(port)", - ]) - } - let tcpOptions = NWProtocolTCP.Options() - tcpOptions.noDelay = true - let parameters = NWParameters(tls: nil, tcp: tcpOptions) - parameters.allowLocalEndpointReuse = true - parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: localPort) - return try NWListener(using: parameters) - } -} - -final class WorkspaceRemoteProxyBroker { - enum Update { - case connecting - case ready(BrowserProxyEndpoint) - case error(String) - } - - final class Lease { - private let key: String - private let subscriberID: UUID - private weak var broker: WorkspaceRemoteProxyBroker? - private var isReleased = false - - fileprivate init(key: String, subscriberID: UUID, broker: WorkspaceRemoteProxyBroker) { - self.key = key - self.subscriberID = subscriberID - self.broker = broker - } - - func release() { - guard !isReleased else { return } - isReleased = true - broker?.release(key: key, subscriberID: subscriberID) - } - - deinit { - release() - } - } - - private final class Entry { - let configuration: WorkspaceRemoteConfiguration - var remotePath: String - var tunnel: WorkspaceRemoteDaemonProxyTunnel? - var endpoint: BrowserProxyEndpoint? - var restartWorkItem: DispatchWorkItem? - var restartRetryCount = 0 - var subscribers: [UUID: (Update) -> Void] = [:] - - init(configuration: WorkspaceRemoteConfiguration, remotePath: String) { - self.configuration = configuration - self.remotePath = remotePath - } - } - - static let shared = WorkspaceRemoteProxyBroker() - - private let queue = DispatchQueue(label: "com.cmux.remote-ssh.proxy-broker", qos: .utility) - private var entries: [String: Entry] = [:] - - func acquire( - configuration: WorkspaceRemoteConfiguration, - remotePath: String, - onUpdate: @escaping (Update) -> Void - ) -> Lease { - queue.sync { - let key = Self.transportKey(for: configuration) - let subscriberID = UUID() - let entry: Entry - if let existing = entries[key] { - entry = existing - if existing.remotePath != remotePath { - existing.remotePath = remotePath - existing.restartRetryCount = 0 - if existing.tunnel != nil { - stopEntryRuntimeLocked(existing) - notifyLocked(existing, update: .connecting) - } - } - } else { - entry = Entry(configuration: configuration, remotePath: remotePath) - entries[key] = entry - } - - entry.subscribers[subscriberID] = onUpdate - if let endpoint = entry.endpoint { - onUpdate(.ready(endpoint)) - } else { - onUpdate(.connecting) - } - - if entry.tunnel == nil, entry.restartWorkItem == nil { - startEntryLocked(key: key, entry: entry) - } - - return Lease(key: key, subscriberID: subscriberID, broker: self) - } - } - - private func release(key: String, subscriberID: UUID) { - queue.async { [weak self] in - guard let self, let entry = self.entries[key] else { return } - entry.subscribers.removeValue(forKey: subscriberID) - guard entry.subscribers.isEmpty else { return } - self.teardownEntryLocked(key: key, entry: entry) - } - } - - private func startEntryLocked(key: String, entry: Entry) { - entry.restartWorkItem?.cancel() - entry.restartWorkItem = nil - - let localPort: Int - if let forcedLocalPort = entry.configuration.localProxyPort { - // Internal deterministic test hook used by docker regressions to force bind conflicts. - localPort = forcedLocalPort - } else { - let retryDelay = Self.retryDelay(baseDelay: 3.0, retry: entry.restartRetryCount + 1) - guard let allocatedPort = Self.allocateLoopbackPort() else { - notifyLocked( - entry, - update: .error("Failed to allocate local proxy port\(Self.retrySuffix(delay: retryDelay))") - ) - scheduleRestartLocked(key: key, entry: entry, baseDelay: 3.0) - return - } - localPort = allocatedPort - } - - do { - let tunnel = WorkspaceRemoteDaemonProxyTunnel( - configuration: entry.configuration, - remotePath: entry.remotePath, - localPort: localPort - ) { [weak self] detail in - self?.queue.async { - self?.handleTunnelFailureLocked(key: key, detail: detail) - } - } - try tunnel.start() - entry.tunnel = tunnel - let endpoint = BrowserProxyEndpoint(host: "127.0.0.1", port: localPort) - entry.endpoint = endpoint - entry.restartRetryCount = 0 - notifyLocked(entry, update: .ready(endpoint)) - } catch { - stopEntryRuntimeLocked(entry) - let detail = "Failed to start local daemon proxy: \(error.localizedDescription)" - let retryDelay = Self.retryDelay(baseDelay: 3.0, retry: entry.restartRetryCount + 1) - notifyLocked(entry, update: .error("\(detail)\(Self.retrySuffix(delay: retryDelay))")) - scheduleRestartLocked(key: key, entry: entry, baseDelay: 3.0) - } - } - - private func handleTunnelFailureLocked(key: String, detail: String) { - guard let entry = entries[key], entry.tunnel != nil else { return } - stopEntryRuntimeLocked(entry) - let retryDelay = Self.retryDelay(baseDelay: 3.0, retry: entry.restartRetryCount + 1) - notifyLocked(entry, update: .error("\(detail)\(Self.retrySuffix(delay: retryDelay))")) - scheduleRestartLocked(key: key, entry: entry, baseDelay: 3.0) - } - - private func scheduleRestartLocked(key: String, entry: Entry, baseDelay: TimeInterval) { - guard !entry.subscribers.isEmpty else { - teardownEntryLocked(key: key, entry: entry) - return - } - guard entry.restartWorkItem == nil else { return } - entry.restartRetryCount += 1 - let retryDelay = Self.retryDelay(baseDelay: baseDelay, retry: entry.restartRetryCount) - - let workItem = DispatchWorkItem { [weak self] in - guard let self, let currentEntry = self.entries[key] else { return } - currentEntry.restartWorkItem = nil - guard !currentEntry.subscribers.isEmpty else { - self.teardownEntryLocked(key: key, entry: currentEntry) - return - } - self.notifyLocked(currentEntry, update: .connecting) - self.startEntryLocked(key: key, entry: currentEntry) - } - - entry.restartWorkItem = workItem - queue.asyncAfter(deadline: .now() + retryDelay, execute: workItem) - } - - private func teardownEntryLocked(key: String, entry: Entry) { - entry.restartWorkItem?.cancel() - entry.restartWorkItem = nil - stopEntryRuntimeLocked(entry) - entries.removeValue(forKey: key) - } - - private func stopEntryRuntimeLocked(_ entry: Entry) { - entry.tunnel?.stop() - entry.tunnel = nil - entry.endpoint = nil - } - - private func notifyLocked(_ entry: Entry, update: Update) { - for callback in entry.subscribers.values { - callback(update) - } - } - - private static func transportKey(for configuration: WorkspaceRemoteConfiguration) -> String { - configuration.proxyBrokerTransportKey - } - - private static func allocateLoopbackPort() -> Int? { - for _ in 0..<8 { - let fd = socket(AF_INET, SOCK_STREAM, 0) - guard fd >= 0 else { return nil } - defer { close(fd) } - - var yes: Int32 = 1 - setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout.size)) - - var addr = sockaddr_in() - addr.sin_len = UInt8(MemoryLayout.size) - addr.sin_family = sa_family_t(AF_INET) - addr.sin_port = in_port_t(0) - addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) - - let bindResult = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - bind(fd, sockaddrPtr, socklen_t(MemoryLayout.size)) - } - } - guard bindResult == 0 else { continue } - - var bound = sockaddr_in() - var len = socklen_t(MemoryLayout.size) - let nameResult = withUnsafeMutablePointer(to: &bound) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - getsockname(fd, sockaddrPtr, &len) - } - } - guard nameResult == 0 else { continue } - - let port = Int(UInt16(bigEndian: bound.sin_port)) - if port > 0 && port <= 65535 { - return port - } - } - return nil - } - - private static func retrySuffix(delay: TimeInterval) -> String { - let seconds = max(1, Int(delay.rounded())) - return " (retry in \(seconds)s)" - } - - private static func retryDelay(baseDelay: TimeInterval, retry: Int) -> TimeInterval { - let exponent = Double(max(0, retry - 1)) - return min(baseDelay * pow(2.0, exponent), 60.0) - } -} - -final class WorkspaceRemoteCLIRelayServer { - private final class Session { - private enum Phase { - case awaitingAuth - case awaitingCommand - case forwarding - case closed - } - - private let connection: NWConnection - private let localSocketPath: String - private let relayID: String - private let relayToken: Data - private let queue: DispatchQueue - private let onClose: () -> Void - private let challengeProtocol = "programa-relay-auth" - private let challengeVersion = 1 - private let minimumFailureDelay: TimeInterval = 0.05 - private let maximumFrameBytes = 16 * 1024 - - private var buffer = Data() - private var phase: Phase = .awaitingAuth - private var challengeNonce = "" - private var challengeSentAt = Date() - private var isClosed = false - - init( - connection: NWConnection, - localSocketPath: String, - relayID: String, - relayToken: Data, - queue: DispatchQueue, - onClose: @escaping () -> Void - ) { - self.connection = connection - self.localSocketPath = localSocketPath - self.relayID = relayID - self.relayToken = relayToken - self.queue = queue - self.onClose = onClose - } - - func start() { - connection.stateUpdateHandler = { [weak self] state in - self?.queue.async { - self?.handleState(state) - } - } - connection.start(queue: queue) - } - - func stop() { - close() - } - - private func handleState(_ state: NWConnection.State) { - guard !isClosed else { return } - switch state { - case .ready: - sendChallenge() - receive() - case .failed, .cancelled: - close() - default: - break - } - } - - private func sendChallenge() { - challengeSentAt = Date() - challengeNonce = Self.randomHex(byteCount: 16) - let challenge: [String: Any] = [ - "protocol": challengeProtocol, - "version": challengeVersion, - "relay_id": relayID, - "nonce": challengeNonce, - ] - sendJSONLine(challenge) { _ in } - } - - private func receive() { - guard !isClosed else { return } - connection.receive(minimumIncompleteLength: 1, maximumLength: maximumFrameBytes) { [weak self] data, _, isComplete, error in - guard let self else { return } - self.queue.async { - if error != nil { - self.close() - return - } - if let data, !data.isEmpty { - self.buffer.append(data) - if self.buffer.count > self.maximumFrameBytes { - self.sendFailureAndClose() - return - } - self.processBufferedLines() - } - if isComplete { - self.close() - return - } - if !self.isClosed { - self.receive() - } - } - } - } - - private func processBufferedLines() { - while let newlineIndex = buffer.firstIndex(of: 0x0A), !isClosed { - let lineData = buffer.prefix(upTo: newlineIndex) - buffer.removeSubrange(...newlineIndex) - let line = String(data: lineData, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - switch phase { - case .awaitingAuth: - handleAuthLine(line) - case .awaitingCommand: - handleCommandLine(Data(lineData) + Data([0x0A])) - case .forwarding, .closed: - return - } - } - } - - private func handleAuthLine(_ line: String) { - guard let data = line.data(using: .utf8), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let receivedRelayID = object["relay_id"] as? String, - receivedRelayID == relayID, - let macHex = object["mac"] as? String, - let receivedMAC = Self.hexData(from: macHex) - else { - sendFailureAndClose() - return - } - - let message = Self.authMessage(relayID: relayID, nonce: challengeNonce, version: challengeVersion) - let expectedMAC = Self.authMAC(token: relayToken, message: message) - guard Self.constantTimeEqual(receivedMAC, expectedMAC) else { - sendFailureAndClose() - return - } - - phase = .awaitingCommand - sendJSONLine(["ok": true]) { [weak self] _ in - self?.queue.async { - self?.processBufferedLines() - } - } - } - - private func handleCommandLine(_ commandLine: Data) { - guard !commandLine.isEmpty else { - sendFailureAndClose() - return - } - phase = .forwarding - DispatchQueue.global(qos: .utility).async { [localSocketPath, commandLine, queue] in - let result = Result { try Self.roundTripUnixSocket(socketPath: localSocketPath, request: commandLine) } - queue.async { [weak self] in - guard let self else { return } - switch result { - case .success(let response): - self.connection.send(content: response, completion: .contentProcessed { [weak self] _ in - self?.queue.async { - self?.close() - } - }) - case .failure: - self.sendFailureAndClose() - } - } - } - } - - private func sendFailureAndClose() { - let elapsed = Date().timeIntervalSince(challengeSentAt) - let delay = max(0, minimumFailureDelay - elapsed) - phase = .closed - queue.asyncAfter(deadline: .now() + delay) { [weak self] in - self?.sendJSONLine(["ok": false]) { [weak self] _ in - self?.queue.async { - self?.close() - } - } - } - } - - private func sendJSONLine(_ object: [String: Any], completion: @escaping (NWError?) -> Void) { - guard !isClosed else { - completion(nil) - return - } - guard let payload = try? JSONSerialization.data(withJSONObject: object) else { - completion(nil) - return - } - connection.send(content: payload + Data([0x0A]), completion: .contentProcessed(completion)) - } - - private func close() { - guard !isClosed else { return } - isClosed = true - phase = .closed - connection.stateUpdateHandler = nil - connection.cancel() - onClose() - } - - private static func authMessage(relayID: String, nonce: String, version: Int) -> Data { - Data("relay_id=\(relayID)\nnonce=\(nonce)\nversion=\(version)".utf8) - } - - private static func authMAC(token: Data, message: Data) -> Data { - let key = SymmetricKey(data: token) - let code = HMAC.authenticationCode(for: message, using: key) - return Data(code) - } - - private static func constantTimeEqual(_ lhs: Data, _ rhs: Data) -> Bool { - guard lhs.count == rhs.count else { return false } - var diff: UInt8 = 0 - for index in lhs.indices { - diff |= lhs[index] ^ rhs[index] - } - return diff == 0 - } - - fileprivate static func hexData(from string: String) -> Data? { - let normalized = string.trimmingCharacters(in: .whitespacesAndNewlines) - guard normalized.count.isMultiple(of: 2), !normalized.isEmpty else { return nil } - var data = Data(capacity: normalized.count / 2) - var cursor = normalized.startIndex - while cursor < normalized.endIndex { - let next = normalized.index(cursor, offsetBy: 2) - guard let byte = UInt8(normalized[cursor.. String { - var bytes = [UInt8](repeating: 0, count: byteCount) - _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - return bytes.map { String(format: "%02x", $0) }.joined() - } - - private static func roundTripUnixSocket(socketPath: String, request: Data) throws -> Data { - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { - throw NSError(domain: "programa.remote.relay", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "failed to create local relay socket", - ]) - } - defer { Darwin.close(fd) } - - var timeout = timeval(tv_sec: 15, tv_usec: 0) - withUnsafePointer(to: &timeout) { pointer in - _ = setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, pointer, socklen_t(MemoryLayout.size)) - _ = setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, pointer, socklen_t(MemoryLayout.size)) - } - - var address = sockaddr_un() - address.sun_family = sa_family_t(AF_UNIX) - let pathBytes = Array(socketPath.utf8CString) - guard pathBytes.count <= MemoryLayout.size(ofValue: address.sun_path) else { - throw NSError(domain: "programa.remote.relay", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "local relay socket path is too long", - ]) - } - let sunPathOffset = MemoryLayout.offset(of: \.sun_path) ?? 0 - withUnsafeMutableBytes(of: &address) { rawBuffer in - let destination = rawBuffer.baseAddress!.advanced(by: sunPathOffset) - pathBytes.withUnsafeBytes { pathBuffer in - destination.copyMemory(from: pathBuffer.baseAddress!, byteCount: pathBytes.count) - } - } - - let addressLength = socklen_t(MemoryLayout.size(ofValue: address.sun_family) + pathBytes.count) - let connectResult = withUnsafePointer(to: &address) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - Darwin.connect(fd, $0, addressLength) - } - } - guard connectResult == 0 else { - throw NSError(domain: "programa.remote.relay", code: 3, userInfo: [ - NSLocalizedDescriptionKey: "failed to connect to local cmux socket", - ]) - } - - try request.withUnsafeBytes { rawBuffer in - guard let baseAddress = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return } - var bytesRemaining = rawBuffer.count - var pointer = baseAddress - while bytesRemaining > 0 { - let written = Darwin.write(fd, pointer, bytesRemaining) - if written <= 0 { - throw NSError(domain: "programa.remote.relay", code: 4, userInfo: [ - NSLocalizedDescriptionKey: "failed to write relay request", - ]) - } - bytesRemaining -= written - pointer = pointer.advanced(by: written) - } - } - _ = shutdown(fd, SHUT_WR) - - var response = Data() - var scratch = [UInt8](repeating: 0, count: 4096) - while true { - let count = Darwin.read(fd, &scratch, scratch.count) - if count > 0 { - response.append(scratch, count: count) - continue - } - if count == 0 { - break - } - - if errno == EAGAIN || errno == EWOULDBLOCK { - if !response.isEmpty { - break - } - throw NSError(domain: "programa.remote.relay", code: 5, userInfo: [ - NSLocalizedDescriptionKey: "timed out waiting for local cmux response", - ]) - } - throw NSError(domain: "programa.remote.relay", code: 6, userInfo: [ - NSLocalizedDescriptionKey: "failed to read local cmux response", - ]) - } - return response - } - } - - private let localSocketPath: String - private let relayID: String - private let relayToken: Data - private let queue = DispatchQueue(label: "com.cmux.remote-ssh.cli-relay.\(UUID().uuidString)", qos: .utility) - - private var listener: NWListener? - private var sessions: [UUID: Session] = [:] - private var isStopped = false - private(set) var localPort: Int? - - init(localSocketPath: String, relayID: String, relayTokenHex: String) throws { - guard let relayToken = Session.hexData(from: relayTokenHex), !relayToken.isEmpty else { - throw NSError(domain: "programa.remote.relay", code: 7, userInfo: [ - NSLocalizedDescriptionKey: "invalid relay token", - ]) - } - self.localSocketPath = localSocketPath - self.relayID = relayID - self.relayToken = relayToken - } - - func start() throws -> Int { - if let existingPort = queue.sync(execute: { localPort }) { - return existingPort - } - - let listener = try Self.makeLoopbackListener() - let readySemaphore = DispatchSemaphore(value: 0) - let stateLock = NSLock() - var capturedError: Error? - var boundPort: Int? - - listener.newConnectionHandler = { [weak self] connection in - self?.queue.async { - self?.acceptConnectionLocked(connection) - } - } - listener.stateUpdateHandler = { listenerState in - switch listenerState { - case .ready: - stateLock.lock() - boundPort = listener.port.map { Int($0.rawValue) } - stateLock.unlock() - readySemaphore.signal() - case .failed(let error): - stateLock.lock() - capturedError = error - stateLock.unlock() - readySemaphore.signal() - default: - break - } - } - listener.start(queue: queue) - - let waitResult = readySemaphore.wait(timeout: .now() + 5.0) - stateLock.lock() - let startupError = capturedError - let startupPort = boundPort - stateLock.unlock() - - if waitResult != .success { - listener.newConnectionHandler = nil - listener.stateUpdateHandler = nil - listener.cancel() - throw NSError(domain: "programa.remote.relay", code: 8, userInfo: [ - NSLocalizedDescriptionKey: "timed out waiting for local relay listener", - ]) - } - if let startupError { - listener.newConnectionHandler = nil - listener.stateUpdateHandler = nil - listener.cancel() - throw startupError - } - guard let startupPort, startupPort > 0 else { - listener.newConnectionHandler = nil - listener.stateUpdateHandler = nil - listener.cancel() - throw NSError(domain: "programa.remote.relay", code: 8, userInfo: [ - NSLocalizedDescriptionKey: "failed to bind local relay listener", - ]) - } - - return queue.sync { - if let localPort { - listener.newConnectionHandler = nil - listener.stateUpdateHandler = nil - listener.cancel() - return localPort - } - self.listener = listener - self.localPort = startupPort - return startupPort - } - } - - func stop() { - queue.sync { - guard !isStopped else { return } - isStopped = true - listener?.newConnectionHandler = nil - listener?.stateUpdateHandler = nil - listener?.cancel() - listener = nil - localPort = nil - let activeSessions = sessions.values - sessions.removeAll() - for session in activeSessions { - session.stop() - } - } - } - - private func acceptConnectionLocked(_ connection: NWConnection) { - guard !isStopped else { - connection.cancel() - return - } - let sessionID = UUID() - let session = Session( - connection: connection, - localSocketPath: localSocketPath, - relayID: relayID, - relayToken: relayToken, - queue: queue - ) { [weak self] in - self?.sessions.removeValue(forKey: sessionID) - } - sessions[sessionID] = session - session.start() - } - - private static func makeLoopbackListener() throws -> NWListener { - let tcpOptions = NWProtocolTCP.Options() - tcpOptions.noDelay = true - let parameters = NWParameters(tls: nil, tcp: tcpOptions) - parameters.allowLocalEndpointReuse = true - parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: .any) - return try NWListener(using: parameters) - } -} - diff --git a/Sources/WorkspaceRemoteDaemonPendingCallRegistry.swift b/Sources/WorkspaceRemoteDaemonPendingCallRegistry.swift new file mode 100644 index 00000000000..536c3eb3324 --- /dev/null +++ b/Sources/WorkspaceRemoteDaemonPendingCallRegistry.swift @@ -0,0 +1,103 @@ +// Extracted from WorkspaceRemoteDaemon.swift (nuclear-review #98): pending-RPC-call bookkeeping for the daemon transport. + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +final class WorkspaceRemoteDaemonPendingCallRegistry { + final class PendingCall { + let id: Int + fileprivate let semaphore = DispatchSemaphore(value: 0) + fileprivate var response: [String: Any]? + fileprivate var failureMessage: String? + + fileprivate init(id: Int) { + self.id = id + } + } + + enum WaitOutcome { + case response([String: Any]) + case failure(String) + case missing + case timedOut + } + + private let queue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.pending.\(UUID().uuidString)") + private var nextRequestID = 1 + private var pendingCalls: [Int: PendingCall] = [:] + + func reset() { + queue.sync { + nextRequestID = 1 + pendingCalls.removeAll(keepingCapacity: false) + } + } + + func register() -> PendingCall { + queue.sync { + let call = PendingCall(id: nextRequestID) + nextRequestID += 1 + pendingCalls[call.id] = call + return call + } + } + + @discardableResult + func resolve(id: Int, payload: [String: Any]) -> Bool { + queue.sync { + guard let pendingCall = pendingCalls[id] else { return false } + pendingCall.response = payload + pendingCall.semaphore.signal() + return true + } + } + + func failAll(_ message: String) { + queue.sync { + let calls = Array(pendingCalls.values) + for call in calls { + guard call.response == nil, call.failureMessage == nil else { continue } + call.failureMessage = message + call.semaphore.signal() + } + } + } + + func remove(_ call: PendingCall) { + _ = queue.sync { + pendingCalls.removeValue(forKey: call.id) + } + } + + func wait(for call: PendingCall, timeout: TimeInterval) -> WaitOutcome { + if call.semaphore.wait(timeout: .now() + timeout) == .timedOut { + _ = queue.sync { + pendingCalls.removeValue(forKey: call.id) + } + // A response can win the race immediately before timeout cleanup removes the call. + // Drain any late signal so DispatchSemaphore is not deallocated with a positive count. + _ = call.semaphore.wait(timeout: .now()) + return .timedOut + } + + return queue.sync { + guard let pendingCall = pendingCalls.removeValue(forKey: call.id) else { + return .missing + } + if let failure = pendingCall.failureMessage { + return .failure(failure) + } + guard let response = pendingCall.response else { + return .missing + } + return .response(response) + } + } +} diff --git a/Sources/WorkspaceRemoteDaemonRPCClient.swift b/Sources/WorkspaceRemoteDaemonRPCClient.swift new file mode 100644 index 00000000000..1688ec88f75 --- /dev/null +++ b/Sources/WorkspaceRemoteDaemonRPCClient.swift @@ -0,0 +1,485 @@ +// Extracted from WorkspaceRemoteDaemon.swift (nuclear-review #98): the JSON-RPC client that talks to programad-remote over ssh -T. + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +final class WorkspaceRemoteDaemonRPCClient { + private static let maxStdoutBufferBytes = 256 * 1024 + static let requiredProxyStreamCapability = "proxy.stream.push" + + enum StreamEvent { + case data(Data) + case eof(Data) + case error(String) + } + + private struct StreamSubscription { + let queue: DispatchQueue + let handler: (StreamEvent) -> Void + } + + private let configuration: WorkspaceRemoteConfiguration + private let remotePath: String + private let onUnexpectedTermination: (String) -> Void + private let writeQueue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.write.\(UUID().uuidString)") + private let stateQueue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.state.\(UUID().uuidString)") + private let pendingCalls = WorkspaceRemoteDaemonPendingCallRegistry() + + private var process: Process? + private var stdinPipe: Pipe? + private var stdoutPipe: Pipe? + private var stderrPipe: Pipe? + private var stdinHandle: FileHandle? + private var stdoutHandle: FileHandle? + private var stderrHandle: FileHandle? + private var isClosed = true + private var shouldReportTermination = true + + private var stdoutBuffer = Data() + private var stderrBuffer = "" + private var streamSubscriptions: [String: StreamSubscription] = [:] + + init( + configuration: WorkspaceRemoteConfiguration, + remotePath: String, + onUnexpectedTermination: @escaping (String) -> Void + ) { + self.configuration = configuration + self.remotePath = remotePath + self.onUnexpectedTermination = onUnexpectedTermination + } + + func start() throws { + let process = Process() + let stdinPipe = Pipe() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + + stateQueue.sync { + self.stdinPipe = stdinPipe + self.stdoutPipe = stdoutPipe + self.stderrPipe = stderrPipe + } + + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = Self.daemonArguments(configuration: configuration, remotePath: remotePath) + process.standardInput = stdinPipe + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + self?.stateQueue.async { + self?.consumeStdoutData(data) + } + } + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + self?.stateQueue.async { + self?.consumeStderrData(data) + } + } + process.terminationHandler = { [weak self] terminated in + self?.stateQueue.async { + self?.handleProcessTermination(terminated) + } + } + + do { + try process.run() + } catch { + throw NSError(domain: "programa.remote.daemon.rpc", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to launch SSH daemon transport: \(error.localizedDescription)", + ]) + } + + stateQueue.sync { + self.process = process + self.stdinHandle = stdinPipe.fileHandleForWriting + self.stdoutHandle = stdoutPipe.fileHandleForReading + self.stderrHandle = stderrPipe.fileHandleForReading + self.isClosed = false + self.shouldReportTermination = true + self.stdoutBuffer = Data() + self.stderrBuffer = "" + self.streamSubscriptions.removeAll(keepingCapacity: false) + } + pendingCalls.reset() + + do { + let hello = try call(method: "hello", params: [:], timeout: 8.0) + let capabilities = (hello["capabilities"] as? [String]) ?? [] + guard capabilities.contains(Self.requiredProxyStreamCapability) else { + throw NSError(domain: "programa.remote.daemon.rpc", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon missing required capability \(Self.requiredProxyStreamCapability)", + ]) + } + } catch { + stop(suppressTerminationCallback: true) + throw error + } + } + + func stop() { + stop(suppressTerminationCallback: true) + } + + func openStream(host: String, port: Int, timeoutMs: Int = 10000) throws -> String { + let result = try call( + method: "proxy.open", + params: [ + "host": host, + "port": port, + "timeout_ms": timeoutMs, + ], + timeout: 12.0 + ) + let streamID = (result["stream_id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !streamID.isEmpty else { + throw NSError(domain: "programa.remote.daemon.rpc", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "proxy.open missing stream_id", + ]) + } + return streamID + } + + func writeStream(streamID: String, data: Data) throws { + _ = try call( + method: "proxy.write", + params: [ + "stream_id": streamID, + "data_base64": data.base64EncodedString(), + ], + timeout: 8.0 + ) + } + + func attachStream( + streamID: String, + queue: DispatchQueue, + onEvent: @escaping (StreamEvent) -> Void + ) throws { + let trimmedStreamID = streamID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedStreamID.isEmpty else { + throw NSError(domain: "programa.remote.daemon.rpc", code: 17, userInfo: [ + NSLocalizedDescriptionKey: "proxy.stream.subscribe requires stream_id", + ]) + } + + stateQueue.sync { + streamSubscriptions[trimmedStreamID] = StreamSubscription(queue: queue, handler: onEvent) + } + + do { + _ = try call( + method: "proxy.stream.subscribe", + params: ["stream_id": trimmedStreamID], + timeout: 8.0 + ) + } catch { + unregisterStream(streamID: trimmedStreamID) + throw error + } + } + + func unregisterStream(streamID: String) { + let trimmedStreamID = streamID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedStreamID.isEmpty else { return } + _ = stateQueue.sync { + streamSubscriptions.removeValue(forKey: trimmedStreamID) + } + } + + func closeStream(streamID: String) { + unregisterStream(streamID: streamID) + _ = try? call( + method: "proxy.close", + params: ["stream_id": streamID], + timeout: 4.0 + ) + } + + private func call(method: String, params: [String: Any], timeout: TimeInterval) throws -> [String: Any] { + let pendingCall = pendingCalls.register() + let requestID = pendingCall.id + + let payload: Data + do { + payload = try Self.encodeJSON([ + "id": requestID, + "method": method, + "params": params, + ]) + } catch { + pendingCalls.remove(pendingCall) + throw NSError(domain: "programa.remote.daemon.rpc", code: 10, userInfo: [ + NSLocalizedDescriptionKey: "failed to encode daemon RPC request \(method): \(error.localizedDescription)", + ]) + } + + do { + try writeQueue.sync { + try writePayload(payload) + } + } catch { + pendingCalls.remove(pendingCall) + throw error + } + + let response: [String: Any] + switch pendingCalls.wait(for: pendingCall, timeout: timeout) { + case .timedOut: + stop(suppressTerminationCallback: false) + throw NSError(domain: "programa.remote.daemon.rpc", code: 11, userInfo: [ + NSLocalizedDescriptionKey: "daemon RPC timeout waiting for \(method) response", + ]) + case .failure(let failure): + throw NSError(domain: "programa.remote.daemon.rpc", code: 12, userInfo: [ + NSLocalizedDescriptionKey: failure, + ]) + case .missing: + throw NSError(domain: "programa.remote.daemon.rpc", code: 13, userInfo: [ + NSLocalizedDescriptionKey: "daemon RPC \(method) returned empty response", + ]) + case .response(let pendingResponse): + response = pendingResponse + } + + let ok = (response["ok"] as? Bool) ?? false + if ok { + return (response["result"] as? [String: Any]) ?? [:] + } + + let errorObject = (response["error"] as? [String: Any]) ?? [:] + let code = (errorObject["code"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "rpc_error" + let message = (errorObject["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "daemon RPC call failed" + throw NSError(domain: "programa.remote.daemon.rpc", code: 14, userInfo: [ + NSLocalizedDescriptionKey: "\(method) failed (\(code)): \(message)", + ]) + } + + private func writePayload(_ payload: Data) throws { + let stdinHandle: FileHandle = stateQueue.sync { + self.stdinHandle ?? FileHandle.nullDevice + } + if stdinHandle === FileHandle.nullDevice { + throw NSError(domain: "programa.remote.daemon.rpc", code: 15, userInfo: [ + NSLocalizedDescriptionKey: "daemon transport is not connected", + ]) + } + do { + try stdinHandle.write(contentsOf: payload) + try stdinHandle.write(contentsOf: Data([0x0A])) + } catch { + stop(suppressTerminationCallback: false) + throw NSError(domain: "programa.remote.daemon.rpc", code: 16, userInfo: [ + NSLocalizedDescriptionKey: "failed writing daemon RPC request: \(error.localizedDescription)", + ]) + } + } + + private func consumeStdoutData(_ data: Data) { + guard !data.isEmpty else { + signalPendingFailureLocked("daemon transport closed stdout") + return + } + + stdoutBuffer.append(data) + if stdoutBuffer.count > Self.maxStdoutBufferBytes { + stdoutBuffer.removeAll(keepingCapacity: false) + signalPendingFailureLocked("daemon transport stdout exceeded \(Self.maxStdoutBufferBytes) bytes without message framing") + process?.terminate() + return + } + while let newlineIndex = stdoutBuffer.firstIndex(of: 0x0A) { + var lineData = Data(stdoutBuffer[.. 8192 { + stderrBuffer.removeFirst(stderrBuffer.count - 8192) + } + } + + private func consumeEventPayload(_ payload: [String: Any]) { + guard let eventName = (payload["event"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !eventName.isEmpty, + let streamID = (payload["stream_id"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !streamID.isEmpty else { + return + } + + let subscription: StreamSubscription? + let event: StreamEvent? + switch eventName { + case "proxy.stream.data": + subscription = streamSubscriptions[streamID] + event = .data(Self.decodeBase64Data(payload["data_base64"])) + + case "proxy.stream.eof": + subscription = streamSubscriptions.removeValue(forKey: streamID) + event = .eof(Self.decodeBase64Data(payload["data_base64"])) + + case "proxy.stream.error": + subscription = streamSubscriptions.removeValue(forKey: streamID) + let detail = ((payload["error"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 } + ?? "stream error" + event = .error(detail) + + default: + return + } + + guard let subscription, let event else { return } + subscription.queue.async { + subscription.handler(event) + } + } + + private func handleProcessTermination(_ process: Process) { + let shouldNotify: Bool = { + guard self.process === process else { return false } + return !isClosed && shouldReportTermination + }() + let detail = Self.bestErrorLine(stderr: stderrBuffer) ?? "daemon transport exited with status \(process.terminationStatus)" + + isClosed = true + self.process = nil + stdinPipe = nil + stdoutPipe = nil + stderrPipe = nil + stdinHandle = nil + stdoutHandle?.readabilityHandler = nil + stdoutHandle = nil + stderrHandle?.readabilityHandler = nil + stderrHandle = nil + streamSubscriptions.removeAll(keepingCapacity: false) + signalPendingFailureLocked(detail) + + guard shouldNotify else { return } + onUnexpectedTermination(detail) + } + + private func stop(suppressTerminationCallback: Bool) { + let captured: (Process?, FileHandle?, FileHandle?, FileHandle?, Bool, String) = stateQueue.sync { + let detail = Self.bestErrorLine(stderr: stderrBuffer) ?? "daemon transport stopped" + let shouldNotify = !suppressTerminationCallback && !isClosed + shouldReportTermination = !suppressTerminationCallback + if isClosed { + return (nil, nil, nil, nil, false, detail) + } + + isClosed = true + signalPendingFailureLocked("daemon transport stopped") + let capturedProcess = process + let capturedStdin = stdinHandle + let capturedStdout = stdoutHandle + let capturedStderr = stderrHandle + + process = nil + stdinPipe = nil + stdoutPipe = nil + stderrPipe = nil + stdinHandle = nil + stdoutHandle = nil + stderrHandle = nil + streamSubscriptions.removeAll(keepingCapacity: false) + return (capturedProcess, capturedStdin, capturedStdout, capturedStderr, shouldNotify, detail) + } + + captured.2?.readabilityHandler = nil + captured.3?.readabilityHandler = nil + try? captured.1?.close() + try? captured.2?.close() + try? captured.3?.close() + if let process = captured.0, process.isRunning { + process.terminate() + } + if captured.4 { + onUnexpectedTermination(captured.5) + } + } + + private func signalPendingFailureLocked(_ message: String) { + pendingCalls.failAll(message) + } + + private static func responseID(in payload: [String: Any]) -> Int? { + if let intValue = payload["id"] as? Int { + return intValue + } + if let numberValue = payload["id"] as? NSNumber { + return numberValue.intValue + } + return nil + } + + private static func decodeBase64Data(_ value: Any?) -> Data { + guard let encoded = value as? String, !encoded.isEmpty else { return Data() } + return Data(base64Encoded: encoded) ?? Data() + } + + private static func encodeJSON(_ object: [String: Any]) throws -> Data { + try JSONSerialization.data(withJSONObject: object, options: []) + } + + private static func daemonArguments(configuration: WorkspaceRemoteConfiguration, remotePath: String) -> [String] { + WorkspaceRemoteSSHBatchCommandBuilder.daemonTransportArguments( + configuration: configuration, + remotePath: remotePath + ) + } + + private static func bestErrorLine(stderr: String) -> String? { + let lines = stderr + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + for line in lines.reversed() where !isNoiseLine(line) { + return line + } + return lines.last + } + + private static func isNoiseLine(_ line: String) -> Bool { + let lowered = line.lowercased() + if lowered.hasPrefix("warning: permanently added") { return true } + if lowered.hasPrefix("debug") { return true } + if lowered.hasPrefix("transferred:") { return true } + if lowered.hasPrefix("openbsd_") { return true } + if lowered.contains("pseudo-terminal will not be allocated") { return true } + return false + } +} diff --git a/Sources/WorkspaceRemoteLoopbackHTTPRewriting.swift b/Sources/WorkspaceRemoteLoopbackHTTPRewriting.swift new file mode 100644 index 00000000000..b4f0ec17e67 --- /dev/null +++ b/Sources/WorkspaceRemoteLoopbackHTTPRewriting.swift @@ -0,0 +1,258 @@ +// Extracted from WorkspaceRemoteDaemon.swift (nuclear-review #98): HTTP request/response rewriting for the loopback proxy alias host. + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +enum RemoteLoopbackHTTPRequestRewriter { + private static let headerDelimiter = Data([0x0d, 0x0a, 0x0d, 0x0a]) + private static let canonicalLoopbackHost = "localhost" + private static let requestLineMethods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "PRI"] + + static func rewriteIfNeeded(data: Data, aliasHost: String) -> Data { + rewriteIfNeeded(data: data, aliasHost: aliasHost, allowIncompleteHeadersAtEOF: false) + } + + static func rewriteIfNeeded(data: Data, aliasHost: String, allowIncompleteHeadersAtEOF: Bool) -> Data { + let headerData: Data + let remainder: Data + + if let headerRange = data.range(of: headerDelimiter) { + headerData = Data(data[.. Bool { + let trimmed = requestLine.trimmingCharacters(in: .whitespacesAndNewlines) + let method = trimmed.split(separator: " ", maxSplits: 1).first.map(String.init)?.uppercased() ?? "" + return requestLineMethods.contains(method) + } + + private static func rewriteRequestLine(_ requestLine: String, aliasHost: String) -> String { + let trimmed = requestLine.trimmingCharacters(in: .whitespacesAndNewlines) + let parts = trimmed.split(separator: " ", omittingEmptySubsequences: false) + guard parts.count >= 3 else { return requestLine } + + var components = URLComponents(string: String(parts[1])) + guard let host = components?.host, + BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else { + return requestLine + } + components?.host = canonicalLoopbackHost + guard let rewrittenURL = components?.string else { return requestLine } + + var rewritten = parts + rewritten[1] = Substring(rewrittenURL) + let leadingTrivia = requestLine.prefix { $0.isWhitespace || $0.isNewline } + let trailingTrivia = String(requestLine.reversed().prefix { $0.isWhitespace || $0.isNewline }.reversed()) + return String(leadingTrivia) + rewritten.joined(separator: " ") + trailingTrivia + } + + private static func rewriteHeaderLine(_ line: String, aliasHost: String) -> String { + guard let colonIndex = line.firstIndex(of: ":") else { return line } + let name = line[.. String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if trimmed.hasPrefix("["), + let closing = trimmed.firstIndex(of: "]") { + let host = String(trimmed[trimmed.index(after: trimmed.startIndex).. String? { + var components = URLComponents(string: value) + guard let host = components?.host, + BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else { + return nil + } + components?.host = canonicalLoopbackHost + return components?.string + } +} + +struct RemoteLoopbackHTTPRequestStreamRewriter { + private static let maxHeaderBytes = 64 * 1024 + private static let headerDelimiter = Data([0x0D, 0x0A, 0x0D, 0x0A]) + + private let aliasHost: String + private var pendingHeaderBytes = Data() + private var hasForwardedHeaders = false + + init(aliasHost: String) { + self.aliasHost = aliasHost + } + + mutating func rewriteNextChunk(_ data: Data, eof: Bool) -> Data { + guard !hasForwardedHeaders else { return data } + + pendingHeaderBytes.append(data) + if pendingHeaderBytes.count > Self.maxHeaderBytes { + hasForwardedHeaders = true + let payload = pendingHeaderBytes + pendingHeaderBytes = Data() + return RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( + data: payload, + aliasHost: aliasHost, + allowIncompleteHeadersAtEOF: true + ) + } + + guard pendingHeaderBytes.range(of: Self.headerDelimiter) != nil else { + guard eof else { return Data() } + hasForwardedHeaders = true + let payload = pendingHeaderBytes + pendingHeaderBytes = Data() + return RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( + data: payload, + aliasHost: aliasHost, + allowIncompleteHeadersAtEOF: true + ) + } + + hasForwardedHeaders = true + let payload = pendingHeaderBytes + pendingHeaderBytes = Data() + return RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( + data: payload, + aliasHost: aliasHost + ) + } +} + +enum RemoteLoopbackHTTPResponseRewriter { + private static let headerDelimiter = Data([0x0d, 0x0a, 0x0d, 0x0a]) + private static let canonicalLoopbackHost = "localhost" + + static func rewriteIfNeeded(data: Data, aliasHost: String) -> Data { + guard let headerRange = data.range(of: headerDelimiter) else { return data } + let headerData = Data(data[.. String { + guard let colonIndex = line.firstIndex(of: ":") else { return line } + let name = line[.. String? { + var components = URLComponents(string: value) + guard let host = components?.host, + BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(canonicalLoopbackHost) else { + return nil + } + components?.host = aliasHost + return components?.string + } + + private static func rewriteCookieValue(_ value: String, aliasHost: String) -> String? { + let parts = value.split(separator: ";", omittingEmptySubsequences: false).map(String.init) + guard !parts.isEmpty else { return nil } + + var didRewrite = false + let rewrittenParts = parts.map { part -> String in + let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.lowercased().hasPrefix("domain=") else { return part } + let domainValue = String(trimmed.dropFirst("domain=".count)) + guard BrowserInsecureHTTPSettings.normalizeHost(domainValue) == BrowserInsecureHTTPSettings.normalizeHost(canonicalLoopbackHost) else { + return part + } + didRewrite = true + let leadingWhitespace = part.prefix { $0.isWhitespace } + return "\(leadingWhitespace)Domain=\(aliasHost)" + } + + return didRewrite ? rewrittenParts.joined(separator: ";") : nil + } +} diff --git a/Sources/WorkspaceRemoteProxyBroker.swift b/Sources/WorkspaceRemoteProxyBroker.swift new file mode 100644 index 00000000000..3e70ea294e7 --- /dev/null +++ b/Sources/WorkspaceRemoteProxyBroker.swift @@ -0,0 +1,881 @@ +// Extracted from WorkspaceRemoteDaemon.swift (nuclear-review #98): the local<->remote proxy tunnel and its broker. + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +private final class WorkspaceRemoteDaemonProxyTunnel { + private final class ProxySession { + private static let maxHandshakeBytes = 64 * 1024 + private static let remoteLoopbackProxyAliasHost = "programa-loopback.localtest.me" + + private enum HandshakeProtocol { + case undecided + case socks5 + case connect + } + + private enum SocksStage { + case greeting + case request + } + + private struct SocksRequest { + let host: String + let port: Int + let command: UInt8 + let consumedBytes: Int + } + + let id = UUID() + + private let connection: NWConnection + private let rpcClient: WorkspaceRemoteDaemonRPCClient + private let queue: DispatchQueue + private let onClose: (UUID) -> Void + + private var isClosed = false + private var protocolKind: HandshakeProtocol = .undecided + private var socksStage: SocksStage = .greeting + private var handshakeBuffer = Data() + private var streamID: String? + private var localInputEOF = false + private var rewritesLoopbackHTTPHeaders = false + private var loopbackRequestHeaderRewriter: RemoteLoopbackHTTPRequestStreamRewriter? + private var pendingRemoteHTTPHeaderBytes = Data() + private var hasForwardedRemoteHTTPHeaders = false + + init( + connection: NWConnection, + rpcClient: WorkspaceRemoteDaemonRPCClient, + queue: DispatchQueue, + onClose: @escaping (UUID) -> Void + ) { + self.connection = connection + self.rpcClient = rpcClient + self.queue = queue + self.onClose = onClose + } + + func start() { + connection.stateUpdateHandler = { [weak self] state in + guard let self else { return } + switch state { + case .failed(let error): + self.close(reason: "proxy client connection failed: \(error)") + case .cancelled: + self.close(reason: nil) + default: + break + } + } + connection.start(queue: queue) + receiveNext() + } + + func stop() { + close(reason: nil) + } + + private func receiveNext() { + guard !isClosed else { return } + connection.receive(minimumIncompleteLength: 1, maximumLength: 32768) { [weak self] data, _, isComplete, error in + guard let self, !self.isClosed else { return } + + if let data, !data.isEmpty { + if self.streamID == nil { + if self.handshakeBuffer.count + data.count > Self.maxHandshakeBytes { + self.close(reason: "proxy handshake exceeded \(Self.maxHandshakeBytes) bytes") + return + } + self.handshakeBuffer.append(data) + self.processHandshakeBuffer() + } else { + self.forwardToRemote(data, eof: isComplete) + } + } + + if isComplete { + // Treat local EOF as a half-close: keep remote read loop alive so we can + // drain upstream response bytes (for example curl closing write-side after + // sending an HTTP request through SOCKS/CONNECT). + self.localInputEOF = true + if self.streamID != nil, data?.isEmpty ?? true { + self.forwardToRemote(Data(), eof: true, allowAfterEOF: true) + } + if self.streamID == nil { + self.close(reason: nil) + } + return + } + if let error { + self.close(reason: "proxy client receive error: \(error)") + return + } + + self.receiveNext() + } + } + + private func processHandshakeBuffer() { + guard !isClosed else { return } + while streamID == nil { + switch protocolKind { + case .undecided: + guard let first = handshakeBuffer.first else { return } + protocolKind = (first == 0x05) ? .socks5 : .connect + case .socks5: + if !processSocksHandshakeStep() { + return + } + case .connect: + if !processConnectHandshakeStep() { + return + } + } + } + } + + private func processSocksHandshakeStep() -> Bool { + switch socksStage { + case .greeting: + guard handshakeBuffer.count >= 2 else { return false } + let methodCount = Int(handshakeBuffer[1]) + let total = 2 + methodCount + guard handshakeBuffer.count >= total else { return false } + + let methods = [UInt8](handshakeBuffer[2.. request.consumedBytes + ? Data(handshakeBuffer[request.consumedBytes...]) + : Data() + handshakeBuffer = Data() + guard request.command == 0x01 else { + sendAndClose(Data([0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) + return false + } + + openRemoteStream( + host: request.host, + port: request.port, + successResponse: Data([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]), + failureResponse: Data([0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]), + pendingPayload: pending + ) + return false + } + } + + private func parseSocksRequest(from data: Data) throws -> SocksRequest? { + let bytes = [UInt8](data) + guard bytes.count >= 4 else { return nil } + guard bytes[0] == 0x05 else { + throw NSError(domain: "programa.remote.proxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS version"]) + } + + let command = bytes[1] + let addressType = bytes[3] + var cursor = 4 + let host: String + + switch addressType { + case 0x01: + guard bytes.count >= cursor + 4 + 2 else { return nil } + let octets = bytes[cursor..<(cursor + 4)].map { String($0) } + host = octets.joined(separator: ".") + cursor += 4 + + case 0x03: + guard bytes.count >= cursor + 1 else { return nil } + let length = Int(bytes[cursor]) + cursor += 1 + guard bytes.count >= cursor + length + 2 else { return nil } + let hostData = Data(bytes[cursor..<(cursor + length)]) + host = String(data: hostData, encoding: .utf8) ?? "" + cursor += length + + case 0x04: + guard bytes.count >= cursor + 16 + 2 else { return nil } + var address = in6_addr() + withUnsafeMutableBytes(of: &address) { target in + for i in 0..<16 { + target[i] = bytes[cursor + i] + } + } + var text = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) + let pointer = withUnsafePointer(to: &address) { + inet_ntop(AF_INET6, UnsafeRawPointer($0), &text, socklen_t(INET6_ADDRSTRLEN)) + } + host = pointer != nil ? String(cString: text) : "" + cursor += 16 + + default: + throw NSError(domain: "programa.remote.proxy", code: 2, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS address type"]) + } + + guard !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw NSError(domain: "programa.remote.proxy", code: 3, userInfo: [NSLocalizedDescriptionKey: "empty SOCKS host"]) + } + guard bytes.count >= cursor + 2 else { return nil } + let port = Int(UInt16(bytes[cursor]) << 8 | UInt16(bytes[cursor + 1])) + cursor += 2 + + guard port > 0 && port <= 65535 else { + throw NSError(domain: "programa.remote.proxy", code: 4, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS port"]) + } + + return SocksRequest(host: host, port: port, command: command, consumedBytes: cursor) + } + + private func processConnectHandshakeStep() -> Bool { + let marker = Data([0x0D, 0x0A, 0x0D, 0x0A]) + guard let headerRange = handshakeBuffer.range(of: marker) else { return false } + + let headerData = Data(handshakeBuffer[..= 2, parts[0].uppercased() == "CONNECT" else { + sendAndClose(Self.httpResponse(status: "400 Bad Request")) + return false + } + + guard let (host, port) = Self.parseConnectAuthority(parts[1]) else { + sendAndClose(Self.httpResponse(status: "400 Bad Request")) + return false + } + + openRemoteStream( + host: host, + port: port, + successResponse: Self.httpResponse(status: "200 Connection Established", closeAfterResponse: false), + failureResponse: Self.httpResponse(status: "502 Bad Gateway", closeAfterResponse: true), + pendingPayload: pending + ) + return false + } + + private func openRemoteStream( + host: String, + port: Int, + successResponse: Data, + failureResponse: Data, + pendingPayload: Data + ) { + guard !isClosed else { return } + do { + rewritesLoopbackHTTPHeaders = + BrowserInsecureHTTPSettings.normalizeHost(host) + == BrowserInsecureHTTPSettings.normalizeHost(Self.remoteLoopbackProxyAliasHost) + loopbackRequestHeaderRewriter = rewritesLoopbackHTTPHeaders + ? RemoteLoopbackHTTPRequestStreamRewriter(aliasHost: Self.remoteLoopbackProxyAliasHost) + : nil + pendingRemoteHTTPHeaderBytes = Data() + hasForwardedRemoteHTTPHeaders = false + let targetHost = Self.normalizedProxyTargetHost(host) + let streamID = try rpcClient.openStream(host: targetHost, port: port) + self.streamID = streamID + try rpcClient.attachStream(streamID: streamID, queue: queue) { [weak self] event in + self?.handleRemoteStreamEvent(streamID: streamID, event: event) + } + connection.send(content: successResponse, completion: .contentProcessed { [weak self] error in + guard let self else { return } + if let error { + self.close(reason: "proxy client send error: \(error)") + return + } + if !pendingPayload.isEmpty { + self.forwardToRemote(pendingPayload, allowAfterEOF: true) + } + }) + } catch { + sendAndClose(failureResponse) + } + } + + private func forwardToRemote(_ data: Data, eof: Bool = false, allowAfterEOF: Bool = false) { + guard !isClosed else { return } + guard !localInputEOF || allowAfterEOF else { return } + guard let streamID else { return } + do { + let outgoingData: Data + if rewritesLoopbackHTTPHeaders { + outgoingData = loopbackRequestHeaderRewriter?.rewriteNextChunk(data, eof: eof) ?? data + } else { + outgoingData = data + } + guard !outgoingData.isEmpty else { return } + try rpcClient.writeStream(streamID: streamID, data: outgoingData) + } catch { + close(reason: "proxy.write failed: \(error.localizedDescription)") + } + } + + private func handleRemoteStreamEvent( + streamID: String, + event: WorkspaceRemoteDaemonRPCClient.StreamEvent + ) { + guard !isClosed else { return } + guard self.streamID == streamID else { return } + + switch event { + case .data(let data): + forwardRemotePayloadToLocal(data, eof: false) + + case .eof(let data): + forwardRemotePayloadToLocal(data, eof: true) + + case .error(let detail): + close(reason: "proxy.stream failed: \(detail)") + } + } + + private func forwardRemotePayloadToLocal(_ data: Data, eof: Bool) { + let localData = rewriteRemoteResponseIfNeeded(data, eof: eof) + if !localData.isEmpty { + connection.send(content: localData, completion: .contentProcessed { [weak self] error in + guard let self else { return } + if let error { + self.close(reason: "proxy client send error: \(error)") + return + } + if eof { + self.close(reason: nil) + } + }) + return + } + + if eof { + close(reason: nil) + } + } + + private func rewriteRemoteResponseIfNeeded(_ data: Data, eof: Bool) -> Data { + guard rewritesLoopbackHTTPHeaders else { return data } + guard !data.isEmpty else { return data } + guard !hasForwardedRemoteHTTPHeaders else { return data } + + pendingRemoteHTTPHeaderBytes.append(data) + let marker = Data([0x0D, 0x0A, 0x0D, 0x0A]) + guard pendingRemoteHTTPHeaderBytes.range(of: marker) != nil else { + guard eof else { return Data() } + hasForwardedRemoteHTTPHeaders = true + let payload = pendingRemoteHTTPHeaderBytes + pendingRemoteHTTPHeaderBytes = Data() + return payload + } + + hasForwardedRemoteHTTPHeaders = true + let payload = pendingRemoteHTTPHeaderBytes + pendingRemoteHTTPHeaderBytes = Data() + return RemoteLoopbackHTTPResponseRewriter.rewriteIfNeeded( + data: payload, + aliasHost: Self.remoteLoopbackProxyAliasHost + ) + } + + private func close(reason: String?) { + guard !isClosed else { return } + isClosed = true + + let streamID = self.streamID + self.streamID = nil + + if let streamID { + rpcClient.closeStream(streamID: streamID) + } + connection.cancel() + onClose(id) + } + + private func sendLocal(_ data: Data) { + guard !isClosed else { return } + connection.send(content: data, completion: .contentProcessed { [weak self] error in + guard let self else { return } + if let error { + self.close(reason: "proxy client send error: \(error)") + } + }) + } + + private func sendAndClose(_ data: Data) { + guard !isClosed else { return } + connection.send(content: data, completion: .contentProcessed { [weak self] _ in + self?.close(reason: nil) + }) + } + + private static func parseConnectAuthority(_ authority: String) -> (host: String, port: Int)? { + let trimmed = authority.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if trimmed.hasPrefix("[") { + guard let closing = trimmed.firstIndex(of: "]") else { return nil } + let host = String(trimmed[trimmed.index(after: trimmed.startIndex).. 0, port <= 65535 else { return nil } + return (host, port) + } + + guard let colon = trimmed.lastIndex(of: ":") else { return nil } + let host = String(trimmed[.. 0, port <= 65535 else { return nil } + return (host, port) + } + + private static func normalizedProxyTargetHost(_ host: String) -> String { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = trimmed + .trimmingCharacters(in: CharacterSet(charactersIn: ".")) + .lowercased() + // BrowserPanel rewrites loopback URLs to this alias so proxy routing works. + // Resolve it back to true loopback before dialing from the remote daemon. + if normalized == remoteLoopbackProxyAliasHost { + return "127.0.0.1" + } + return host + } + + private static func httpResponse(status: String, closeAfterResponse: Bool = true) -> Data { + var text = "HTTP/1.1 \(status)\r\nProxy-Agent: cmux\r\n" + if closeAfterResponse { + text += "Connection: close\r\n" + } + text += "\r\n" + return Data(text.utf8) + } + } + + private let configuration: WorkspaceRemoteConfiguration + private let remotePath: String + private let localPort: Int + private let onFatalError: (String) -> Void + private let queue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-tunnel.\(UUID().uuidString)", qos: .utility) + + private var listener: NWListener? + private var rpcClient: WorkspaceRemoteDaemonRPCClient? + private var sessions: [UUID: ProxySession] = [:] + private var isStopped = false + + init( + configuration: WorkspaceRemoteConfiguration, + remotePath: String, + localPort: Int, + onFatalError: @escaping (String) -> Void + ) { + self.configuration = configuration + self.remotePath = remotePath + self.localPort = localPort + self.onFatalError = onFatalError + } + + func start() throws { + var capturedError: Error? + queue.sync { + guard !isStopped else { + capturedError = NSError(domain: "programa.remote.proxy", code: 20, userInfo: [ + NSLocalizedDescriptionKey: "proxy tunnel already stopped", + ]) + return + } + do { + let client = WorkspaceRemoteDaemonRPCClient( + configuration: configuration, + remotePath: remotePath + ) { [weak self] detail in + self?.queue.async { + self?.failLocked("Remote daemon transport failed: \(detail)") + } + } + try client.start() + + let listener = try Self.makeLoopbackListener(port: localPort) + listener.newConnectionHandler = { [weak self] connection in + self?.queue.async { + self?.acceptConnectionLocked(connection) + } + } + listener.stateUpdateHandler = { [weak self] state in + self?.queue.async { + self?.handleListenerStateLocked(state) + } + } + + self.rpcClient = client + self.listener = listener + listener.start(queue: queue) + } catch { + capturedError = error + stopLocked(notify: false) + } + } + if let capturedError { + throw capturedError + } + } + + func stop() { + queue.sync { + stopLocked(notify: false) + } + } + + private func handleListenerStateLocked(_ state: NWListener.State) { + guard !isStopped else { return } + switch state { + case .failed(let error): + failLocked("Local proxy listener failed: \(error)") + default: + break + } + } + + private func acceptConnectionLocked(_ connection: NWConnection) { + guard !isStopped else { + connection.cancel() + return + } + guard let rpcClient else { + connection.cancel() + return + } + + let session = ProxySession( + connection: connection, + rpcClient: rpcClient, + queue: queue + ) { [weak self] id in + self?.queue.async { + self?.sessions.removeValue(forKey: id) + } + } + sessions[session.id] = session + session.start() + } + + private func failLocked(_ detail: String) { + guard !isStopped else { return } + stopLocked(notify: false) + onFatalError(detail) + } + + private func stopLocked(notify: Bool) { + guard !isStopped else { return } + isStopped = true + + listener?.stateUpdateHandler = nil + listener?.newConnectionHandler = nil + listener?.cancel() + listener = nil + + let activeSessions = sessions.values + sessions.removeAll() + for session in activeSessions { + session.stop() + } + + rpcClient?.stop() + rpcClient = nil + } + + private static func makeLoopbackListener(port: Int) throws -> NWListener { + guard let localPort = NWEndpoint.Port(rawValue: UInt16(port)) else { + throw NSError(domain: "programa.remote.proxy", code: 21, userInfo: [ + NSLocalizedDescriptionKey: "invalid local proxy port \(port)", + ]) + } + let tcpOptions = NWProtocolTCP.Options() + tcpOptions.noDelay = true + let parameters = NWParameters(tls: nil, tcp: tcpOptions) + parameters.allowLocalEndpointReuse = true + parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: localPort) + return try NWListener(using: parameters) + } +} + +final class WorkspaceRemoteProxyBroker { + enum Update { + case connecting + case ready(BrowserProxyEndpoint) + case error(String) + } + + final class Lease { + private let key: String + private let subscriberID: UUID + private weak var broker: WorkspaceRemoteProxyBroker? + private var isReleased = false + + fileprivate init(key: String, subscriberID: UUID, broker: WorkspaceRemoteProxyBroker) { + self.key = key + self.subscriberID = subscriberID + self.broker = broker + } + + func release() { + guard !isReleased else { return } + isReleased = true + broker?.release(key: key, subscriberID: subscriberID) + } + + deinit { + release() + } + } + + private final class Entry { + let configuration: WorkspaceRemoteConfiguration + var remotePath: String + var tunnel: WorkspaceRemoteDaemonProxyTunnel? + var endpoint: BrowserProxyEndpoint? + var restartWorkItem: DispatchWorkItem? + var restartRetryCount = 0 + var subscribers: [UUID: (Update) -> Void] = [:] + + init(configuration: WorkspaceRemoteConfiguration, remotePath: String) { + self.configuration = configuration + self.remotePath = remotePath + } + } + + static let shared = WorkspaceRemoteProxyBroker() + + private let queue = DispatchQueue(label: "com.cmux.remote-ssh.proxy-broker", qos: .utility) + private var entries: [String: Entry] = [:] + + func acquire( + configuration: WorkspaceRemoteConfiguration, + remotePath: String, + onUpdate: @escaping (Update) -> Void + ) -> Lease { + queue.sync { + let key = Self.transportKey(for: configuration) + let subscriberID = UUID() + let entry: Entry + if let existing = entries[key] { + entry = existing + if existing.remotePath != remotePath { + existing.remotePath = remotePath + existing.restartRetryCount = 0 + if existing.tunnel != nil { + stopEntryRuntimeLocked(existing) + notifyLocked(existing, update: .connecting) + } + } + } else { + entry = Entry(configuration: configuration, remotePath: remotePath) + entries[key] = entry + } + + entry.subscribers[subscriberID] = onUpdate + if let endpoint = entry.endpoint { + onUpdate(.ready(endpoint)) + } else { + onUpdate(.connecting) + } + + if entry.tunnel == nil, entry.restartWorkItem == nil { + startEntryLocked(key: key, entry: entry) + } + + return Lease(key: key, subscriberID: subscriberID, broker: self) + } + } + + private func release(key: String, subscriberID: UUID) { + queue.async { [weak self] in + guard let self, let entry = self.entries[key] else { return } + entry.subscribers.removeValue(forKey: subscriberID) + guard entry.subscribers.isEmpty else { return } + self.teardownEntryLocked(key: key, entry: entry) + } + } + + private func startEntryLocked(key: String, entry: Entry) { + entry.restartWorkItem?.cancel() + entry.restartWorkItem = nil + + let localPort: Int + if let forcedLocalPort = entry.configuration.localProxyPort { + // Internal deterministic test hook used by docker regressions to force bind conflicts. + localPort = forcedLocalPort + } else { + let retryDelay = Self.retryDelay(baseDelay: 3.0, retry: entry.restartRetryCount + 1) + guard let allocatedPort = Self.allocateLoopbackPort() else { + notifyLocked( + entry, + update: .error("Failed to allocate local proxy port\(Self.retrySuffix(delay: retryDelay))") + ) + scheduleRestartLocked(key: key, entry: entry, baseDelay: 3.0) + return + } + localPort = allocatedPort + } + + do { + let tunnel = WorkspaceRemoteDaemonProxyTunnel( + configuration: entry.configuration, + remotePath: entry.remotePath, + localPort: localPort + ) { [weak self] detail in + self?.queue.async { + self?.handleTunnelFailureLocked(key: key, detail: detail) + } + } + try tunnel.start() + entry.tunnel = tunnel + let endpoint = BrowserProxyEndpoint(host: "127.0.0.1", port: localPort) + entry.endpoint = endpoint + entry.restartRetryCount = 0 + notifyLocked(entry, update: .ready(endpoint)) + } catch { + stopEntryRuntimeLocked(entry) + let detail = "Failed to start local daemon proxy: \(error.localizedDescription)" + let retryDelay = Self.retryDelay(baseDelay: 3.0, retry: entry.restartRetryCount + 1) + notifyLocked(entry, update: .error("\(detail)\(Self.retrySuffix(delay: retryDelay))")) + scheduleRestartLocked(key: key, entry: entry, baseDelay: 3.0) + } + } + + private func handleTunnelFailureLocked(key: String, detail: String) { + guard let entry = entries[key], entry.tunnel != nil else { return } + stopEntryRuntimeLocked(entry) + let retryDelay = Self.retryDelay(baseDelay: 3.0, retry: entry.restartRetryCount + 1) + notifyLocked(entry, update: .error("\(detail)\(Self.retrySuffix(delay: retryDelay))")) + scheduleRestartLocked(key: key, entry: entry, baseDelay: 3.0) + } + + private func scheduleRestartLocked(key: String, entry: Entry, baseDelay: TimeInterval) { + guard !entry.subscribers.isEmpty else { + teardownEntryLocked(key: key, entry: entry) + return + } + guard entry.restartWorkItem == nil else { return } + entry.restartRetryCount += 1 + let retryDelay = Self.retryDelay(baseDelay: baseDelay, retry: entry.restartRetryCount) + + let workItem = DispatchWorkItem { [weak self] in + guard let self, let currentEntry = self.entries[key] else { return } + currentEntry.restartWorkItem = nil + guard !currentEntry.subscribers.isEmpty else { + self.teardownEntryLocked(key: key, entry: currentEntry) + return + } + self.notifyLocked(currentEntry, update: .connecting) + self.startEntryLocked(key: key, entry: currentEntry) + } + + entry.restartWorkItem = workItem + queue.asyncAfter(deadline: .now() + retryDelay, execute: workItem) + } + + private func teardownEntryLocked(key: String, entry: Entry) { + entry.restartWorkItem?.cancel() + entry.restartWorkItem = nil + stopEntryRuntimeLocked(entry) + entries.removeValue(forKey: key) + } + + private func stopEntryRuntimeLocked(_ entry: Entry) { + entry.tunnel?.stop() + entry.tunnel = nil + entry.endpoint = nil + } + + private func notifyLocked(_ entry: Entry, update: Update) { + for callback in entry.subscribers.values { + callback(update) + } + } + + private static func transportKey(for configuration: WorkspaceRemoteConfiguration) -> String { + configuration.proxyBrokerTransportKey + } + + private static func allocateLoopbackPort() -> Int? { + for _ in 0..<8 { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return nil } + defer { close(fd) } + + var yes: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout.size)) + + var addr = sockaddr_in() + addr.sin_len = UInt8(MemoryLayout.size) + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = in_port_t(0) + addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + + let bindResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + bind(fd, sockaddrPtr, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { continue } + + var bound = sockaddr_in() + var len = socklen_t(MemoryLayout.size) + let nameResult = withUnsafeMutablePointer(to: &bound) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + getsockname(fd, sockaddrPtr, &len) + } + } + guard nameResult == 0 else { continue } + + let port = Int(UInt16(bigEndian: bound.sin_port)) + if port > 0 && port <= 65535 { + return port + } + } + return nil + } + + private static func retrySuffix(delay: TimeInterval) -> String { + let seconds = max(1, Int(delay.rounded())) + return " (retry in \(seconds)s)" + } + + private static func retryDelay(baseDelay: TimeInterval, retry: Int) -> TimeInterval { + let exponent = Double(max(0, retry - 1)) + return min(baseDelay * pow(2.0, exponent), 60.0) + } +} diff --git a/Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift b/Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift new file mode 100644 index 00000000000..867f77c3cd0 --- /dev/null +++ b/Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift @@ -0,0 +1,60 @@ +// Extracted from WorkspaceRemoteDaemon.swift (nuclear-review #98): SSH argument builders for daemon-transport batch commands. + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +enum WorkspaceRemoteSSHBatchCommandBuilder { + static func daemonTransportArguments( + configuration: WorkspaceRemoteConfiguration, + remotePath: String + ) -> [String] { + let script = "exec \(RemoteSSHConnectionPolicy.shellSingleQuoted(remotePath)) serve --stdio" + let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(script))" + return ["-T"] + + batchArguments(configuration: configuration) + + ["-o", "RequestTTY=no", configuration.destination, command] + } + + static func reverseRelayControlMasterArguments( + configuration: WorkspaceRemoteConfiguration, + controlCommand: String, + forwardSpec: String + ) -> [String]? { + guard let controlPath = RemoteSSHConnectionPolicy.optionValue(named: "ControlPath", in: configuration.sshOptions)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !controlPath.isEmpty, + controlPath.lowercased() != "none" else { + return nil + } + + var args = batchArguments(configuration: configuration) + args += ["-O", controlCommand, "-R", forwardSpec, configuration.destination] + return args + } + + private static func batchArguments(configuration: WorkspaceRemoteConfiguration) -> [String] { + let effectiveSSHOptions = RemoteSSHConnectionPolicy.backgroundOptions(configuration.sshOptions) + var args = RemoteSSHConnectionPolicy.keepaliveArguments + args += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: effectiveSSHOptions) + // Batch helpers may reuse an existing ControlPath, but must not negotiate a new master. + args += RemoteSSHConnectionPolicy.batchModeArguments + if let port = configuration.port { + args += ["-p", String(port)] + } + if let identityFile = configuration.identityFile, + !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args += ["-i", identityFile] + } + for option in effectiveSSHOptions { + args += ["-o", option] + } + return args + } +} diff --git a/Sources/WorkspaceRemoteSession.swift b/Sources/WorkspaceRemoteSession.swift index 4444612cee4..e497c992f61 100644 --- a/Sources/WorkspaceRemoteSession.swift +++ b/Sources/WorkspaceRemoteSession.swift @@ -36,41 +36,41 @@ final class WorkspaceRemoteSessionController { } } - private struct RetrySchedule { + struct RetrySchedule { let retry: Int let delay: TimeInterval } - private struct CommandResult { + struct CommandResult { let status: Int32 let stdout: String let stderr: String } - private struct RemotePlatform { + struct RemotePlatform { let goOS: String let goArch: String } - private struct RemoteBootstrapState { + struct RemoteBootstrapState { let platform: RemotePlatform let binaryExists: Bool } - private struct DaemonHello { + struct DaemonHello { let name: String let version: String let capabilities: [String] let remotePath: String } - private let queue = DispatchQueue(label: "com.cmux.remote-ssh.\(UUID().uuidString)", qos: .utility) - private let queueKey = DispatchSpecificKey() - private weak var workspace: Workspace? - private let configuration: WorkspaceRemoteConfiguration - private let controllerID: UUID + let queue = DispatchQueue(label: "com.cmux.remote-ssh.\(UUID().uuidString)", qos: .utility) + let queueKey = DispatchSpecificKey() + weak var workspace: Workspace? + let configuration: WorkspaceRemoteConfiguration + let controllerID: UUID - private enum RemotePortPollingMode { + enum RemotePortPollingMode { case hostWide case hostWideDelta case ttyScoped @@ -98,40 +98,40 @@ final class WorkspaceRemoteSessionController { } } - private var isStopping = false - private var proxyLease: WorkspaceRemoteProxyBroker.Lease? - private var proxyEndpoint: BrowserProxyEndpoint? - private var daemonReady = false - private var daemonBootstrapVersion: String? - private var daemonRemotePath: String? - private var reverseRelayProcess: Process? - private var reverseRelayControlMasterForwardSpec: String? - private var cliRelayServer: WorkspaceRemoteCLIRelayServer? - private var remotePortScanTTYNames: [UUID: String] = [:] - private var remoteScannedPortsByPanel: [UUID: [Int]] = [:] - private var remotePortScanBurstActive = false - private var remotePortScanActiveReason: PortScanKickReason? - private var remotePortScanPendingReason: PortScanKickReason? - private var remotePortScanGeneration: UInt64 = 0 - private var remotePortScanCoalesceWorkItem: DispatchWorkItem? - private var remotePortPollTimer: DispatchSourceTimer? - private var remotePortPollMode: RemotePortPollingMode? - private var polledRemotePorts: [Int] = [] - private var remotePortPollBaselinePorts: Set? - private var keepPolledRemotePortsUntilTTYScan = false - private var bootstrapRemoteTTYResolved = false - private var bootstrapRemoteTTYRetryWorkItem: DispatchWorkItem? - private var bootstrapRemoteTTYFetchInFlight = false - private var bootstrapRemoteTTYRetryCount = 0 - private var reverseRelayStderrPipe: Pipe? - private var reverseRelayRestartWorkItem: DispatchWorkItem? - private var reverseRelayStderrBuffer = "" - private var reconnectRetryCount = 0 - private var reconnectWorkItem: DispatchWorkItem? - private var heartbeatCount: Int = 0 - private var connectionAttemptStartedAt: Date? - - private static let reverseRelayStartupGracePeriod: TimeInterval = 0.5 + var isStopping = false + var proxyLease: WorkspaceRemoteProxyBroker.Lease? + var proxyEndpoint: BrowserProxyEndpoint? + var daemonReady = false + var daemonBootstrapVersion: String? + var daemonRemotePath: String? + var reverseRelayProcess: Process? + var reverseRelayControlMasterForwardSpec: String? + var cliRelayServer: WorkspaceRemoteCLIRelayServer? + var remotePortScanTTYNames: [UUID: String] = [:] + var remoteScannedPortsByPanel: [UUID: [Int]] = [:] + var remotePortScanBurstActive = false + var remotePortScanActiveReason: PortScanKickReason? + var remotePortScanPendingReason: PortScanKickReason? + var remotePortScanGeneration: UInt64 = 0 + var remotePortScanCoalesceWorkItem: DispatchWorkItem? + var remotePortPollTimer: DispatchSourceTimer? + var remotePortPollMode: RemotePortPollingMode? + var polledRemotePorts: [Int] = [] + var remotePortPollBaselinePorts: Set? + var keepPolledRemotePortsUntilTTYScan = false + var bootstrapRemoteTTYResolved = false + var bootstrapRemoteTTYRetryWorkItem: DispatchWorkItem? + var bootstrapRemoteTTYFetchInFlight = false + var bootstrapRemoteTTYRetryCount = 0 + var reverseRelayStderrPipe: Pipe? + var reverseRelayRestartWorkItem: DispatchWorkItem? + var reverseRelayStderrBuffer = "" + var reconnectRetryCount = 0 + var reconnectWorkItem: DispatchWorkItem? + var heartbeatCount: Int = 0 + var connectionAttemptStartedAt: Date? + + static let reverseRelayStartupGracePeriod: TimeInterval = 0.5 init(workspace: Workspace, configuration: WorkspaceRemoteConfiguration, controllerID: UUID) { self.workspace = workspace @@ -211,2432 +211,4 @@ final class WorkspaceRemoteSessionController { ) } - private func stopAllLocked() { - debugLog("remote.session.stop \(debugConfigSummary())") - isStopping = true - reconnectWorkItem?.cancel() - reconnectWorkItem = nil - reconnectRetryCount = 0 - reverseRelayRestartWorkItem?.cancel() - reverseRelayRestartWorkItem = nil - remotePortScanCoalesceWorkItem?.cancel() - remotePortScanCoalesceWorkItem = nil - stopReverseRelayLocked() - remotePortScanGeneration &+= 1 - remotePortScanBurstActive = false - remotePortScanActiveReason = nil - remotePortScanPendingReason = nil - remotePortScanTTYNames.removeAll() - remoteScannedPortsByPanel.removeAll() - stopRemotePortPollingLocked() - polledRemotePorts = [] - remotePortPollBaselinePorts = nil - keepPolledRemotePortsUntilTTYScan = false - bootstrapRemoteTTYResolved = false - bootstrapRemoteTTYRetryWorkItem?.cancel() - bootstrapRemoteTTYRetryWorkItem = nil - bootstrapRemoteTTYFetchInFlight = false - bootstrapRemoteTTYRetryCount = 0 - - proxyLease?.release() - proxyLease = nil - proxyEndpoint = nil - daemonReady = false - daemonBootstrapVersion = nil - daemonRemotePath = nil - publishProxyEndpoint(nil) - publishPortsSnapshotLocked() - } - - private func beginConnectionAttemptLocked() { - guard !isStopping else { return } - - Self.killOrphanedRemoteSSHProcesses( - destination: configuration.destination, - relayPort: configuration.relayPort - ) - connectionAttemptStartedAt = Date() - debugLog("remote.session.connect.begin retry=\(reconnectRetryCount) \(debugConfigSummary())") - reconnectWorkItem = nil - bootstrapRemoteTTYRetryWorkItem?.cancel() - bootstrapRemoteTTYRetryWorkItem = nil - bootstrapRemoteTTYFetchInFlight = false - if remotePortScanTTYNames.isEmpty { - bootstrapRemoteTTYResolved = false - bootstrapRemoteTTYRetryCount = 0 - } - let connectDetail: String - let bootstrapDetail: String - if reconnectRetryCount > 0 { - connectDetail = "Reconnecting to \(configuration.displayTarget) (retry \(reconnectRetryCount))" - bootstrapDetail = "Bootstrapping remote daemon on \(configuration.displayTarget) (retry \(reconnectRetryCount))" - } else { - connectDetail = "Connecting to \(configuration.displayTarget)" - bootstrapDetail = "Bootstrapping remote daemon on \(configuration.displayTarget)" - } - publishState(.connecting, detail: connectDetail) - publishDaemonStatus(.bootstrapping, detail: bootstrapDetail) - do { - let hello = try bootstrapDaemonLocked() - guard hello.capabilities.contains(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability) else { - throw NSError(domain: "programa.remote.daemon", code: 43, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon missing required capability \(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability)", - ]) - } - daemonReady = true - daemonBootstrapVersion = hello.version - daemonRemotePath = hello.remotePath - publishDaemonStatus( - .ready, - detail: "Remote daemon ready", - version: hello.version, - name: hello.name, - capabilities: hello.capabilities, - remotePath: hello.remotePath - ) - recordHeartbeatActivityLocked() - startReverseRelayLocked(remotePath: hello.remotePath) - requestBootstrapRemoteTTYIfNeededLocked() - startProxyLocked() - } catch { - daemonReady = false - daemonBootstrapVersion = nil - daemonRemotePath = nil - let retrySchedule = scheduleReconnectLocked(baseDelay: 4.0) - let retrySuffix = Self.retrySuffix(retry: retrySchedule.retry, delay: retrySchedule.delay) - let detail = "Remote daemon bootstrap failed: \(error.localizedDescription)\(retrySuffix)" - publishDaemonStatus(.error, detail: detail) - publishState(.error, detail: detail) - } - } - - private func startProxyLocked() { - guard !isStopping else { return } - guard daemonReady else { return } - guard proxyLease == nil else { return } - guard let remotePath = daemonRemotePath, - !remotePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - let retrySchedule = scheduleReconnectLocked(baseDelay: 4.0) - let retrySuffix = Self.retrySuffix(retry: retrySchedule.retry, delay: retrySchedule.delay) - let detail = "Remote daemon did not provide a valid remote path\(retrySuffix)" - publishDaemonStatus(.error, detail: detail) - publishState(.error, detail: detail) - return - } - - let lease = WorkspaceRemoteProxyBroker.shared.acquire( - configuration: configuration, - remotePath: remotePath - ) { [weak self] update in - self?.queue.async { - self?.handleProxyBrokerUpdateLocked(update) - } - } - proxyLease = lease - } - - private func startReverseRelayLocked(remotePath: String) { - guard !isStopping else { return } - guard daemonReady else { return } - guard let relayPort = configuration.relayPort, relayPort > 0, - let relayID = configuration.relayID?.trimmingCharacters(in: .whitespacesAndNewlines), - !relayID.isEmpty, - let relayToken = configuration.relayToken?.trimmingCharacters(in: .whitespacesAndNewlines), - !relayToken.isEmpty, - let localSocketPath = configuration.localSocketPath? - .trimmingCharacters(in: .whitespacesAndNewlines), - !localSocketPath.isEmpty else { - return - } - guard reverseRelayProcess == nil else { return } - guard reverseRelayControlMasterForwardSpec == nil else { return } - - reverseRelayRestartWorkItem?.cancel() - reverseRelayRestartWorkItem = nil - var relayServer: WorkspaceRemoteCLIRelayServer? - do { - let server = try ensureCLIRelayServerLocked( - localSocketPath: localSocketPath, - relayID: relayID, - relayToken: relayToken - ) - relayServer = server - let localRelayPort = try server.start() - Self.killOrphanedRemoteSSHProcesses( - destination: configuration.destination, - relayPort: relayPort - ) - let forwardSpec = "127.0.0.1:\(relayPort):127.0.0.1:\(localRelayPort)" - - if startReverseRelayViaControlMasterLocked(forwardSpec: forwardSpec) { - cliRelayServer = relayServer - reverseRelayStderrBuffer = "" - do { - try installRemoteRelayMetadataLocked( - remotePath: remotePath, - relayPort: relayPort, - relayID: relayID, - relayToken: relayToken - ) - } catch { - debugLog("remote.relay.metadata.error \(error.localizedDescription)") - stopReverseRelayLocked() - scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) - return - } - recordHeartbeatActivityLocked() - debugLog( - "remote.relay.start relayPort=\(relayPort) localRelayPort=\(localRelayPort) " + - "target=\(configuration.displayTarget) controlMaster=1" - ) - return - } - - let process = Process() - let stderrPipe = Pipe() - process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") - process.arguments = reverseRelayArguments(relayPort: relayPort, localRelayPort: localRelayPort) - process.standardInput = FileHandle.nullDevice - process.standardOutput = FileHandle.nullDevice - process.standardError = stderrPipe - - process.terminationHandler = { [weak self] terminated in - self?.queue.async { - self?.handleReverseRelayTerminationLocked(process: terminated) - } - } - - try process.run() - if let startupFailure = Self.reverseRelayStartupFailureDetail( - process: process, - stderrPipe: stderrPipe - ) { - let retryDelay = 2.0 - let retrySeconds = max(1, Int(retryDelay.rounded())) - debugLog( - "remote.relay.startFailed relayPort=\(relayPort) " + - "error=\(startupFailure)" - ) - relayServer?.stop() - publishDaemonStatus( - .error, - detail: "Remote SSH relay unavailable: \(startupFailure) (retry in \(retrySeconds)s)" - ) - scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: retryDelay) - return - } - installReverseRelayStderrHandlerLocked(stderrPipe) - reverseRelayProcess = process - cliRelayServer = relayServer - reverseRelayStderrPipe = stderrPipe - reverseRelayStderrBuffer = "" - do { - try installRemoteRelayMetadataLocked( - remotePath: remotePath, - relayPort: relayPort, - relayID: relayID, - relayToken: relayToken - ) - } catch { - debugLog("remote.relay.metadata.error \(error.localizedDescription)") - stopReverseRelayLocked() - scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) - return - } - recordHeartbeatActivityLocked() - debugLog( - "remote.relay.start relayPort=\(relayPort) localRelayPort=\(localRelayPort) " + - "target=\(configuration.displayTarget) controlMaster=0" - ) - } catch { - debugLog( - "remote.relay.startFailed relayPort=\(relayPort) " + - "error=\(error.localizedDescription)" - ) - relayServer?.stop() - cliRelayServer = nil - scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) - } - } - - private func installReverseRelayStderrHandlerLocked(_ stderrPipe: Pipe) { - stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - guard !data.isEmpty else { - handle.readabilityHandler = nil - return - } - self?.queue.async { - guard let self else { return } - if let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty { - self.reverseRelayStderrBuffer.append(chunk) - if self.reverseRelayStderrBuffer.count > 8192 { - self.reverseRelayStderrBuffer.removeFirst(self.reverseRelayStderrBuffer.count - 8192) - } - } - } - } - } - - private func handleReverseRelayTerminationLocked(process: Process) { - guard reverseRelayProcess === process else { return } - let stderrDetail = Self.bestErrorLine(stderr: reverseRelayStderrBuffer) - reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil - reverseRelayProcess = nil - reverseRelayStderrPipe = nil - - guard !isStopping else { return } - guard let remotePath = daemonRemotePath, - !remotePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } - - let detail = stderrDetail ?? "status=\(process.terminationStatus)" - debugLog("remote.relay.exit \(detail)") - scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) - } - - private func scheduleReverseRelayRestartLocked(remotePath: String, delay: TimeInterval) { - guard !isStopping else { return } - reverseRelayRestartWorkItem?.cancel() - - let workItem = DispatchWorkItem { [weak self] in - guard let self else { return } - self.reverseRelayRestartWorkItem = nil - guard !self.isStopping else { return } - guard self.reverseRelayProcess == nil else { return } - guard self.daemonReady else { return } - self.startReverseRelayLocked(remotePath: self.daemonRemotePath ?? remotePath) - } - reverseRelayRestartWorkItem = workItem - queue.asyncAfter(deadline: .now() + delay, execute: workItem) - } - - private func stopReverseRelayLocked() { - reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil - if let reverseRelayProcess, reverseRelayProcess.isRunning { - reverseRelayProcess.terminate() - } - reverseRelayProcess = nil - stopReverseRelayViaControlMasterLocked() - reverseRelayStderrPipe = nil - reverseRelayStderrBuffer = "" - cliRelayServer?.stop() - cliRelayServer = nil - removeRemoteRelayMetadataLocked() - } - - private func handleProxyBrokerUpdateLocked(_ update: WorkspaceRemoteProxyBroker.Update) { - guard !isStopping else { return } - switch update { - case .connecting: - debugLog("remote.proxy.connecting \(debugConfigSummary())") - if proxyEndpoint == nil { - publishState(.connecting, detail: "Connecting to \(configuration.displayTarget)") - } - case .ready(let endpoint): - debugLog("remote.proxy.ready host=\(endpoint.host) port=\(endpoint.port) \(debugConfigSummary())") - reconnectWorkItem?.cancel() - reconnectWorkItem = nil - reconnectRetryCount = 0 - guard proxyEndpoint != endpoint else { - recordHeartbeatActivityLocked() - return - } - proxyEndpoint = endpoint - publishProxyEndpoint(endpoint) - updateRemotePortPollingStateLocked() - publishPortsSnapshotLocked() - publishState( - .connected, - detail: "Connected to \(configuration.displayTarget) via shared local proxy \(endpoint.host):\(endpoint.port)" - ) - requestBootstrapRemoteTTYIfNeededLocked() - recordHeartbeatActivityLocked() - case .error(let detail): - debugLog("remote.proxy.error detail=\(detail) \(debugConfigSummary())") - remotePortScanGeneration &+= 1 - remotePortScanBurstActive = false - remotePortScanActiveReason = nil - remotePortScanPendingReason = nil - remotePortScanCoalesceWorkItem?.cancel() - remotePortScanCoalesceWorkItem = nil - remoteScannedPortsByPanel.removeAll() - stopRemotePortPollingLocked() - polledRemotePorts = [] - keepPolledRemotePortsUntilTTYScan = false - proxyEndpoint = nil - publishProxyEndpoint(nil) - publishPortsSnapshotLocked() - publishState(.error, detail: "Remote proxy to \(configuration.displayTarget) unavailable: \(detail)") - guard Self.shouldEscalateProxyErrorToBootstrap(detail) else { return } - - proxyLease?.release() - proxyLease = nil - daemonReady = false - daemonBootstrapVersion = nil - daemonRemotePath = nil - - let retrySchedule = scheduleReconnectLocked(baseDelay: 2.0) - let retrySuffix = Self.retrySuffix(retry: retrySchedule.retry, delay: retrySchedule.delay) - publishDaemonStatus( - .error, - detail: "Remote daemon transport needs re-bootstrap after proxy failure\(retrySuffix)" - ) - } - } - - @discardableResult - private func scheduleReconnectLocked(baseDelay: TimeInterval) -> RetrySchedule { - let retryNumber = reconnectRetryCount + 1 - let retryDelay = Self.retryDelay(baseDelay: baseDelay, retry: retryNumber) - guard !isStopping else { return RetrySchedule(retry: retryNumber, delay: retryDelay) } - reconnectWorkItem?.cancel() - reconnectRetryCount = retryNumber - let workItem = DispatchWorkItem { [weak self] in - guard let self else { return } - self.reconnectWorkItem = nil - guard !self.isStopping else { return } - guard self.proxyLease == nil else { return } - self.beginConnectionAttemptLocked() - } - reconnectWorkItem = workItem - queue.asyncAfter(deadline: .now() + retryDelay, execute: workItem) - return RetrySchedule(retry: retryNumber, delay: retryDelay) - } - - private func publishState(_ state: WorkspaceRemoteConnectionState, detail: String?) { - let controllerID = self.controllerID - DispatchQueue.main.async { [weak workspace] in - guard let workspace else { return } - guard workspace.activeRemoteSessionControllerID == controllerID else { return } - workspace.applyRemoteConnectionStateUpdate( - state, - detail: detail, - target: workspace.remoteDisplayTarget ?? "remote host" - ) - } - } - - private func publishDaemonStatus( - _ state: WorkspaceRemoteDaemonState, - detail: String?, - version: String? = nil, - name: String? = nil, - capabilities: [String] = [], - remotePath: String? = nil - ) { - let controllerID = self.controllerID - let status = WorkspaceRemoteDaemonStatus( - state: state, - detail: detail, - version: version, - name: name, - capabilities: capabilities, - remotePath: remotePath - ) - DispatchQueue.main.async { [weak workspace] in - guard let workspace else { return } - guard workspace.activeRemoteSessionControllerID == controllerID else { return } - workspace.applyRemoteDaemonStatusUpdate( - status, - target: workspace.remoteDisplayTarget ?? "remote host" - ) - } - } - - private func publishProxyEndpoint(_ endpoint: BrowserProxyEndpoint?) { - let controllerID = self.controllerID - DispatchQueue.main.async { [weak workspace] in - guard let workspace else { return } - guard workspace.activeRemoteSessionControllerID == controllerID else { return } - workspace.applyRemoteProxyEndpointUpdate(endpoint) - } - } - - private func publishPortsSnapshotLocked() { - let controllerID = self.controllerID - let detectedByPanel = remotePortScanTTYNames.keys.reduce(into: [UUID: [Int]]()) { result, panelId in - result[panelId] = remoteScannedPortsByPanel[panelId] ?? [] - } - let detected = Array( - Set(polledRemotePorts) - .union(detectedByPanel.values.flatMap { $0 }) - ).sorted() - DispatchQueue.main.async { [weak workspace] in - guard let workspace else { return } - guard workspace.activeRemoteSessionControllerID == controllerID else { return } - workspace.applyRemoteDetectedSurfacePortsSnapshot( - detectedByPanel: detectedByPanel, - detected: detected, - forwarded: [], - conflicts: [], - target: workspace.remoteDisplayTarget ?? "remote host" - ) - } - } - - private func recordHeartbeatActivityLocked() { - heartbeatCount += 1 - publishHeartbeat(count: heartbeatCount, at: Date()) - } - - private func publishHeartbeat(count: Int, at date: Date?) { - let controllerID = self.controllerID - DispatchQueue.main.async { [weak workspace] in - guard let workspace else { return } - guard workspace.activeRemoteSessionControllerID == controllerID else { return } - workspace.applyRemoteHeartbeatUpdate(count: count, lastSeenAt: date) - } - } - - private func requestBootstrapRemoteTTYIfNeededLocked() { - guard !bootstrapRemoteTTYResolved else { return } - guard let relayPort = configuration.relayPort, relayPort > 0 else { return } - if !remotePortScanTTYNames.isEmpty { - bootstrapRemoteTTYResolved = true - bootstrapRemoteTTYRetryWorkItem?.cancel() - bootstrapRemoteTTYRetryWorkItem = nil - bootstrapRemoteTTYRetryCount = 0 - return - } - guard !bootstrapRemoteTTYFetchInFlight else { return } - bootstrapRemoteTTYFetchInFlight = true - defer { bootstrapRemoteTTYFetchInFlight = false } - - let command = "sh -c \(Self.shellSingleQuoted("tty_path=\"$HOME/.programa/relay/\(relayPort).tty\"; if [ -r \"$tty_path\" ]; then cat \"$tty_path\"; fi"))" - do { - let result = try sshExec( - arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], - timeout: 2 - ) - guard result.status == 0 else { - scheduleBootstrapRemoteTTYRetryLocked() - return - } - guard let ttyName = Self.normalizedRemotePortScanTTYName(result.stdout) else { - scheduleBootstrapRemoteTTYRetryLocked() - return - } - bootstrapRemoteTTYResolved = true - bootstrapRemoteTTYRetryWorkItem?.cancel() - bootstrapRemoteTTYRetryWorkItem = nil - bootstrapRemoteTTYRetryCount = 0 - debugLog("remote.tty.bootstrap.ready tty=\(ttyName) \(debugConfigSummary())") - publishBootstrapRemoteTTY(ttyName) - } catch { - debugLog("remote.tty.bootstrap.failed error=\(error.localizedDescription) \(debugConfigSummary())") - scheduleBootstrapRemoteTTYRetryLocked() - } - } - - private func scheduleBootstrapRemoteTTYRetryLocked() { - guard !isStopping else { return } - guard daemonReady else { return } - guard !bootstrapRemoteTTYResolved else { return } - guard remotePortScanTTYNames.isEmpty else { return } - guard bootstrapRemoteTTYRetryCount < Self.bootstrapRemoteTTYRetryLimit else { return } - guard bootstrapRemoteTTYRetryWorkItem == nil else { return } - - bootstrapRemoteTTYRetryCount += 1 - let workItem = DispatchWorkItem { [weak self] in - guard let self else { return } - self.bootstrapRemoteTTYRetryWorkItem = nil - self.requestBootstrapRemoteTTYIfNeededLocked() - } - bootstrapRemoteTTYRetryWorkItem = workItem - queue.asyncAfter(deadline: .now() + Self.bootstrapRemoteTTYRetryDelay, execute: workItem) - } - - private func publishBootstrapRemoteTTY(_ ttyName: String) { - let controllerID = self.controllerID - DispatchQueue.main.async { [weak workspace] in - guard let workspace else { return } - guard workspace.activeRemoteSessionControllerID == controllerID else { return } - workspace.applyBootstrapRemoteTTY(ttyName) - } - } - - private func reverseRelayArguments(relayPort: Int, localRelayPort: Int) -> [String] { - // Fallback standalone transport when dynamic forwarding through an existing - // control master is unavailable. - var args: [String] = ["-N", "-T", "-S", "none"] - args += sshCommonArguments(batchMode: true) - args += [ - "-o", "ExitOnForwardFailure=yes", - "-o", "RequestTTY=no", - "-R", "127.0.0.1:\(relayPort):127.0.0.1:\(localRelayPort)", - configuration.destination, - ] - return args - } - - private func startReverseRelayViaControlMasterLocked(forwardSpec: String) -> Bool { - guard let arguments = WorkspaceRemoteSSHBatchCommandBuilder.reverseRelayControlMasterArguments( - configuration: configuration, - controlCommand: "forward", - forwardSpec: forwardSpec - ) else { - return false - } - - do { - let result = try sshExec(arguments: arguments, timeout: 6) - guard result.status == 0 else { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) - ?? "ssh exited \(result.status)" - debugLog("remote.relay.controlmaster.forwardFailed \(detail) \(debugConfigSummary())") - return false - } - reverseRelayControlMasterForwardSpec = forwardSpec - return true - } catch { - debugLog("remote.relay.controlmaster.forwardFailed \(error.localizedDescription) \(debugConfigSummary())") - return false - } - } - - private func stopReverseRelayViaControlMasterLocked() { - guard let forwardSpec = reverseRelayControlMasterForwardSpec else { return } - reverseRelayControlMasterForwardSpec = nil - guard let arguments = WorkspaceRemoteSSHBatchCommandBuilder.reverseRelayControlMasterArguments( - configuration: configuration, - controlCommand: "cancel", - forwardSpec: forwardSpec - ) else { - return - } - _ = try? sshExec(arguments: arguments, timeout: 4) - } - - private static let remotePlatformProbeOSMarker = "__PROGRAMA_REMOTE_OS__=" - private static let remotePlatformProbeArchMarker = "__PROGRAMA_REMOTE_ARCH__=" - private static let remotePlatformProbeExistsMarker = "__PROGRAMA_REMOTE_EXISTS__=" - private static let bootstrapRemoteTTYRetryDelay: TimeInterval = 0.5 - private static let bootstrapRemoteTTYRetryLimit = 8 - - private func sshCommonArguments(batchMode: Bool) -> [String] { - let effectiveSSHOptions: [String] = { - if batchMode { - return backgroundSSHOptions(configuration.sshOptions) - } - return normalizedSSHOptions(configuration.sshOptions) - }() - var args: [String] = [ - "-o", "ConnectTimeout=6", - "-o", "ServerAliveInterval=20", - "-o", "ServerAliveCountMax=2", - ] - if !hasSSHOptionKey(effectiveSSHOptions, key: "StrictHostKeyChecking") { - args += ["-o", "StrictHostKeyChecking=accept-new"] - } - if batchMode { - args += ["-o", "BatchMode=yes"] - args += ["-o", "ControlMaster=no"] - } - if let port = configuration.port { - args += ["-p", String(port)] - } - if let identityFile = configuration.identityFile, - !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args += ["-i", identityFile] - } - for option in effectiveSSHOptions { - args += ["-o", option] - } - return args - } - - private func hasSSHOptionKey(_ options: [String], key: String) -> Bool { - let loweredKey = key.lowercased() - for option in options { - let token = sshOptionKey(option) - if token == loweredKey { - return true - } - } - return false - } - - private func normalizedSSHOptions(_ options: [String]) -> [String] { - options.compactMap { option in - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - } - } - - private func backgroundSSHOptions(_ options: [String]) -> [String] { - let batchSSHControlOptionKeys: Set = [ - "controlmaster", - "controlpersist", - ] - return normalizedSSHOptions(options).filter { option in - guard let key = sshOptionKey(option) else { return false } - return !batchSSHControlOptionKeys.contains(key) - } - } - - private func sshOptionKey(_ option: String) -> String? { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) - .first - .map(String.init)? - .lowercased() - } - - private func sshExec(arguments: [String], stdin: Data? = nil, timeout: TimeInterval = 15) throws -> CommandResult { - try runProcess( - executable: "/usr/bin/ssh", - arguments: arguments, - stdin: stdin, - timeout: timeout - ) - } - - private func scpExec( - arguments: [String], - timeout: TimeInterval = 30, - operation: TerminalImageTransferOperation? = nil - ) throws -> CommandResult { - try runProcess( - executable: "/usr/bin/scp", - arguments: arguments, - stdin: nil, - timeout: timeout, - operation: operation - ) - } - - private func runProcess( - executable: String, - arguments: [String], - environment: [String: String]? = nil, - currentDirectory: URL? = nil, - stdin: Data?, - timeout: TimeInterval, - operation: TerminalImageTransferOperation? = nil - ) throws -> CommandResult { - debugLog( - "remote.proc.start exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + - "timeout=\(Int(timeout)) args=\(debugShellCommand(executable: executable, arguments: arguments))" - ) - let process = Process() - process.executableURL = URL(fileURLWithPath: executable) - process.arguments = arguments - if let environment { - process.environment = environment - } - if let currentDirectory { - process.currentDirectoryURL = currentDirectory - } - - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - if stdin != nil { - process.standardInput = Pipe() - } else { - process.standardInput = FileHandle.nullDevice - } - - let stdoutHandle = stdoutPipe.fileHandleForReading - let stderrHandle = stderrPipe.fileHandleForReading - let captureQueue = DispatchQueue(label: "programa.remote.process.capture") - let exitSemaphore = DispatchSemaphore(value: 0) - var stdoutData = Data() - var stderrData = Data() - let captureGroup = DispatchGroup() - process.terminationHandler = { _ in - exitSemaphore.signal() - } - captureGroup.enter() - DispatchQueue.global(qos: .utility).async { - let data = stdoutHandle.readDataToEndOfFile() - captureQueue.sync { - stdoutData = data - } - captureGroup.leave() - } - captureGroup.enter() - DispatchQueue.global(qos: .utility).async { - let data = stderrHandle.readDataToEndOfFile() - captureQueue.sync { - stderrData = data - } - captureGroup.leave() - } - - do { - try operation?.throwIfCancelled() - try process.run() - } catch { - try? stdoutPipe.fileHandleForWriting.close() - try? stderrPipe.fileHandleForWriting.close() - debugLog( - "remote.proc.launchFailed exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + - "error=\(error.localizedDescription)" - ) - throw NSError(domain: "programa.remote.process", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Failed to launch \(URL(fileURLWithPath: executable).lastPathComponent): \(error.localizedDescription)", - ]) - } - try? stdoutPipe.fileHandleForWriting.close() - try? stderrPipe.fileHandleForWriting.close() - operation?.installCancellationHandler { - if process.isRunning { - process.terminate() - } - } - defer { operation?.clearCancellationHandler() } - - if let stdin, let pipe = process.standardInput as? Pipe { - pipe.fileHandleForWriting.write(stdin) - try? pipe.fileHandleForWriting.close() - } - - func terminateProcessAndWait() { - process.terminate() - let terminatedGracefully = exitSemaphore.wait(timeout: .now() + 2.0) == .success - if !terminatedGracefully, process.isRunning { - _ = Darwin.kill(process.processIdentifier, SIGKILL) - process.waitUntilExit() - } - } - - let didExitBeforeTimeout = exitSemaphore.wait(timeout: .now() + max(0, timeout)) == .success - if !didExitBeforeTimeout, process.isRunning { - if operation?.isCancelled == true { - terminateProcessAndWait() - throw TerminalImageTransferExecutionError.cancelled - } - terminateProcessAndWait() - debugLog( - "remote.proc.timeout exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + - "timeout=\(Int(timeout)) args=\(debugShellCommand(executable: executable, arguments: arguments))" - ) - throw NSError(domain: "programa.remote.process", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "\(URL(fileURLWithPath: executable).lastPathComponent) timed out after \(Int(timeout))s", - ]) - } - - _ = captureGroup.wait(timeout: .now() + 2.0) - try? stdoutHandle.close() - try? stderrHandle.close() - let stdout = String(data: stdoutData, encoding: .utf8) ?? "" - let stderr = String(data: stderrData, encoding: .utf8) ?? "" - if operation?.isCancelled == true { - throw TerminalImageTransferExecutionError.cancelled - } - debugLog( - "remote.proc.end exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + - "status=\(process.terminationStatus) stdout=\(Self.debugLogSnippet(stdout)) " + - "stderr=\(Self.debugLogSnippet(stderr))" - ) - return CommandResult(status: process.terminationStatus, stdout: stdout, stderr: stderr) - } - - private func bootstrapDaemonLocked() throws -> DaemonHello { - debugLog("remote.bootstrap.begin \(debugConfigSummary())") - let version = Self.remoteDaemonVersion() - let bootstrapState = try probeRemoteBootstrapStateLocked(version: version) - let platform = bootstrapState.platform - let remotePath = Self.remoteDaemonPath(version: version, goOS: platform.goOS, goArch: platform.goArch) - let explicitOverrideBinary = Self.explicitRemoteDaemonBinaryURL() - let forceExplicitOverrideInstall = explicitOverrideBinary != nil - debugLog( - "remote.bootstrap.platform os=\(platform.goOS) arch=\(platform.goArch) " + - "version=\(version) remotePath=\(remotePath) " + - "allowLocalBuildFallback=\(Self.allowLocalDaemonBuildFallback() ? 1 : 0) " + - "explicitOverride=\(forceExplicitOverrideInstall ? 1 : 0)" - ) - - let hadExistingBinary = bootstrapState.binaryExists - debugLog("remote.bootstrap.binaryExists remotePath=\(remotePath) exists=\(hadExistingBinary ? 1 : 0)") - if forceExplicitOverrideInstall || !hadExistingBinary { - let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) - try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) - } - - var hello: DaemonHello - do { - hello = try helloRemoteDaemonLocked(remotePath: remotePath) - } catch { - guard hadExistingBinary else { - throw error - } - debugLog( - "remote.bootstrap.helloRetry remotePath=\(remotePath) " + - "detail=\(error.localizedDescription)" - ) - let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) - try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) - hello = try helloRemoteDaemonLocked(remotePath: remotePath) - } - if hadExistingBinary, !hello.capabilities.contains(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability) { - debugLog("remote.bootstrap.capabilityMissing remotePath=\(remotePath) capabilities=\(hello.capabilities.joined(separator: ","))") - let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) - try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) - hello = try helloRemoteDaemonLocked(remotePath: remotePath) - } - - debugLog( - "remote.bootstrap.ready name=\(hello.name) version=\(hello.version) " + - "capabilities=\(hello.capabilities.joined(separator: ",")) remotePath=\(hello.remotePath)" - ) - if let connectionAttemptStartedAt { - debugLog( - "remote.timing.bootstrap.ready elapsedMs=\(Int(Date().timeIntervalSince(connectionAttemptStartedAt) * 1000)) " + - "\(debugConfigSummary())" - ) - } - return hello - } - - private func ensureCLIRelayServerLocked(localSocketPath: String, relayID: String, relayToken: String) throws -> WorkspaceRemoteCLIRelayServer { - if let cliRelayServer { - return cliRelayServer - } - let relayServer = try WorkspaceRemoteCLIRelayServer( - localSocketPath: localSocketPath, - relayID: relayID, - relayTokenHex: relayToken - ) - cliRelayServer = relayServer - return relayServer - } - - private func installRemoteRelayMetadataLocked( - remotePath: String, - relayPort: Int, - relayID: String, - relayToken: String - ) throws { - let script = Self.remoteRelayMetadataInstallScript( - daemonRemotePath: remotePath, - relayPort: relayPort, - relayID: relayID, - relayToken: relayToken - ) - let command = "sh -c \(Self.shellSingleQuoted(script))" - let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) - guard result.status == 0 else { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" - throw NSError(domain: "programa.remote.relay", code: 70, userInfo: [ - NSLocalizedDescriptionKey: "failed to install remote relay metadata: \(detail)", - ]) - } - } - - private func removeRemoteRelayMetadataLocked() { - guard let relayPort = configuration.relayPort, relayPort > 0 else { return } - let script = Self.remoteRelayMetadataCleanupScript(relayPort: relayPort) - let command = "sh -c \(Self.shellSingleQuoted(script))" - do { - _ = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) - } catch { - debugLog("remote.relay.cleanup.error \(error.localizedDescription)") - } - } - - static func remoteRelayMetadataCleanupScript(relayPort: Int) -> String { - """ - relay_socket='127.0.0.1:\(relayPort)' - socket_addr_file="$HOME/.programa/socket_addr" - if [ -r "$socket_addr_file" ] && [ "$(tr -d '\\r\\n' < "$socket_addr_file")" = "$relay_socket" ]; then - rm -f "$socket_addr_file" - fi - rm -f "$HOME/.programa/relay/\(relayPort).auth" "$HOME/.programa/relay/\(relayPort).daemon_path" "$HOME/.programa/relay/\(relayPort).tty" - """ - } - - private func probeRemoteBootstrapStateLocked(version: String) throws -> RemoteBootstrapState { - let script = """ - programa_uname_os="$(uname -s)" - programa_uname_arch="$(uname -m)" - printf '%s%s\\n' '\(Self.remotePlatformProbeOSMarker)' "$programa_uname_os" - printf '%s%s\\n' '\(Self.remotePlatformProbeArchMarker)' "$programa_uname_arch" - case "$(printf '%s' "$programa_uname_os" | tr '[:upper:]' '[:lower:]')" in - linux|darwin|freebsd) programa_go_os="$(printf '%s' "$programa_uname_os" | tr '[:upper:]' '[:lower:]')" ;; - *) exit 70 ;; - esac - case "$(printf '%s' "$programa_uname_arch" | tr '[:upper:]' '[:lower:]')" in - x86_64|amd64) programa_go_arch=amd64 ;; - aarch64|arm64) programa_go_arch=arm64 ;; - armv7l) programa_go_arch=arm ;; - *) exit 71 ;; - esac - programa_remote_path="$HOME/.programa/bin/programad-remote/\(version)/${programa_go_os}-${programa_go_arch}/programad-remote" - if [ -x "$programa_remote_path" ]; then - printf '%syes\\n' '\(Self.remotePlatformProbeExistsMarker)' - else - printf '%sno\\n' '\(Self.remotePlatformProbeExistsMarker)' - fi - """ - let command = "sh -c \(Self.shellSingleQuoted(script))" - let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 20) - - let lines = result.stdout - .split(separator: "\n", omittingEmptySubsequences: false) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - let unameOS = lines.first { $0.hasPrefix(Self.remotePlatformProbeOSMarker) } - .map { String($0.dropFirst(Self.remotePlatformProbeOSMarker.count)) } - let unameArch = lines.first { $0.hasPrefix(Self.remotePlatformProbeArchMarker) } - .map { String($0.dropFirst(Self.remotePlatformProbeArchMarker.count)) } - guard let unameOS, let unameArch else { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" - throw NSError(domain: "programa.remote.daemon", code: 11, userInfo: [ - NSLocalizedDescriptionKey: "failed to query remote platform: \(detail)", - ]) - } - - guard let goOS = Self.mapUnameOS(unameOS), - let goArch = Self.mapUnameArch(unameArch) else { - throw NSError(domain: "programa.remote.daemon", code: 12, userInfo: [ - NSLocalizedDescriptionKey: "unsupported remote platform \(unameOS)/\(unameArch)", - ]) - } - - let binaryExists = lines.first { $0.hasPrefix(Self.remotePlatformProbeExistsMarker) } - .map { String($0.dropFirst(Self.remotePlatformProbeExistsMarker.count)) == "yes" } - if result.status != 0, binaryExists == nil { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" - throw NSError(domain: "programa.remote.daemon", code: 13, userInfo: [ - NSLocalizedDescriptionKey: "failed to query remote daemon state: \(detail)", - ]) - } - - return RemoteBootstrapState( - platform: RemotePlatform(goOS: goOS, goArch: goArch), - binaryExists: binaryExists ?? false - ) - } - - static let remoteDaemonManifestInfoKey = "CMUXRemoteDaemonManifestJSON" - - static func remoteDaemonManifest(from infoDictionary: [String: Any]?) -> WorkspaceRemoteDaemonManifest? { - guard let rawManifest = infoDictionary?[remoteDaemonManifestInfoKey] as? String else { return nil } - let trimmed = rawManifest.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - guard let data = trimmed.data(using: .utf8) else { return nil } - return try? JSONDecoder().decode(WorkspaceRemoteDaemonManifest.self, from: data) - } - - private static func remoteDaemonManifest() -> WorkspaceRemoteDaemonManifest? { - remoteDaemonManifest(from: Bundle.main.infoDictionary) - } - - private static func remoteDaemonCacheRoot(fileManager: FileManager = .default) throws -> URL { - let appSupportRoot = try fileManager.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - let cacheRoot = appSupportRoot - .appendingPathComponent("programa", isDirectory: true) - .appendingPathComponent("remote-daemons", isDirectory: true) - try fileManager.createDirectory(at: cacheRoot, withIntermediateDirectories: true) - return cacheRoot - } - - static func remoteDaemonCachedBinaryURL( - version: String, - goOS: String, - goArch: String, - fileManager: FileManager = .default - ) throws -> URL { - try remoteDaemonCacheRoot(fileManager: fileManager) - .appendingPathComponent(version, isDirectory: true) - .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) - .appendingPathComponent("programad-remote", isDirectory: false) - } - - private static func sha256Hex(forFile url: URL) throws -> String { - let data = try Data(contentsOf: url) - let digest = SHA256.hash(data: data) - return digest.map { String(format: "%02x", $0) }.joined() - } - - private static func allowLocalDaemonBuildFallback(environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool { - environment["PROGRAMA_REMOTE_DAEMON_ALLOW_LOCAL_BUILD"] == "1" - } - - private static func explicitRemoteDaemonBinaryURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? { - guard allowLocalDaemonBuildFallback(environment: environment) else { return nil } - guard let path = environment["PROGRAMA_REMOTE_DAEMON_BINARY"]?.trimmingCharacters(in: .whitespacesAndNewlines), - !path.isEmpty else { - return nil - } - return URL(fileURLWithPath: path, isDirectory: false).standardizedFileURL - } - - private static func versionedRemoteDaemonBuildURL(goOS: String, goArch: String, version: String) -> URL { - URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent("programa-remote-daemon-build", isDirectory: true) - .appendingPathComponent(version, isDirectory: true) - .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) - .appendingPathComponent("programad-remote", isDirectory: false) - } - - /// Fetch the live manifest JSON from the release, returning nil on any failure. - private static func fetchRemoteManifestLocked(releaseURL: String, version: String) -> WorkspaceRemoteDaemonManifest? { - guard let manifestURL = URL(string: "\(releaseURL)/programad-remote-manifest.json") else { return nil } - let request = NSMutableURLRequest(url: manifestURL) - request.timeoutInterval = 15 - request.setValue("cmux/\(version)", forHTTPHeaderField: "User-Agent") - let session = URLSession(configuration: .ephemeral) - let semaphore = DispatchSemaphore(value: 0) - var resultData: Data? - session.dataTask(with: request as URLRequest) { data, response, error in - defer { semaphore.signal() } - guard error == nil, - let httpResponse = response as? HTTPURLResponse, - (200...299).contains(httpResponse.statusCode) else { return } - resultData = data - }.resume() - _ = semaphore.wait(timeout: .now() + 20.0) - session.finishTasksAndInvalidate() - guard let data = resultData else { return nil } - return try? JSONDecoder().decode(WorkspaceRemoteDaemonManifest.self, from: data) - } - - private func downloadRemoteDaemonBinaryLocked(entry: WorkspaceRemoteDaemonManifest.Entry, version: String, releaseURL: String? = nil) throws -> URL { - guard let url = URL(string: entry.downloadURL) else { - throw NSError(domain: "programa.remote.daemon", code: 25, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon manifest has an invalid download URL", - ]) - } - - let cacheURL = try Self.remoteDaemonCachedBinaryURL(version: version, goOS: entry.goOS, goArch: entry.goArch) - let fileManager = FileManager.default - try fileManager.createDirectory(at: cacheURL.deletingLastPathComponent(), withIntermediateDirectories: true) - - let request = NSMutableURLRequest(url: url) - request.timeoutInterval = 60 - request.setValue("cmux/\(version)", forHTTPHeaderField: "User-Agent") - let session = URLSession(configuration: .ephemeral) - - let semaphore = DispatchSemaphore(value: 0) - var downloadedURL: URL? - var downloadError: Error? - session.downloadTask(with: request as URLRequest) { localURL, response, error in - defer { semaphore.signal() } - if let error { - downloadError = error - return - } - if let httpResponse = response as? HTTPURLResponse, - !(200...299).contains(httpResponse.statusCode) { - downloadError = NSError(domain: "programa.remote.daemon", code: 26, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon download failed with HTTP \(httpResponse.statusCode)", - ]) - return - } - downloadedURL = localURL - }.resume() - _ = semaphore.wait(timeout: .now() + 75.0) - session.finishTasksAndInvalidate() - - if let downloadError { - throw downloadError - } - guard let downloadedURL else { - throw NSError(domain: "programa.remote.daemon", code: 27, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon download did not produce a file", - ]) - } - - let downloadedSHA = try Self.sha256Hex(forFile: downloadedURL) - if downloadedSHA != entry.sha256.lowercased() { - // The embedded manifest's checksum doesn't match the downloaded binary. - // This can happen when a newer build overwrites the shared release - // asset after this build's manifest was embedded. As a fallback, fetch - // the live manifest from the release and verify against that. - if let releaseURL, - let liveManifest = Self.fetchRemoteManifestLocked(releaseURL: releaseURL, version: version), - let liveEntry = liveManifest.entry(goOS: entry.goOS, goArch: entry.goArch), - downloadedSHA == liveEntry.sha256.lowercased() { - debugLog("remote.download.checksum-fallback: embedded manifest checksum stale, live manifest matched for \(entry.assetName)") - } else { - throw NSError(domain: "programa.remote.daemon", code: 28, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon checksum mismatch for \(entry.assetName)", - ]) - } - } - - let tempURL = cacheURL.deletingLastPathComponent() - .appendingPathComponent(".\(cacheURL.lastPathComponent).tmp-\(UUID().uuidString)") - try? fileManager.removeItem(at: tempURL) - try fileManager.moveItem(at: downloadedURL, to: tempURL) - try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tempURL.path) - try? fileManager.removeItem(at: cacheURL) - try fileManager.moveItem(at: tempURL, to: cacheURL) - return cacheURL - } - - private func buildLocalDaemonBinary(goOS: String, goArch: String, version: String) throws -> URL { - if let explicitBinary = Self.explicitRemoteDaemonBinaryURL(), - FileManager.default.isExecutableFile(atPath: explicitBinary.path) { - debugLog("remote.build.explicit path=\(explicitBinary.path)") - return explicitBinary - } - - if let manifest = Self.remoteDaemonManifest(), - manifest.appVersion == version, - let entry = manifest.entry(goOS: goOS, goArch: goArch) { - let cacheURL = try Self.remoteDaemonCachedBinaryURL(version: manifest.appVersion, goOS: goOS, goArch: goArch) - if FileManager.default.fileExists(atPath: cacheURL.path) { - let cachedSHA = try Self.sha256Hex(forFile: cacheURL) - if cachedSHA == entry.sha256.lowercased(), - FileManager.default.isExecutableFile(atPath: cacheURL.path) { - debugLog("remote.build.cached path=\(cacheURL.path)") - return cacheURL - } - try? FileManager.default.removeItem(at: cacheURL) - } - let downloadedURL = try downloadRemoteDaemonBinaryLocked(entry: entry, version: manifest.appVersion, releaseURL: manifest.releaseURL) - debugLog("remote.build.downloaded path=\(downloadedURL.path)") - return downloadedURL - } - - guard Self.allowLocalDaemonBuildFallback() else { - throw NSError(domain: "programa.remote.daemon", code: 20, userInfo: [ - NSLocalizedDescriptionKey: "this build does not include a verified programad-remote manifest for \(goOS)-\(goArch). Use a release build, or set PROGRAMA_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 for a dev-only fallback.", - ]) - } - - guard let repoRoot = Self.findRepoRoot() else { - throw NSError(domain: "programa.remote.daemon", code: 20, userInfo: [ - NSLocalizedDescriptionKey: "cannot locate cmux repo root for dev-only programad-remote build fallback", - ]) - } - let daemonRoot = repoRoot.appendingPathComponent("daemon/remote", isDirectory: true) - let goModPath = daemonRoot.appendingPathComponent("go.mod").path - guard FileManager.default.fileExists(atPath: goModPath) else { - throw NSError(domain: "programa.remote.daemon", code: 21, userInfo: [ - NSLocalizedDescriptionKey: "missing daemon module at \(goModPath)", - ]) - } - guard let goBinary = Self.which("go") else { - throw NSError(domain: "programa.remote.daemon", code: 22, userInfo: [ - NSLocalizedDescriptionKey: "go is required for the dev-only programad-remote build fallback", - ]) - } - - let output = Self.versionedRemoteDaemonBuildURL(goOS: goOS, goArch: goArch, version: version) - try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) - - var env = ProcessInfo.processInfo.environment - env["GOOS"] = goOS - env["GOARCH"] = goArch - env["CGO_ENABLED"] = "0" - let ldflags = "-s -w -X main.version=\(version)" - let result = try runProcess( - executable: goBinary, - arguments: ["build", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, "-o", output.path, "./cmd/programad-remote"], - environment: env, - currentDirectory: daemonRoot, - stdin: nil, - timeout: 90 - ) - guard result.status == 0 else { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "go build failed with status \(result.status)" - throw NSError(domain: "programa.remote.daemon", code: 23, userInfo: [ - NSLocalizedDescriptionKey: "failed to build programad-remote: \(detail)", - ]) - } - guard FileManager.default.isExecutableFile(atPath: output.path) else { - throw NSError(domain: "programa.remote.daemon", code: 24, userInfo: [ - NSLocalizedDescriptionKey: "programad-remote build output is not executable", - ]) - } - debugLog("remote.build.output path=\(output.path)") - return output - } - - private func uploadRemoteDaemonBinaryLocked(localBinary: URL, remotePath: String) throws { - let remoteDirectory = (remotePath as NSString).deletingLastPathComponent - let remoteTempPath = "\(remotePath).tmp-\(UUID().uuidString.prefix(8))" - debugLog( - "remote.upload.begin local=\(localBinary.path) remoteTemp=\(remoteTempPath) remote=\(remotePath)" - ) - - let mkdirScript = "mkdir -p \(Self.shellSingleQuoted(remoteDirectory))" - let mkdirCommand = "sh -c \(Self.shellSingleQuoted(mkdirScript))" - let mkdirResult = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, mkdirCommand], timeout: 12) - guard mkdirResult.status == 0 else { - let detail = Self.bestErrorLine(stderr: mkdirResult.stderr, stdout: mkdirResult.stdout) ?? "ssh exited \(mkdirResult.status)" - throw NSError(domain: "programa.remote.daemon", code: 30, userInfo: [ - NSLocalizedDescriptionKey: "failed to create remote daemon directory: \(detail)", - ]) - } - - let scpSSHOptions = backgroundSSHOptions(configuration.sshOptions) - var scpArgs: [String] = ["-q"] - if !hasSSHOptionKey(scpSSHOptions, key: "StrictHostKeyChecking") { - scpArgs += ["-o", "StrictHostKeyChecking=accept-new"] - } - scpArgs += ["-o", "ControlMaster=no"] - if let port = configuration.port { - scpArgs += ["-P", String(port)] - } - if let identityFile = configuration.identityFile, - !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - scpArgs += ["-i", identityFile] - } - for option in scpSSHOptions { - scpArgs += ["-o", option] - } - scpArgs += [localBinary.path, "\(configuration.destination):\(remoteTempPath)"] - let scpResult = try scpExec(arguments: scpArgs, timeout: 45) - guard scpResult.status == 0 else { - let detail = Self.bestErrorLine(stderr: scpResult.stderr, stdout: scpResult.stdout) ?? "scp exited \(scpResult.status)" - throw NSError(domain: "programa.remote.daemon", code: 31, userInfo: [ - NSLocalizedDescriptionKey: "failed to upload programad-remote: \(detail)", - ]) - } - - let finalizeScript = """ - chmod 755 \(Self.shellSingleQuoted(remoteTempPath)) && \ - mv \(Self.shellSingleQuoted(remoteTempPath)) \(Self.shellSingleQuoted(remotePath)) - """ - let finalizeCommand = "sh -c \(Self.shellSingleQuoted(finalizeScript))" - let finalizeResult = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, finalizeCommand], timeout: 12) - guard finalizeResult.status == 0 else { - let detail = Self.bestErrorLine(stderr: finalizeResult.stderr, stdout: finalizeResult.stdout) ?? "ssh exited \(finalizeResult.status)" - throw NSError(domain: "programa.remote.daemon", code: 32, userInfo: [ - NSLocalizedDescriptionKey: "failed to install remote daemon binary: \(detail)", - ]) - } - } - - private func uploadDroppedFilesLocked( - _ fileURLs: [URL], - operation: TerminalImageTransferOperation - ) throws -> [String] { - guard !fileURLs.isEmpty else { return [] } - - let scpSSHOptions = backgroundSSHOptions(configuration.sshOptions) - var uploadedRemotePaths: [String] = [] - do { - for localURL in fileURLs { - try operation.throwIfCancelled() - let normalizedLocalURL = localURL.standardizedFileURL - guard normalizedLocalURL.isFileURL else { - throw RemoteDropUploadError.invalidFileURL - } - - let remotePath = Self.remoteDropPath(for: normalizedLocalURL) - uploadedRemotePaths.append(remotePath) - var scpArgs: [String] = ["-q", "-o", "ControlMaster=no"] - if !hasSSHOptionKey(scpSSHOptions, key: "StrictHostKeyChecking") { - scpArgs += ["-o", "StrictHostKeyChecking=accept-new"] - } - if let port = configuration.port { - scpArgs += ["-P", String(port)] - } - if let identityFile = configuration.identityFile, - !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - scpArgs += ["-i", identityFile] - } - for option in scpSSHOptions { - scpArgs += ["-o", option] - } - scpArgs += [normalizedLocalURL.path, "\(configuration.destination):\(remotePath)"] - - let scpResult = try scpExec(arguments: scpArgs, timeout: 45, operation: operation) - guard scpResult.status == 0 else { - let detail = Self.bestErrorLine(stderr: scpResult.stderr, stdout: scpResult.stdout) ?? - "scp exited \(scpResult.status)" - throw RemoteDropUploadError.uploadFailed(detail) - } - } - return uploadedRemotePaths - } catch { - cleanupUploadedRemotePaths(uploadedRemotePaths) - throw error - } - } - - static func remoteDropPath(for fileURL: URL, uuid: UUID = UUID()) -> String { - let extensionSuffix = fileURL.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines) - let lowercasedSuffix = extensionSuffix.isEmpty ? "" : ".\(extensionSuffix.lowercased())" - return "/tmp/programa-drop-\(uuid.uuidString.lowercased())\(lowercasedSuffix)" - } - - private func cleanupUploadedRemotePaths(_ remotePaths: [String]) { - guard !remotePaths.isEmpty else { return } - let cleanupScript = "rm -f -- " + remotePaths.map(Self.shellSingleQuoted).joined(separator: " ") - let cleanupCommand = "sh -c \(Self.shellSingleQuoted(cleanupScript))" - _ = try? sshExec( - arguments: sshCommonArguments(batchMode: true) + [configuration.destination, cleanupCommand], - timeout: 8 - ) - } - - private func helloRemoteDaemonLocked(remotePath: String) throws -> DaemonHello { - let request = #"{"id":1,"method":"hello","params":{}}"# - let script = "printf '%s\\n' \(Self.shellSingleQuoted(request)) | \(Self.shellSingleQuoted(remotePath)) serve --stdio" - let command = "sh -c \(Self.shellSingleQuoted(script))" - let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 12) - guard result.status == 0 else { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" - throw NSError(domain: "programa.remote.daemon", code: 40, userInfo: [ - NSLocalizedDescriptionKey: "failed to start remote daemon: \(detail)", - ]) - } - - let responseLine = result.stdout - .split(separator: "\n") - .map(String.init) - .first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) ?? "" - guard !responseLine.isEmpty, - let data = responseLine.data(using: .utf8), - let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { - throw NSError(domain: "programa.remote.daemon", code: 41, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon hello returned invalid JSON", - ]) - } - - if let ok = payload["ok"] as? Bool, !ok { - let errorMessage: String = { - if let errorObject = payload["error"] as? [String: Any], - let message = errorObject["message"] as? String, - !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return message - } - return "hello call failed" - }() - throw NSError(domain: "programa.remote.daemon", code: 42, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon hello failed: \(errorMessage)", - ]) - } - - let resultObject = payload["result"] as? [String: Any] ?? [:] - let name = (resultObject["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - let version = (resultObject["version"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - let capabilities = (resultObject["capabilities"] as? [String]) ?? [] - return DaemonHello( - name: (name?.isEmpty == false ? name! : "programad-remote"), - version: (version?.isEmpty == false ? version! : "dev"), - capabilities: capabilities, - remotePath: remotePath - ) - } - - private func debugLog(_ message: @autoclosure () -> String) { -#if DEBUG - dlog(message()) -#endif - } - - private func debugConfigSummary() -> String { - let controlPath = Self.debugSSHOptionValue(named: "ControlPath", in: configuration.sshOptions) ?? "nil" - return - "target=\(configuration.displayTarget) port=\(configuration.port.map(String.init) ?? "nil") " + - "relayPort=\(configuration.relayPort.map(String.init) ?? "nil") " + - "localSocket=\(configuration.localSocketPath ?? "nil") " + - "controlPath=\(controlPath)" - } - - private func debugShellCommand(executable: String, arguments: [String]) -> String { - ([URL(fileURLWithPath: executable).lastPathComponent] + arguments) - .map(Self.shellSingleQuoted) - .joined(separator: " ") - } - - private static func debugSSHOptionValue(named key: String, in options: [String]) -> String? { - let loweredKey = key.lowercased() - for option in options { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - let parts = trimmed.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) - if parts.count == 2, - parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == loweredKey { - return parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - } - } - return nil - } - - private static func debugLogSnippet(_ text: String, limit: Int = 160) -> String { - let normalized = text - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - .trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalized.isEmpty else { return "\"\"" } - if normalized.count <= limit { - return normalized - } - return String(normalized.prefix(limit)) + "..." - } - - private static func shellSingleQuoted(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" - } - - static func remoteCLIWrapperScript() -> String { - """ - #!/bin/sh - set -eu - - daemon="$HOME/.programa/bin/programad-remote-current" - socket_path="${PROGRAMA_SOCKET_PATH:-}" - if [ -z "$socket_path" ] && [ -r "$HOME/.programa/socket_addr" ]; then - socket_path="$(tr -d '\\r\\n' < "$HOME/.programa/socket_addr")" - fi - - if [ -n "$socket_path" ] && [ "${socket_path#/}" = "$socket_path" ] && [ "${socket_path#*:}" != "$socket_path" ]; then - relay_port="${socket_path##*:}" - relay_map="$HOME/.programa/relay/${relay_port}.daemon_path" - if [ -r "$relay_map" ]; then - mapped_daemon="$(tr -d '\\r\\n' < "$relay_map")" - if [ -n "$mapped_daemon" ] && [ -x "$mapped_daemon" ]; then - daemon="$mapped_daemon" - fi - fi - fi - - exec "$daemon" "$@" - """ - } - - static func remoteCLIWrapperInstallScript(daemonRemotePath: String) -> String { - let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines) - return """ - mkdir -p "$HOME/.programa/bin" "$HOME/.programa/relay" - ln -sf "$HOME/\(trimmedRemotePath)" "$HOME/.programa/bin/programad-remote-current" - wrapper_tmp="$HOME/.programa/bin/.programa-wrapper.tmp.$$" - cat > "$wrapper_tmp" <<'CMUXWRAPPER' - \(remoteCLIWrapperScript()) - CMUXWRAPPER - chmod 755 "$wrapper_tmp" - mv -f "$wrapper_tmp" "$HOME/.programa/bin/programa" - """ - } - - static func remoteRelayMetadataInstallScript( - daemonRemotePath: String, - relayPort: Int, - relayID: String, - relayToken: String - ) -> String { - let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines) - let authPayload = """ - {"relay_id":"\(relayID)","relay_token":"\(relayToken)"} - """ - return """ - umask 077 - mkdir -p "$HOME/.programa" "$HOME/.programa/relay" - chmod 700 "$HOME/.programa/relay" - \(remoteCLIWrapperInstallScript(daemonRemotePath: trimmedRemotePath)) - printf '%s' "$HOME/\(trimmedRemotePath)" > "$HOME/.programa/relay/\(relayPort).daemon_path" - cat > "$HOME/.programa/relay/\(relayPort).auth" <<'PROGRAMARELAYAUTH' - \(authPayload) - PROGRAMARELAYAUTH - chmod 600 "$HOME/.programa/relay/\(relayPort).auth" - printf '%s' '127.0.0.1:\(relayPort)' > "$HOME/.programa/socket_addr" - """ - } - - private static func mapUnameOS(_ raw: String) -> String? { - switch raw.lowercased() { - case "linux": - return "linux" - case "darwin": - return "darwin" - case "freebsd": - return "freebsd" - default: - return nil - } - } - - private static func mapUnameArch(_ raw: String) -> String? { - switch raw.lowercased() { - case "x86_64", "amd64": - return "amd64" - case "aarch64", "arm64": - return "arm64" - case "armv7l": - return "arm" - default: - return nil - } - } - - private static func remoteDaemonVersion() -> String { - let bundleVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) - let baseVersion = (bundleVersion?.isEmpty == false) ? bundleVersion! : "dev" - guard allowLocalDaemonBuildFallback(), - let sourceFingerprint = remoteDaemonSourceFingerprint(), - !sourceFingerprint.isEmpty else { - return baseVersion - } - return "\(baseVersion)-dev-\(sourceFingerprint)" - } - - private static let cachedRemoteDaemonSourceFingerprint: String? = computeRemoteDaemonSourceFingerprint() - - private static func remoteDaemonSourceFingerprint() -> String? { - cachedRemoteDaemonSourceFingerprint - } - - private static func computeRemoteDaemonSourceFingerprint(fileManager: FileManager = .default) -> String? { - guard let repoRoot = findRepoRoot() else { return nil } - let daemonRoot = repoRoot.appendingPathComponent("daemon/remote", isDirectory: true) - guard let enumerator = fileManager.enumerator( - at: daemonRoot, - includingPropertiesForKeys: [.isRegularFileKey], - options: [.skipsHiddenFiles] - ) else { - return nil - } - - var relativePaths: [String] = [] - for case let fileURL as URL in enumerator { - guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey]), - resourceValues.isRegularFile == true else { - continue - } - - let relativePath = fileURL.path.replacingOccurrences(of: daemonRoot.path + "/", with: "") - if relativePath == "go.mod" || relativePath == "go.sum" || relativePath.hasSuffix(".go") { - relativePaths.append(relativePath) - } - } - - guard !relativePaths.isEmpty else { return nil } - - let digest = SHA256.hash(data: relativePaths.sorted().reduce(into: Data()) { partialResult, relativePath in - let fileURL = daemonRoot.appendingPathComponent(relativePath, isDirectory: false) - guard let fileData = try? Data(contentsOf: fileURL) else { return } - partialResult.append(Data(relativePath.utf8)) - partialResult.append(0) - partialResult.append(fileData) - partialResult.append(0) - }) - let hex = digest.map { String(format: "%02x", $0) }.joined() - return String(hex.prefix(12)) - } - - private static func remoteDaemonPath(version: String, goOS: String, goArch: String) -> String { - ".programa/bin/programad-remote/\(version)/\(goOS)-\(goArch)/programad-remote" - } - - static func orphanedCMUXRemoteSSHPIDs( - psOutput: String, - destination: String, - relayPort: Int? = nil - ) -> [Int] { - let trimmedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedDestination.isEmpty else { return [] } - - return psOutput - .split(separator: "\n", omittingEmptySubsequences: false) - .compactMap { line -> Int? in - guard let parsed = parsePSLine(line) else { return nil } - guard parsed.ppid == 1 else { return nil } - guard isOrphanedCMUXRemoteSSHCommand( - parsed.command, - destination: trimmedDestination, - relayPort: relayPort - ) else { - return nil - } - return parsed.pid - } - .sorted() - } - - private static func killOrphanedRemoteSSHProcesses(destination: String, relayPort: Int? = nil) { - guard let output = captureCommandStandardOutput( - executablePath: "/bin/ps", - arguments: ["-axo", "pid=,ppid=,command="] - ) else { - return - } - - for pid in orphanedCMUXRemoteSSHPIDs( - psOutput: output, - destination: destination, - relayPort: relayPort - ) { - _ = Darwin.kill(pid_t(pid), SIGTERM) - } - } - - private static func captureCommandStandardOutput( - executablePath: String, - arguments: [String] - ) -> String? { - let process = Process() - let stdoutPipe = Pipe() - process.executableURL = URL(fileURLWithPath: executablePath) - process.arguments = arguments - process.standardOutput = stdoutPipe - process.standardError = FileHandle.nullDevice - - do { - try process.run() - let outputData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - guard process.terminationStatus == 0, - let output = String(data: outputData, encoding: .utf8), - !output.isEmpty else { - return nil - } - return output - } catch { - // Best effort cleanup only. - return nil - } - } - - private static func parsePSLine(_ line: Substring) -> (pid: Int, ppid: Int, command: String)? { - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - let scanner = Scanner(string: trimmed) - var pidValue: Int = 0 - var ppidValue: Int = 0 - guard scanner.scanInt(&pidValue), scanner.scanInt(&ppidValue) else { - return nil - } - - let commandStart = scanner.currentIndex - let command = String(trimmed[commandStart...]).trimmingCharacters(in: .whitespacesAndNewlines) - guard !command.isEmpty else { return nil } - return (pidValue, ppidValue, command) - } - - private static func isOrphanedCMUXRemoteSSHCommand( - _ command: String, - destination: String, - relayPort: Int? - ) -> Bool { - let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return false } - guard trimmed.hasPrefix("/usr/bin/ssh ") || trimmed.hasPrefix("ssh ") else { return false } - guard commandContainsDestination(trimmed, destination: destination) else { return false } - - if let relayPort { - return trimmed.contains(" -N ") - && trimmed.contains(" -R 127.0.0.1:\(relayPort):127.0.0.1:") - } - - if trimmed.contains(" -N ") && trimmed.contains(" -R 127.0.0.1:") { - return true - } - if trimmed.contains("programad-remote") && trimmed.contains(" serve --stdio") { - return true - } - return false - } - - private static func commandContainsDestination(_ command: String, destination: String) -> Bool { - guard !destination.isEmpty else { return false } - let escaped = NSRegularExpression.escapedPattern(for: destination) - guard let regex = try? NSRegularExpression( - pattern: "(^|[\\s'\\\"])\(escaped)($|[\\s'\\\"])", - options: [] - ) else { - return command.contains(destination) - } - let range = NSRange(command.startIndex.. [String] { - var ordered: [String] = [] - var seen: Set = [] - - func appendSearchPath(_ rawPath: String?) { - guard let rawPath else { return } - let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - guard seen.insert(trimmed).inserted else { return } - ordered.append(trimmed) - } - - if let path = environment["PATH"] { - for component in path.split(separator: ":") { - appendSearchPath(String(component)) - } - } - - if let home = environment["HOME"], !home.isEmpty { - appendSearchPath((home as NSString).appendingPathComponent(".local/bin")) - appendSearchPath((home as NSString).appendingPathComponent("go/bin")) - appendSearchPath((home as NSString).appendingPathComponent("bin")) - } - - let helperOutput = pathHelperOutput ?? pathHelperShellOutput() - for component in parsePathHelperPaths(helperOutput) { - appendSearchPath(component) - } - - for component in [ - "/opt/homebrew/bin", - "/opt/homebrew/sbin", - "/usr/local/bin", - "/usr/local/sbin", - "/usr/bin", - "/bin", - "/usr/sbin", - "/sbin", - ] { - appendSearchPath(component) - } - - return ordered - } - - static func parsePathHelperPaths(_ output: String) -> [String] { - for fragment in output.split(whereSeparator: { $0 == "\n" || $0 == ";" }) { - let trimmed = fragment.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.hasPrefix("PATH=\"") else { continue } - let suffix = trimmed.dropFirst("PATH=\"".count) - guard let closingQuote = suffix.firstIndex(of: "\"") else { return [] } - return suffix[.. String { - let executable = "/usr/libexec/path_helper" - guard FileManager.default.isExecutableFile(atPath: executable) else { return "" } - - let process = Process() - process.executableURL = URL(fileURLWithPath: executable) - process.arguments = ["-s"] - - let stdout = Pipe() - let stderr = Pipe() - process.standardOutput = stdout - process.standardError = stderr - - do { - try process.run() - } catch { - return "" - } - - process.waitUntilExit() - guard process.terminationStatus == 0 else { return "" } - let data = stdout.fileHandleForReading.readDataToEndOfFile() - return String(data: data, encoding: .utf8) ?? "" - } - - private static func which(_ executable: String) -> String? { - for component in executableSearchPaths() { - let candidate = (component as NSString).appendingPathComponent(executable) - if FileManager.default.isExecutableFile(atPath: candidate) { - return candidate - } - } - return nil - } - - private static func findRepoRoot() -> URL? { - var candidates: [URL] = [] - let compileTimeRoot = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() // Sources - .deletingLastPathComponent() // repo root - candidates.append(compileTimeRoot) - let environment = ProcessInfo.processInfo.environment - if let envRoot = environment["PROGRAMA_REMOTE_DAEMON_SOURCE_ROOT"], - !envRoot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - candidates.append(URL(fileURLWithPath: envRoot, isDirectory: true)) - } - if let envRoot = environment["PROGRAMATERM_REPO_ROOT"], - !envRoot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - candidates.append(URL(fileURLWithPath: envRoot, isDirectory: true)) - } - candidates.append(URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)) - if let executable = Bundle.main.executableURL?.deletingLastPathComponent() { - candidates.append(executable) - candidates.append(executable.deletingLastPathComponent()) - candidates.append(executable.deletingLastPathComponent().deletingLastPathComponent()) - } - - let fm = FileManager.default - for base in candidates { - var cursor = base.standardizedFileURL - for _ in 0..<10 { - let marker = cursor.appendingPathComponent("daemon/remote/go.mod").path - if fm.fileExists(atPath: marker) { - return cursor - } - let parent = cursor.deletingLastPathComponent() - if parent.path == cursor.path { - break - } - cursor = parent - } - } - return nil - } - - private static func bestErrorLine(stderr: String, stdout: String = "") -> String? { - if let stderrLine = meaningfulErrorLine(in: stderr) { - return stderrLine - } - if let stdoutLine = meaningfulErrorLine(in: stdout) { - return stdoutLine - } - return nil - } - - static func reverseRelayStartupFailureDetail( - process: Process, - stderrPipe: Pipe, - gracePeriod: TimeInterval = reverseRelayStartupGracePeriod - ) -> String? { - if process.isRunning { - let originalTerminationHandler = process.terminationHandler - let exitSemaphore = DispatchSemaphore(value: 0) - process.terminationHandler = { terminated in - originalTerminationHandler?(terminated) - exitSemaphore.signal() - } - if !process.isRunning { - exitSemaphore.signal() - } - guard exitSemaphore.wait(timeout: .now() + max(0, gracePeriod)) == .success else { - return nil - } - } - let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - let stderr = String(data: stderrData, encoding: .utf8) ?? "" - return bestErrorLine(stderr: stderr) ?? "status=\(process.terminationStatus)" - } - - private static func meaningfulErrorLine(in text: String) -> String? { - let lines = text - .split(separator: "\n") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - - for line in lines.reversed() where !isNoiseLine(line) { - return line - } - return lines.last - } - - private static func isNoiseLine(_ line: String) -> Bool { - let lowered = line.lowercased() - if lowered.hasPrefix("warning: permanently added") { return true } - if lowered.hasPrefix("debug") { return true } - if lowered.hasPrefix("transferred:") { return true } - if lowered.hasPrefix("openbsd_") { return true } - if lowered.contains("pseudo-terminal will not be allocated") { return true } - return false - } - - private static func retrySuffix(retry: Int, delay: TimeInterval) -> String { - let seconds = max(1, Int(delay.rounded())) - return " (retry \(retry) in \(seconds)s)" - } - - private static func retryDelay(baseDelay: TimeInterval, retry: Int) -> TimeInterval { - let exponent = Double(max(0, retry - 1)) - return min(baseDelay * pow(2.0, exponent), 60.0) - } - - private static func shouldEscalateProxyErrorToBootstrap(_ detail: String) -> Bool { - let lowered = detail.lowercased() - return lowered.contains("remote daemon transport failed") - || lowered.contains("daemon transport closed stdout") - || lowered.contains("daemon transport exited") - || lowered.contains("daemon transport is not connected") - || lowered.contains("daemon transport stopped") - } - - func updateRemotePortScanTTYs(_ ttyNames: [UUID: String]) { - queue.async { [weak self] in - self?.updateRemotePortScanTTYsLocked(ttyNames) - } - } - - func kickRemotePortScan(panelId: UUID, reason: PortScanKickReason = .command) { - queue.async { [weak self] in - self?.kickRemotePortScanLocked(panelId: panelId, reason: reason) - } - } - - private func updateRemotePortScanTTYsLocked(_ ttyNames: [UUID: String]) { - let previousTTYNames = remotePortScanTTYNames - let nextTTYNames = ttyNames.reduce(into: [UUID: String]()) { result, entry in - guard let ttyName = Self.normalizedRemotePortScanTTYName(entry.value) else { return } - result[entry.key] = ttyName - } - guard previousTTYNames != nextTTYNames else { return } - if !nextTTYNames.isEmpty { - bootstrapRemoteTTYResolved = true - bootstrapRemoteTTYRetryWorkItem?.cancel() - bootstrapRemoteTTYRetryWorkItem = nil - bootstrapRemoteTTYRetryCount = 0 - } - keepPolledRemotePortsUntilTTYScan = - !previousTTYNames.isEmpty - ? keepPolledRemotePortsUntilTTYScan - : shouldUseFallbackRemotePortPollingLocked() && !polledRemotePorts.isEmpty && !nextTTYNames.isEmpty - remoteScannedPortsByPanel = remoteScannedPortsByPanel.filter { panelId, _ in - guard let oldTTY = previousTTYNames[panelId], - let newTTY = nextTTYNames[panelId] else { - return false - } - return oldTTY == newTTY - } - remotePortScanTTYNames = nextTTYNames - if nextTTYNames.isEmpty { - keepPolledRemotePortsUntilTTYScan = false - } - updateRemotePortPollingStateLocked() - publishPortsSnapshotLocked() - } - - private func kickRemotePortScanLocked(panelId: UUID, reason: PortScanKickReason) { - guard !isStopping else { return } - guard daemonReady else { return } - guard remotePortScanTTYNames[panelId] != nil else { return } - if remotePortScanBurstActive, remotePortScanActiveReason == .command, reason == .refresh { - return - } - remotePortScanPendingReason = remotePortScanPendingReason?.merged(with: reason) ?? reason - scheduleRemotePortScanCoalesceLocked() - } - - private func scheduleRemotePortScanCoalesceLocked() { - guard !remotePortScanBurstActive else { return } - guard remotePortScanCoalesceWorkItem == nil else { return } - - let generation = remotePortScanGeneration - let workItem = DispatchWorkItem { [weak self] in - guard let self else { return } - guard self.remotePortScanGeneration == generation else { return } - self.remotePortScanCoalesceWorkItem = nil - guard let reason = self.remotePortScanPendingReason else { return } - self.remotePortScanPendingReason = nil - self.remotePortScanBurstActive = true - self.remotePortScanActiveReason = reason - self.runRemotePortScanBurstLocked(index: 0, generation: generation, reason: reason) - } - remotePortScanCoalesceWorkItem = workItem - queue.asyncAfter(deadline: .now() + 0.2, execute: workItem) - } - - private func runRemotePortScanBurstLocked( - index: Int, - generation: UInt64, - reason: PortScanKickReason, - burstStart: DispatchTime? = nil - ) { - guard remotePortScanGeneration == generation else { return } - - let burstOffsets = reason.burstOffsets - guard index < burstOffsets.count else { - remotePortScanBurstActive = false - remotePortScanActiveReason = nil - if remotePortScanPendingReason != nil && remotePortScanCoalesceWorkItem == nil { - scheduleRemotePortScanCoalesceLocked() - } - return - } - - let start = burstStart ?? .now() - let deadline = start + burstOffsets[index] - queue.asyncAfter(deadline: deadline) { [weak self] in - guard let self else { return } - guard self.remotePortScanGeneration == generation else { return } - self.performRemotePortScanLocked() - self.runRemotePortScanBurstLocked( - index: index + 1, - generation: generation, - reason: reason, - burstStart: start - ) - } - } - - private func performRemotePortScanLocked() { - let ttyNamesByPanel = remotePortScanTTYNames - guard !ttyNamesByPanel.isEmpty else { - remoteScannedPortsByPanel.removeAll() - keepPolledRemotePortsUntilTTYScan = false - publishPortsSnapshotLocked() - return - } - - do { - remoteScannedPortsByPanel = try scanRemotePortsByPanelLocked(ttyNamesByPanel: ttyNamesByPanel) - keepPolledRemotePortsUntilTTYScan = false - polledRemotePorts = [] - publishPortsSnapshotLocked() - } catch { - debugLog("remote.ports.scan.failed error=\(error.localizedDescription) \(debugConfigSummary())") - } - } - - private func scanRemotePortsByPanelLocked(ttyNamesByPanel: [UUID: String]) throws -> [UUID: [Int]] { - let ttyNames = Array(Set(ttyNamesByPanel.values)).sorted() - guard !ttyNames.isEmpty else { return [:] } - - let command = "sh -c \(Self.shellSingleQuoted(Self.remotePortScanScript(ttyNames: ttyNames, excluding: excludedRemoteScanPorts())))" - let result = try sshExec( - arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], - timeout: 8 - ) - guard result.status == 0 else { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" - throw NSError(domain: "programa.remote.ports", code: 90, userInfo: [ - NSLocalizedDescriptionKey: "remote port scan failed: \(detail)", - ]) - } - - let portsByTTY = Self.parseRemoteTTYPortPairs( - output: result.stdout, - trackedTTYNames: Set(ttyNames) - ) - - return ttyNamesByPanel.reduce(into: [UUID: [Int]]()) { result, entry in - result[entry.key] = portsByTTY[entry.value] ?? [] - } - } - - private func startRemotePortPollingLocked(mode: RemotePortPollingMode) { - if remotePortPollTimer != nil, remotePortPollMode == mode { - return - } - stopRemotePortPollingLocked() - - let timer = DispatchSource.makeTimerSource(queue: queue) - timer.schedule(deadline: .now() + mode.initialDelay, repeating: mode.repeatInterval) - timer.setEventHandler { [weak self] in - self?.pollRemotePortsLocked() - } - remotePortPollTimer = timer - remotePortPollMode = mode - timer.resume() - pollRemotePortsLocked() - } - - private func stopRemotePortPollingLocked() { - remotePortPollTimer?.setEventHandler {} - remotePortPollTimer?.cancel() - remotePortPollTimer = nil - remotePortPollMode = nil - } - - private func updateRemotePortPollingStateLocked() { - guard daemonReady, !isStopping, let pollingMode = remotePortPollingModeLocked() else { - stopRemotePortPollingLocked() - if !keepPolledRemotePortsUntilTTYScan { - polledRemotePorts = [] - } - remotePortPollBaselinePorts = nil - return - } - startRemotePortPollingLocked(mode: pollingMode) - } - - private func pollRemotePortsLocked() { - guard !isStopping else { return } - guard daemonReady else { return } - if !remotePortScanTTYNames.isEmpty { - guard shouldUseTTYFallbackRemotePortPollingLocked() else { - stopRemotePortPollingLocked() - if !keepPolledRemotePortsUntilTTYScan { - polledRemotePorts = [] - } - publishPortsSnapshotLocked() - return - } - if remotePortScanBurstActive || remotePortScanCoalesceWorkItem != nil || remotePortScanPendingReason != nil { - return - } - performRemotePortScanLocked() - return - } - guard let pollingMode = remotePortPollingModeLocked() else { - stopRemotePortPollingLocked() - polledRemotePorts = [] - remotePortPollBaselinePorts = nil - keepPolledRemotePortsUntilTTYScan = false - publishPortsSnapshotLocked() - return - } - guard remotePortScanTTYNames.isEmpty else { - stopRemotePortPollingLocked() - if !keepPolledRemotePortsUntilTTYScan { - polledRemotePorts = [] - } - remotePortPollBaselinePorts = nil - publishPortsSnapshotLocked() - return - } - - let command = "sh -c \(Self.shellSingleQuoted(Self.remoteAllPortsScanScript(excluding: excludedRemoteScanPorts())))" - do { - let result = try sshExec( - arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], - timeout: 8 - ) - guard result.status == 0 else { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" - throw NSError(domain: "programa.remote.ports", code: 90, userInfo: [ - NSLocalizedDescriptionKey: "remote port scan failed: \(detail)", - ]) - } - let currentPorts = Set(Self.parseRemotePorts(output: result.stdout)) - switch pollingMode { - case .hostWide: - polledRemotePorts = currentPorts.sorted() - remotePortPollBaselinePorts = nil - case .hostWideDelta: - if let baselinePorts = remotePortPollBaselinePorts { - polledRemotePorts = currentPorts.subtracting(baselinePorts).sorted() - } else { - remotePortPollBaselinePorts = currentPorts - polledRemotePorts = [] - } - case .ttyScoped: - polledRemotePorts = [] - remotePortPollBaselinePorts = nil - } - keepPolledRemotePortsUntilTTYScan = false - publishPortsSnapshotLocked() - } catch { - debugLog("remote.ports.poll.failed error=\(error.localizedDescription) \(debugConfigSummary())") - } - } - - private func excludedRemoteScanPorts() -> Set { - var excluded: Set = [] - if let relayPort = configuration.relayPort, relayPort > 0 { - excluded.insert(relayPort) - } - if let configuredPort = configuration.port, configuredPort > 0 { - excluded.insert(configuredPort) - } - return excluded - } - - private func shouldUseFallbackRemotePortPollingLocked() -> Bool { - // `cmux ssh` owns the remote shell bootstrap and can report the remote - // TTY precisely. Falling back to host-wide port scans in that path leaks - // unrelated listeners from the remote machine into the workspace card. - let startupCommand = configuration.terminalStartupCommand? - .trimmingCharacters(in: .whitespacesAndNewlines) - return startupCommand?.isEmpty != false - } - - private func shouldUseTTYFallbackRemotePortPollingLocked() -> Bool { - // `cmux ssh` can still land in shells without our command hooks, such as - // `/bin/sh` in the Docker fixture. Once the workspace knows the TTY, - // keep a low-frequency TTY-scoped poll so unsupported shells still - // surface ports without bringing back noisy host-wide scans. - let startupCommand = configuration.terminalStartupCommand? - .trimmingCharacters(in: .whitespacesAndNewlines) - return startupCommand?.isEmpty == false - } - - private func remotePortPollingModeLocked() -> RemotePortPollingMode? { - if !remotePortScanTTYNames.isEmpty { - return shouldUseTTYFallbackRemotePortPollingLocked() ? .ttyScoped : nil - } - let startupCommand = configuration.terminalStartupCommand? - .trimmingCharacters(in: .whitespacesAndNewlines) - if startupCommand?.isEmpty == false { - return .hostWideDelta - } - return shouldUseFallbackRemotePortPollingLocked() ? .hostWide : nil - } - - private static func parseRemoteTTYPortPairs(output: String, trackedTTYNames: Set) -> [String: [Int]] { - var portsByTTY = Dictionary(uniqueKeysWithValues: trackedTTYNames.map { ($0, Set()) }) - - for line in output.split(separator: "\n") { - let parts = line.split(separator: "\t", omittingEmptySubsequences: false) - guard parts.count == 2 else { continue } - let ttyName = String(parts[0]).trimmingCharacters(in: .whitespacesAndNewlines) - guard trackedTTYNames.contains(ttyName), - let port = Int(parts[1]), - port >= 1024, - port <= 65535 else { - continue - } - portsByTTY[ttyName, default: []].insert(port) - } - - return portsByTTY.reduce(into: [String: [Int]]()) { result, entry in - result[entry.key] = entry.value.sorted() - } - } - - private static func parseRemotePorts(output: String) -> [Int] { - let values = output - .split(whereSeparator: \.isWhitespace) - .compactMap { Int($0) } - .filter { $0 >= 1024 && $0 <= 65535 } - return Array(Set(values)).sorted() - } - - private static func normalizedRemotePortScanTTYName(_ raw: String) -> String? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let candidate = trimmed.split(separator: "/").last.map(String.init) ?? trimmed - guard !candidate.isEmpty else { return nil } - let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) - guard candidate.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { return nil } - return candidate - } - - private static func remotePortScanScript(ttyNames: [String], excluding ports: Set) -> String { - let ttySet = ttyNames.joined(separator: " ") - let ttyCSV = ttyNames.joined(separator: ",") - let excludedPorts = ports.sorted().map(String.init).joined(separator: " ") - - return """ - set -eu - programa_tracked_ttys=" \(ttySet) " - programa_tty_csv='\(ttyCSV)' - programa_excluded_ports=" \(excludedPorts) " - - programa_emit_port() { - programa_tty="$1" - programa_port="$2" - case "$programa_tracked_ttys" in - *" $programa_tty "*) ;; - *) return 0 ;; - esac - case "$programa_excluded_ports" in - *" $programa_port "*) return 0 ;; - esac - [ "$programa_port" -ge 1024 ] && [ "$programa_port" -le 65535 ] || return 0 - printf '%s\\t%s\\n' "$programa_tty" "$programa_port" - } - - programa_used_ss=0 - if [ -d /proc ] && command -v ss >/dev/null 2>&1; then - programa_ss_output="$(ss -ltnpH 2>/dev/null || true)" - case "$programa_ss_output" in - *pid=*) - programa_used_ss=1 - printf '%s\\n' "$programa_ss_output" | while IFS= read -r programa_line; do - [ -n "$programa_line" ] || continue - programa_port="$(printf '%s\\n' "$programa_line" | awk '{print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ { print $1; exit }')" - [ -n "$programa_port" ] || continue - printf '%s\\n' "$programa_line" | awk ' - { - line = $0 - while (match(line, /pid=[0-9]+/)) { - print substr(line, RSTART + 4, RLENGTH - 4) - line = substr(line, RSTART + RLENGTH) - } - } - ' | while IFS= read -r programa_pid; do - [ -n "$programa_pid" ] || continue - programa_tty_path="$(readlink "/proc/$programa_pid/fd/0" 2>/dev/null || true)" - [ -n "$programa_tty_path" ] || continue - programa_tty="${programa_tty_path##*/}" - [ -n "$programa_tty" ] || continue - programa_emit_port "$programa_tty" "$programa_port" - done - done - ;; - esac - fi - - if [ "$programa_used_ss" -eq 0 ] && command -v lsof >/dev/null 2>&1 && [ -n "$programa_tty_csv" ]; then - programa_tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t programa-ports)" - trap 'rm -rf "$programa_tmpdir"' EXIT INT TERM - programa_pid_tty_map="$programa_tmpdir/pid_tty" - ps -t "$programa_tty_csv" -o pid=,tty= 2>/dev/null | awk ' - NF >= 2 { - tty = $2 - sub(/^.*\\//, "", tty) - print $1 "\\t" tty - } - ' > "$programa_pid_tty_map" - [ -s "$programa_pid_tty_map" ] || exit 0 - programa_pid_csv="$(awk '{print $1}' "$programa_pid_tty_map" | paste -sd, -)" - [ -n "$programa_pid_csv" ] || exit 0 - lsof -nP -a -p "$programa_pid_csv" -iTCP -sTCP:LISTEN -Fpn 2>/dev/null | awk -v map="$programa_pid_tty_map" ' - BEGIN { - while ((getline < map) > 0) { - pid_to_tty[$1] = $2 - } - close(map) - } - $0 ~ /^p/ { - pid = substr($0, 2) - tty = pid_to_tty[pid] - next - } - $0 ~ /^n/ && tty != "" { - name = substr($0, 2) - sub(/->.*/, "", name) - sub(/^.*:/, "", name) - sub(/[^0-9].*/, "", name) - if (name != "") { - print tty "\\t" name - } - } - ' | while IFS=$'\\t' read -r programa_tty programa_port; do - [ -n "$programa_tty" ] || continue - [ -n "$programa_port" ] || continue - programa_emit_port "$programa_tty" "$programa_port" - done - fi - """ - } - - private static func remoteAllPortsScanScript(excluding ports: Set) -> String { - let excludedPorts = ports.sorted().map(String.init).joined(separator: " ") - - return """ - set -eu - programa_excluded_ports=" \(excludedPorts) " - - programa_emit_port() { - programa_port="$1" - case "$programa_excluded_ports" in - *" $programa_port "*) return 0 ;; - esac - [ "$programa_port" -ge 1024 ] && [ "$programa_port" -le 65535 ] || return 0 - printf '%s\\n' "$programa_port" - } - - if command -v ss >/dev/null 2>&1; then - ss -ltnH 2>/dev/null | awk '{print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | while IFS= read -r programa_port; do - [ -n "$programa_port" ] || continue - programa_emit_port "$programa_port" - done - elif command -v netstat >/dev/null 2>&1; then - netstat -lnt 2>/dev/null | awk 'NR > 2 {print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | while IFS= read -r programa_port; do - [ -n "$programa_port" ] || continue - programa_emit_port "$programa_port" - done - elif command -v lsof >/dev/null 2>&1; then - lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk 'NR > 1 {print $9}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | while IFS= read -r programa_port; do - [ -n "$programa_port" ] || continue - programa_emit_port "$programa_port" - done - fi - """ - } - } - diff --git a/Sources/WorkspaceRemoteSessionController+ConnectionOrchestration.swift b/Sources/WorkspaceRemoteSessionController+ConnectionOrchestration.swift new file mode 100644 index 00000000000..dc0297483aa --- /dev/null +++ b/Sources/WorkspaceRemoteSessionController+ConnectionOrchestration.swift @@ -0,0 +1,613 @@ +// Extracted from WorkspaceRemoteSession.swift (nuclear-review #98): connection-attempt/reverse-relay/proxy orchestration and status publishing. + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +extension WorkspaceRemoteSessionController { + func stopAllLocked() { + debugLog("remote.session.stop \(debugConfigSummary())") + isStopping = true + reconnectWorkItem?.cancel() + reconnectWorkItem = nil + reconnectRetryCount = 0 + reverseRelayRestartWorkItem?.cancel() + reverseRelayRestartWorkItem = nil + remotePortScanCoalesceWorkItem?.cancel() + remotePortScanCoalesceWorkItem = nil + stopReverseRelayLocked() + remotePortScanGeneration &+= 1 + remotePortScanBurstActive = false + remotePortScanActiveReason = nil + remotePortScanPendingReason = nil + remotePortScanTTYNames.removeAll() + remoteScannedPortsByPanel.removeAll() + stopRemotePortPollingLocked() + polledRemotePorts = [] + remotePortPollBaselinePorts = nil + keepPolledRemotePortsUntilTTYScan = false + bootstrapRemoteTTYResolved = false + bootstrapRemoteTTYRetryWorkItem?.cancel() + bootstrapRemoteTTYRetryWorkItem = nil + bootstrapRemoteTTYFetchInFlight = false + bootstrapRemoteTTYRetryCount = 0 + + proxyLease?.release() + proxyLease = nil + proxyEndpoint = nil + daemonReady = false + daemonBootstrapVersion = nil + daemonRemotePath = nil + publishProxyEndpoint(nil) + publishPortsSnapshotLocked() + } + + func beginConnectionAttemptLocked() { + guard !isStopping else { return } + + Self.killOrphanedRemoteSSHProcesses( + destination: configuration.destination, + relayPort: configuration.relayPort + ) + connectionAttemptStartedAt = Date() + debugLog("remote.session.connect.begin retry=\(reconnectRetryCount) \(debugConfigSummary())") + reconnectWorkItem = nil + bootstrapRemoteTTYRetryWorkItem?.cancel() + bootstrapRemoteTTYRetryWorkItem = nil + bootstrapRemoteTTYFetchInFlight = false + if remotePortScanTTYNames.isEmpty { + bootstrapRemoteTTYResolved = false + bootstrapRemoteTTYRetryCount = 0 + } + let connectDetail: String + let bootstrapDetail: String + if reconnectRetryCount > 0 { + connectDetail = "Reconnecting to \(configuration.displayTarget) (retry \(reconnectRetryCount))" + bootstrapDetail = "Bootstrapping remote daemon on \(configuration.displayTarget) (retry \(reconnectRetryCount))" + } else { + connectDetail = "Connecting to \(configuration.displayTarget)" + bootstrapDetail = "Bootstrapping remote daemon on \(configuration.displayTarget)" + } + publishState(.connecting, detail: connectDetail) + publishDaemonStatus(.bootstrapping, detail: bootstrapDetail) + do { + let hello = try bootstrapDaemonLocked() + guard hello.capabilities.contains(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability) else { + throw NSError(domain: "programa.remote.daemon", code: 43, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon missing required capability \(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability)", + ]) + } + daemonReady = true + daemonBootstrapVersion = hello.version + daemonRemotePath = hello.remotePath + publishDaemonStatus( + .ready, + detail: "Remote daemon ready", + version: hello.version, + name: hello.name, + capabilities: hello.capabilities, + remotePath: hello.remotePath + ) + recordHeartbeatActivityLocked() + startReverseRelayLocked(remotePath: hello.remotePath) + requestBootstrapRemoteTTYIfNeededLocked() + startProxyLocked() + } catch { + daemonReady = false + daemonBootstrapVersion = nil + daemonRemotePath = nil + let retrySchedule = scheduleReconnectLocked(baseDelay: 4.0) + let retrySuffix = Self.retrySuffix(retry: retrySchedule.retry, delay: retrySchedule.delay) + let detail = "Remote daemon bootstrap failed: \(error.localizedDescription)\(retrySuffix)" + publishDaemonStatus(.error, detail: detail) + publishState(.error, detail: detail) + } + } + + func startProxyLocked() { + guard !isStopping else { return } + guard daemonReady else { return } + guard proxyLease == nil else { return } + guard let remotePath = daemonRemotePath, + !remotePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + let retrySchedule = scheduleReconnectLocked(baseDelay: 4.0) + let retrySuffix = Self.retrySuffix(retry: retrySchedule.retry, delay: retrySchedule.delay) + let detail = "Remote daemon did not provide a valid remote path\(retrySuffix)" + publishDaemonStatus(.error, detail: detail) + publishState(.error, detail: detail) + return + } + + let lease = WorkspaceRemoteProxyBroker.shared.acquire( + configuration: configuration, + remotePath: remotePath + ) { [weak self] update in + self?.queue.async { + self?.handleProxyBrokerUpdateLocked(update) + } + } + proxyLease = lease + } + + func startReverseRelayLocked(remotePath: String) { + guard !isStopping else { return } + guard daemonReady else { return } + guard let relayPort = configuration.relayPort, relayPort > 0, + let relayID = configuration.relayID?.trimmingCharacters(in: .whitespacesAndNewlines), + !relayID.isEmpty, + let relayToken = configuration.relayToken?.trimmingCharacters(in: .whitespacesAndNewlines), + !relayToken.isEmpty, + let localSocketPath = configuration.localSocketPath? + .trimmingCharacters(in: .whitespacesAndNewlines), + !localSocketPath.isEmpty else { + return + } + guard reverseRelayProcess == nil else { return } + guard reverseRelayControlMasterForwardSpec == nil else { return } + + reverseRelayRestartWorkItem?.cancel() + reverseRelayRestartWorkItem = nil + var relayServer: WorkspaceRemoteCLIRelayServer? + do { + let server = try ensureCLIRelayServerLocked( + localSocketPath: localSocketPath, + relayID: relayID, + relayToken: relayToken + ) + relayServer = server + let localRelayPort = try server.start() + Self.killOrphanedRemoteSSHProcesses( + destination: configuration.destination, + relayPort: relayPort + ) + let forwardSpec = "127.0.0.1:\(relayPort):127.0.0.1:\(localRelayPort)" + + if startReverseRelayViaControlMasterLocked(forwardSpec: forwardSpec) { + cliRelayServer = relayServer + reverseRelayStderrBuffer = "" + do { + try installRemoteRelayMetadataLocked( + remotePath: remotePath, + relayPort: relayPort, + relayID: relayID, + relayToken: relayToken + ) + } catch { + debugLog("remote.relay.metadata.error \(error.localizedDescription)") + stopReverseRelayLocked() + scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) + return + } + recordHeartbeatActivityLocked() + debugLog( + "remote.relay.start relayPort=\(relayPort) localRelayPort=\(localRelayPort) " + + "target=\(configuration.displayTarget) controlMaster=1" + ) + return + } + + let process = Process() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = reverseRelayArguments(relayPort: relayPort, localRelayPort: localRelayPort) + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = stderrPipe + + process.terminationHandler = { [weak self] terminated in + self?.queue.async { + self?.handleReverseRelayTerminationLocked(process: terminated) + } + } + + try process.run() + if let startupFailure = Self.reverseRelayStartupFailureDetail( + process: process, + stderrPipe: stderrPipe + ) { + let retryDelay = 2.0 + let retrySeconds = max(1, Int(retryDelay.rounded())) + debugLog( + "remote.relay.startFailed relayPort=\(relayPort) " + + "error=\(startupFailure)" + ) + relayServer?.stop() + publishDaemonStatus( + .error, + detail: "Remote SSH relay unavailable: \(startupFailure) (retry in \(retrySeconds)s)" + ) + scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: retryDelay) + return + } + installReverseRelayStderrHandlerLocked(stderrPipe) + reverseRelayProcess = process + cliRelayServer = relayServer + reverseRelayStderrPipe = stderrPipe + reverseRelayStderrBuffer = "" + do { + try installRemoteRelayMetadataLocked( + remotePath: remotePath, + relayPort: relayPort, + relayID: relayID, + relayToken: relayToken + ) + } catch { + debugLog("remote.relay.metadata.error \(error.localizedDescription)") + stopReverseRelayLocked() + scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) + return + } + recordHeartbeatActivityLocked() + debugLog( + "remote.relay.start relayPort=\(relayPort) localRelayPort=\(localRelayPort) " + + "target=\(configuration.displayTarget) controlMaster=0" + ) + } catch { + debugLog( + "remote.relay.startFailed relayPort=\(relayPort) " + + "error=\(error.localizedDescription)" + ) + relayServer?.stop() + cliRelayServer = nil + scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) + } + } + + func installReverseRelayStderrHandlerLocked(_ stderrPipe: Pipe) { + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { + handle.readabilityHandler = nil + return + } + self?.queue.async { + guard let self else { return } + if let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty { + self.reverseRelayStderrBuffer.append(chunk) + if self.reverseRelayStderrBuffer.count > 8192 { + self.reverseRelayStderrBuffer.removeFirst(self.reverseRelayStderrBuffer.count - 8192) + } + } + } + } + } + + func handleReverseRelayTerminationLocked(process: Process) { + guard reverseRelayProcess === process else { return } + let stderrDetail = Self.bestErrorLine(stderr: reverseRelayStderrBuffer) + reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil + reverseRelayProcess = nil + reverseRelayStderrPipe = nil + + guard !isStopping else { return } + guard let remotePath = daemonRemotePath, + !remotePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + + let detail = stderrDetail ?? "status=\(process.terminationStatus)" + debugLog("remote.relay.exit \(detail)") + scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) + } + + func scheduleReverseRelayRestartLocked(remotePath: String, delay: TimeInterval) { + guard !isStopping else { return } + reverseRelayRestartWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.reverseRelayRestartWorkItem = nil + guard !self.isStopping else { return } + guard self.reverseRelayProcess == nil else { return } + guard self.daemonReady else { return } + self.startReverseRelayLocked(remotePath: self.daemonRemotePath ?? remotePath) + } + reverseRelayRestartWorkItem = workItem + queue.asyncAfter(deadline: .now() + delay, execute: workItem) + } + + func stopReverseRelayLocked() { + reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil + if let reverseRelayProcess, reverseRelayProcess.isRunning { + reverseRelayProcess.terminate() + } + reverseRelayProcess = nil + stopReverseRelayViaControlMasterLocked() + reverseRelayStderrPipe = nil + reverseRelayStderrBuffer = "" + cliRelayServer?.stop() + cliRelayServer = nil + removeRemoteRelayMetadataLocked() + } + + func handleProxyBrokerUpdateLocked(_ update: WorkspaceRemoteProxyBroker.Update) { + guard !isStopping else { return } + switch update { + case .connecting: + debugLog("remote.proxy.connecting \(debugConfigSummary())") + if proxyEndpoint == nil { + publishState(.connecting, detail: "Connecting to \(configuration.displayTarget)") + } + case .ready(let endpoint): + debugLog("remote.proxy.ready host=\(endpoint.host) port=\(endpoint.port) \(debugConfigSummary())") + reconnectWorkItem?.cancel() + reconnectWorkItem = nil + reconnectRetryCount = 0 + guard proxyEndpoint != endpoint else { + recordHeartbeatActivityLocked() + return + } + proxyEndpoint = endpoint + publishProxyEndpoint(endpoint) + updateRemotePortPollingStateLocked() + publishPortsSnapshotLocked() + publishState( + .connected, + detail: "Connected to \(configuration.displayTarget) via shared local proxy \(endpoint.host):\(endpoint.port)" + ) + requestBootstrapRemoteTTYIfNeededLocked() + recordHeartbeatActivityLocked() + case .error(let detail): + debugLog("remote.proxy.error detail=\(detail) \(debugConfigSummary())") + remotePortScanGeneration &+= 1 + remotePortScanBurstActive = false + remotePortScanActiveReason = nil + remotePortScanPendingReason = nil + remotePortScanCoalesceWorkItem?.cancel() + remotePortScanCoalesceWorkItem = nil + remoteScannedPortsByPanel.removeAll() + stopRemotePortPollingLocked() + polledRemotePorts = [] + keepPolledRemotePortsUntilTTYScan = false + proxyEndpoint = nil + publishProxyEndpoint(nil) + publishPortsSnapshotLocked() + publishState(.error, detail: "Remote proxy to \(configuration.displayTarget) unavailable: \(detail)") + guard Self.shouldEscalateProxyErrorToBootstrap(detail) else { return } + + proxyLease?.release() + proxyLease = nil + daemonReady = false + daemonBootstrapVersion = nil + daemonRemotePath = nil + + let retrySchedule = scheduleReconnectLocked(baseDelay: 2.0) + let retrySuffix = Self.retrySuffix(retry: retrySchedule.retry, delay: retrySchedule.delay) + publishDaemonStatus( + .error, + detail: "Remote daemon transport needs re-bootstrap after proxy failure\(retrySuffix)" + ) + } + } + + @discardableResult + func scheduleReconnectLocked(baseDelay: TimeInterval) -> RetrySchedule { + let retryNumber = reconnectRetryCount + 1 + let retryDelay = Self.retryDelay(baseDelay: baseDelay, retry: retryNumber) + guard !isStopping else { return RetrySchedule(retry: retryNumber, delay: retryDelay) } + reconnectWorkItem?.cancel() + reconnectRetryCount = retryNumber + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.reconnectWorkItem = nil + guard !self.isStopping else { return } + guard self.proxyLease == nil else { return } + self.beginConnectionAttemptLocked() + } + reconnectWorkItem = workItem + queue.asyncAfter(deadline: .now() + retryDelay, execute: workItem) + return RetrySchedule(retry: retryNumber, delay: retryDelay) + } + + func publishState(_ state: WorkspaceRemoteConnectionState, detail: String?) { + let controllerID = self.controllerID + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } + workspace.applyRemoteConnectionStateUpdate( + state, + detail: detail, + target: workspace.remoteDisplayTarget ?? "remote host" + ) + } + } + + func publishDaemonStatus( + _ state: WorkspaceRemoteDaemonState, + detail: String?, + version: String? = nil, + name: String? = nil, + capabilities: [String] = [], + remotePath: String? = nil + ) { + let controllerID = self.controllerID + let status = WorkspaceRemoteDaemonStatus( + state: state, + detail: detail, + version: version, + name: name, + capabilities: capabilities, + remotePath: remotePath + ) + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } + workspace.applyRemoteDaemonStatusUpdate( + status, + target: workspace.remoteDisplayTarget ?? "remote host" + ) + } + } + + func publishProxyEndpoint(_ endpoint: BrowserProxyEndpoint?) { + let controllerID = self.controllerID + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } + workspace.applyRemoteProxyEndpointUpdate(endpoint) + } + } + + func publishPortsSnapshotLocked() { + let controllerID = self.controllerID + let detectedByPanel = remotePortScanTTYNames.keys.reduce(into: [UUID: [Int]]()) { result, panelId in + result[panelId] = remoteScannedPortsByPanel[panelId] ?? [] + } + let detected = Array( + Set(polledRemotePorts) + .union(detectedByPanel.values.flatMap { $0 }) + ).sorted() + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } + workspace.applyRemoteDetectedSurfacePortsSnapshot( + detectedByPanel: detectedByPanel, + detected: detected, + forwarded: [], + conflicts: [], + target: workspace.remoteDisplayTarget ?? "remote host" + ) + } + } + + func recordHeartbeatActivityLocked() { + heartbeatCount += 1 + publishHeartbeat(count: heartbeatCount, at: Date()) + } + + func publishHeartbeat(count: Int, at date: Date?) { + let controllerID = self.controllerID + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } + workspace.applyRemoteHeartbeatUpdate(count: count, lastSeenAt: date) + } + } + + func requestBootstrapRemoteTTYIfNeededLocked() { + guard !bootstrapRemoteTTYResolved else { return } + guard let relayPort = configuration.relayPort, relayPort > 0 else { return } + if !remotePortScanTTYNames.isEmpty { + bootstrapRemoteTTYResolved = true + bootstrapRemoteTTYRetryWorkItem?.cancel() + bootstrapRemoteTTYRetryWorkItem = nil + bootstrapRemoteTTYRetryCount = 0 + return + } + guard !bootstrapRemoteTTYFetchInFlight else { return } + bootstrapRemoteTTYFetchInFlight = true + defer { bootstrapRemoteTTYFetchInFlight = false } + + let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted("tty_path=\"$HOME/.programa/relay/\(relayPort).tty\"; if [ -r \"$tty_path\" ]; then cat \"$tty_path\"; fi"))" + do { + let result = try sshExec( + arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], + timeout: 2 + ) + guard result.status == 0 else { + scheduleBootstrapRemoteTTYRetryLocked() + return + } + guard let ttyName = Self.normalizedRemotePortScanTTYName(result.stdout) else { + scheduleBootstrapRemoteTTYRetryLocked() + return + } + bootstrapRemoteTTYResolved = true + bootstrapRemoteTTYRetryWorkItem?.cancel() + bootstrapRemoteTTYRetryWorkItem = nil + bootstrapRemoteTTYRetryCount = 0 + debugLog("remote.tty.bootstrap.ready tty=\(ttyName) \(debugConfigSummary())") + publishBootstrapRemoteTTY(ttyName) + } catch { + debugLog("remote.tty.bootstrap.failed error=\(error.localizedDescription) \(debugConfigSummary())") + scheduleBootstrapRemoteTTYRetryLocked() + } + } + + func scheduleBootstrapRemoteTTYRetryLocked() { + guard !isStopping else { return } + guard daemonReady else { return } + guard !bootstrapRemoteTTYResolved else { return } + guard remotePortScanTTYNames.isEmpty else { return } + guard bootstrapRemoteTTYRetryCount < Self.bootstrapRemoteTTYRetryLimit else { return } + guard bootstrapRemoteTTYRetryWorkItem == nil else { return } + + bootstrapRemoteTTYRetryCount += 1 + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.bootstrapRemoteTTYRetryWorkItem = nil + self.requestBootstrapRemoteTTYIfNeededLocked() + } + bootstrapRemoteTTYRetryWorkItem = workItem + queue.asyncAfter(deadline: .now() + Self.bootstrapRemoteTTYRetryDelay, execute: workItem) + } + + func publishBootstrapRemoteTTY(_ ttyName: String) { + let controllerID = self.controllerID + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } + workspace.applyBootstrapRemoteTTY(ttyName) + } + } + + func reverseRelayArguments(relayPort: Int, localRelayPort: Int) -> [String] { + // Fallback standalone transport when dynamic forwarding through an existing + // control master is unavailable. + var args: [String] = ["-N", "-T", "-S", "none"] + args += sshCommonArguments(batchMode: true) + args += [ + "-o", "ExitOnForwardFailure=yes", + "-o", "RequestTTY=no", + "-R", "127.0.0.1:\(relayPort):127.0.0.1:\(localRelayPort)", + configuration.destination, + ] + return args + } + + func startReverseRelayViaControlMasterLocked(forwardSpec: String) -> Bool { + guard let arguments = WorkspaceRemoteSSHBatchCommandBuilder.reverseRelayControlMasterArguments( + configuration: configuration, + controlCommand: "forward", + forwardSpec: forwardSpec + ) else { + return false + } + + do { + let result = try sshExec(arguments: arguments, timeout: 6) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) + ?? "ssh exited \(result.status)" + debugLog("remote.relay.controlmaster.forwardFailed \(detail) \(debugConfigSummary())") + return false + } + reverseRelayControlMasterForwardSpec = forwardSpec + return true + } catch { + debugLog("remote.relay.controlmaster.forwardFailed \(error.localizedDescription) \(debugConfigSummary())") + return false + } + } + + func stopReverseRelayViaControlMasterLocked() { + guard let forwardSpec = reverseRelayControlMasterForwardSpec else { return } + reverseRelayControlMasterForwardSpec = nil + guard let arguments = WorkspaceRemoteSSHBatchCommandBuilder.reverseRelayControlMasterArguments( + configuration: configuration, + controlCommand: "cancel", + forwardSpec: forwardSpec + ) else { + return + } + _ = try? sshExec(arguments: arguments, timeout: 4) + } + + static let bootstrapRemoteTTYRetryDelay: TimeInterval = 0.5 + static let bootstrapRemoteTTYRetryLimit = 8 + +} diff --git a/Sources/WorkspaceRemoteSessionController+DaemonInstall.swift b/Sources/WorkspaceRemoteSessionController+DaemonInstall.swift new file mode 100644 index 00000000000..c78e0567d03 --- /dev/null +++ b/Sources/WorkspaceRemoteSessionController+DaemonInstall.swift @@ -0,0 +1,599 @@ +// Extracted from WorkspaceRemoteSession.swift (nuclear-review #98): remote daemon bootstrap/build/download/upload and file-drop upload. + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +extension WorkspaceRemoteSessionController { + static let remotePlatformProbeOSMarker = "__PROGRAMA_REMOTE_OS__=" + static let remotePlatformProbeArchMarker = "__PROGRAMA_REMOTE_ARCH__=" + static let remotePlatformProbeExistsMarker = "__PROGRAMA_REMOTE_EXISTS__=" + + func bootstrapDaemonLocked() throws -> DaemonHello { + debugLog("remote.bootstrap.begin \(debugConfigSummary())") + let version = Self.remoteDaemonVersion() + let bootstrapState = try probeRemoteBootstrapStateLocked(version: version) + let platform = bootstrapState.platform + let remotePath = Self.remoteDaemonPath(version: version, goOS: platform.goOS, goArch: platform.goArch) + let explicitOverrideBinary = Self.explicitRemoteDaemonBinaryURL() + let forceExplicitOverrideInstall = explicitOverrideBinary != nil + debugLog( + "remote.bootstrap.platform os=\(platform.goOS) arch=\(platform.goArch) " + + "version=\(version) remotePath=\(remotePath) " + + "allowLocalBuildFallback=\(Self.allowLocalDaemonBuildFallback() ? 1 : 0) " + + "explicitOverride=\(forceExplicitOverrideInstall ? 1 : 0)" + ) + + let hadExistingBinary = bootstrapState.binaryExists + debugLog("remote.bootstrap.binaryExists remotePath=\(remotePath) exists=\(hadExistingBinary ? 1 : 0)") + if forceExplicitOverrideInstall || !hadExistingBinary { + let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) + try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) + } + + var hello: DaemonHello + do { + hello = try helloRemoteDaemonLocked(remotePath: remotePath) + } catch { + guard hadExistingBinary else { + throw error + } + debugLog( + "remote.bootstrap.helloRetry remotePath=\(remotePath) " + + "detail=\(error.localizedDescription)" + ) + let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) + try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) + hello = try helloRemoteDaemonLocked(remotePath: remotePath) + } + if hadExistingBinary, !hello.capabilities.contains(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability) { + debugLog("remote.bootstrap.capabilityMissing remotePath=\(remotePath) capabilities=\(hello.capabilities.joined(separator: ","))") + let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) + try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) + hello = try helloRemoteDaemonLocked(remotePath: remotePath) + } + + debugLog( + "remote.bootstrap.ready name=\(hello.name) version=\(hello.version) " + + "capabilities=\(hello.capabilities.joined(separator: ",")) remotePath=\(hello.remotePath)" + ) + if let connectionAttemptStartedAt { + debugLog( + "remote.timing.bootstrap.ready elapsedMs=\(Int(Date().timeIntervalSince(connectionAttemptStartedAt) * 1000)) " + + "\(debugConfigSummary())" + ) + } + return hello + } + + func ensureCLIRelayServerLocked(localSocketPath: String, relayID: String, relayToken: String) throws -> WorkspaceRemoteCLIRelayServer { + if let cliRelayServer { + return cliRelayServer + } + let relayServer = try WorkspaceRemoteCLIRelayServer( + localSocketPath: localSocketPath, + relayID: relayID, + relayTokenHex: relayToken + ) + cliRelayServer = relayServer + return relayServer + } + + func installRemoteRelayMetadataLocked( + remotePath: String, + relayPort: Int, + relayID: String, + relayToken: String + ) throws { + let script = Self.remoteRelayMetadataInstallScript( + daemonRemotePath: remotePath, + relayPort: relayPort, + relayID: relayID, + relayToken: relayToken + ) + let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(script))" + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" + throw NSError(domain: "programa.remote.relay", code: 70, userInfo: [ + NSLocalizedDescriptionKey: "failed to install remote relay metadata: \(detail)", + ]) + } + } + + func removeRemoteRelayMetadataLocked() { + guard let relayPort = configuration.relayPort, relayPort > 0 else { return } + let script = Self.remoteRelayMetadataCleanupScript(relayPort: relayPort) + let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(script))" + do { + _ = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + } catch { + debugLog("remote.relay.cleanup.error \(error.localizedDescription)") + } + } + + static func remoteRelayMetadataCleanupScript(relayPort: Int) -> String { + """ + relay_socket='127.0.0.1:\(relayPort)' + socket_addr_file="$HOME/.programa/socket_addr" + if [ -r "$socket_addr_file" ] && [ "$(tr -d '\\r\\n' < "$socket_addr_file")" = "$relay_socket" ]; then + rm -f "$socket_addr_file" + fi + rm -f "$HOME/.programa/relay/\(relayPort).auth" "$HOME/.programa/relay/\(relayPort).daemon_path" "$HOME/.programa/relay/\(relayPort).tty" + """ + } + + func probeRemoteBootstrapStateLocked(version: String) throws -> RemoteBootstrapState { + let script = """ + programa_uname_os="$(uname -s)" + programa_uname_arch="$(uname -m)" + printf '%s%s\\n' '\(Self.remotePlatformProbeOSMarker)' "$programa_uname_os" + printf '%s%s\\n' '\(Self.remotePlatformProbeArchMarker)' "$programa_uname_arch" + case "$(printf '%s' "$programa_uname_os" | tr '[:upper:]' '[:lower:]')" in + linux|darwin|freebsd) programa_go_os="$(printf '%s' "$programa_uname_os" | tr '[:upper:]' '[:lower:]')" ;; + *) exit 70 ;; + esac + case "$(printf '%s' "$programa_uname_arch" | tr '[:upper:]' '[:lower:]')" in + x86_64|amd64) programa_go_arch=amd64 ;; + aarch64|arm64) programa_go_arch=arm64 ;; + armv7l) programa_go_arch=arm ;; + *) exit 71 ;; + esac + programa_remote_path="$HOME/.programa/bin/programad-remote/\(version)/${programa_go_os}-${programa_go_arch}/programad-remote" + if [ -x "$programa_remote_path" ]; then + printf '%syes\\n' '\(Self.remotePlatformProbeExistsMarker)' + else + printf '%sno\\n' '\(Self.remotePlatformProbeExistsMarker)' + fi + """ + let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(script))" + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 20) + + let lines = result.stdout + .split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + let unameOS = lines.first { $0.hasPrefix(Self.remotePlatformProbeOSMarker) } + .map { String($0.dropFirst(Self.remotePlatformProbeOSMarker.count)) } + let unameArch = lines.first { $0.hasPrefix(Self.remotePlatformProbeArchMarker) } + .map { String($0.dropFirst(Self.remotePlatformProbeArchMarker.count)) } + guard let unameOS, let unameArch else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" + throw NSError(domain: "programa.remote.daemon", code: 11, userInfo: [ + NSLocalizedDescriptionKey: "failed to query remote platform: \(detail)", + ]) + } + + guard let goOS = Self.mapUnameOS(unameOS), + let goArch = Self.mapUnameArch(unameArch) else { + throw NSError(domain: "programa.remote.daemon", code: 12, userInfo: [ + NSLocalizedDescriptionKey: "unsupported remote platform \(unameOS)/\(unameArch)", + ]) + } + + let binaryExists = lines.first { $0.hasPrefix(Self.remotePlatformProbeExistsMarker) } + .map { String($0.dropFirst(Self.remotePlatformProbeExistsMarker.count)) == "yes" } + if result.status != 0, binaryExists == nil { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" + throw NSError(domain: "programa.remote.daemon", code: 13, userInfo: [ + NSLocalizedDescriptionKey: "failed to query remote daemon state: \(detail)", + ]) + } + + return RemoteBootstrapState( + platform: RemotePlatform(goOS: goOS, goArch: goArch), + binaryExists: binaryExists ?? false + ) + } + + static let remoteDaemonManifestInfoKey = "CMUXRemoteDaemonManifestJSON" + + static func remoteDaemonManifest(from infoDictionary: [String: Any]?) -> WorkspaceRemoteDaemonManifest? { + guard let rawManifest = infoDictionary?[remoteDaemonManifestInfoKey] as? String else { return nil } + let trimmed = rawManifest.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard let data = trimmed.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(WorkspaceRemoteDaemonManifest.self, from: data) + } + + static func remoteDaemonManifest() -> WorkspaceRemoteDaemonManifest? { + remoteDaemonManifest(from: Bundle.main.infoDictionary) + } + + static func remoteDaemonCacheRoot(fileManager: FileManager = .default) throws -> URL { + let appSupportRoot = try fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let cacheRoot = appSupportRoot + .appendingPathComponent("programa", isDirectory: true) + .appendingPathComponent("remote-daemons", isDirectory: true) + try fileManager.createDirectory(at: cacheRoot, withIntermediateDirectories: true) + return cacheRoot + } + + static func remoteDaemonCachedBinaryURL( + version: String, + goOS: String, + goArch: String, + fileManager: FileManager = .default + ) throws -> URL { + try remoteDaemonCacheRoot(fileManager: fileManager) + .appendingPathComponent(version, isDirectory: true) + .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) + .appendingPathComponent("programad-remote", isDirectory: false) + } + + static func sha256Hex(forFile url: URL) throws -> String { + let data = try Data(contentsOf: url) + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } + + static func allowLocalDaemonBuildFallback(environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool { + environment["PROGRAMA_REMOTE_DAEMON_ALLOW_LOCAL_BUILD"] == "1" + } + + static func explicitRemoteDaemonBinaryURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? { + guard allowLocalDaemonBuildFallback(environment: environment) else { return nil } + guard let path = environment["PROGRAMA_REMOTE_DAEMON_BINARY"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !path.isEmpty else { + return nil + } + return URL(fileURLWithPath: path, isDirectory: false).standardizedFileURL + } + + static func versionedRemoteDaemonBuildURL(goOS: String, goArch: String, version: String) -> URL { + URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("programa-remote-daemon-build", isDirectory: true) + .appendingPathComponent(version, isDirectory: true) + .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) + .appendingPathComponent("programad-remote", isDirectory: false) + } + + /// Fetch the live manifest JSON from the release, returning nil on any failure. + static func fetchRemoteManifestLocked(releaseURL: String, version: String) -> WorkspaceRemoteDaemonManifest? { + guard let manifestURL = URL(string: "\(releaseURL)/programad-remote-manifest.json") else { return nil } + let request = NSMutableURLRequest(url: manifestURL) + request.timeoutInterval = 15 + request.setValue("cmux/\(version)", forHTTPHeaderField: "User-Agent") + let session = URLSession(configuration: .ephemeral) + let semaphore = DispatchSemaphore(value: 0) + var resultData: Data? + session.dataTask(with: request as URLRequest) { data, response, error in + defer { semaphore.signal() } + guard error == nil, + let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { return } + resultData = data + }.resume() + _ = semaphore.wait(timeout: .now() + 20.0) + session.finishTasksAndInvalidate() + guard let data = resultData else { return nil } + return try? JSONDecoder().decode(WorkspaceRemoteDaemonManifest.self, from: data) + } + + func downloadRemoteDaemonBinaryLocked(entry: WorkspaceRemoteDaemonManifest.Entry, version: String, releaseURL: String? = nil) throws -> URL { + guard let url = URL(string: entry.downloadURL) else { + throw NSError(domain: "programa.remote.daemon", code: 25, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon manifest has an invalid download URL", + ]) + } + + let cacheURL = try Self.remoteDaemonCachedBinaryURL(version: version, goOS: entry.goOS, goArch: entry.goArch) + let fileManager = FileManager.default + try fileManager.createDirectory(at: cacheURL.deletingLastPathComponent(), withIntermediateDirectories: true) + + let request = NSMutableURLRequest(url: url) + request.timeoutInterval = 60 + request.setValue("cmux/\(version)", forHTTPHeaderField: "User-Agent") + let session = URLSession(configuration: .ephemeral) + + let semaphore = DispatchSemaphore(value: 0) + var downloadedURL: URL? + var downloadError: Error? + session.downloadTask(with: request as URLRequest) { localURL, response, error in + defer { semaphore.signal() } + if let error { + downloadError = error + return + } + if let httpResponse = response as? HTTPURLResponse, + !(200...299).contains(httpResponse.statusCode) { + downloadError = NSError(domain: "programa.remote.daemon", code: 26, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon download failed with HTTP \(httpResponse.statusCode)", + ]) + return + } + downloadedURL = localURL + }.resume() + _ = semaphore.wait(timeout: .now() + 75.0) + session.finishTasksAndInvalidate() + + if let downloadError { + throw downloadError + } + guard let downloadedURL else { + throw NSError(domain: "programa.remote.daemon", code: 27, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon download did not produce a file", + ]) + } + + let downloadedSHA = try Self.sha256Hex(forFile: downloadedURL) + if downloadedSHA != entry.sha256.lowercased() { + // The embedded manifest's checksum doesn't match the downloaded binary. + // This can happen when a newer build overwrites the shared release + // asset after this build's manifest was embedded. As a fallback, fetch + // the live manifest from the release and verify against that. + if let releaseURL, + let liveManifest = Self.fetchRemoteManifestLocked(releaseURL: releaseURL, version: version), + let liveEntry = liveManifest.entry(goOS: entry.goOS, goArch: entry.goArch), + downloadedSHA == liveEntry.sha256.lowercased() { + debugLog("remote.download.checksum-fallback: embedded manifest checksum stale, live manifest matched for \(entry.assetName)") + } else { + throw NSError(domain: "programa.remote.daemon", code: 28, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon checksum mismatch for \(entry.assetName)", + ]) + } + } + + let tempURL = cacheURL.deletingLastPathComponent() + .appendingPathComponent(".\(cacheURL.lastPathComponent).tmp-\(UUID().uuidString)") + try? fileManager.removeItem(at: tempURL) + try fileManager.moveItem(at: downloadedURL, to: tempURL) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tempURL.path) + try? fileManager.removeItem(at: cacheURL) + try fileManager.moveItem(at: tempURL, to: cacheURL) + return cacheURL + } + + func buildLocalDaemonBinary(goOS: String, goArch: String, version: String) throws -> URL { + if let explicitBinary = Self.explicitRemoteDaemonBinaryURL(), + FileManager.default.isExecutableFile(atPath: explicitBinary.path) { + debugLog("remote.build.explicit path=\(explicitBinary.path)") + return explicitBinary + } + + if let manifest = Self.remoteDaemonManifest(), + manifest.appVersion == version, + let entry = manifest.entry(goOS: goOS, goArch: goArch) { + let cacheURL = try Self.remoteDaemonCachedBinaryURL(version: manifest.appVersion, goOS: goOS, goArch: goArch) + if FileManager.default.fileExists(atPath: cacheURL.path) { + let cachedSHA = try Self.sha256Hex(forFile: cacheURL) + if cachedSHA == entry.sha256.lowercased(), + FileManager.default.isExecutableFile(atPath: cacheURL.path) { + debugLog("remote.build.cached path=\(cacheURL.path)") + return cacheURL + } + try? FileManager.default.removeItem(at: cacheURL) + } + let downloadedURL = try downloadRemoteDaemonBinaryLocked(entry: entry, version: manifest.appVersion, releaseURL: manifest.releaseURL) + debugLog("remote.build.downloaded path=\(downloadedURL.path)") + return downloadedURL + } + + guard Self.allowLocalDaemonBuildFallback() else { + throw NSError(domain: "programa.remote.daemon", code: 20, userInfo: [ + NSLocalizedDescriptionKey: "this build does not include a verified programad-remote manifest for \(goOS)-\(goArch). Use a release build, or set PROGRAMA_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 for a dev-only fallback.", + ]) + } + + guard let repoRoot = Self.findRepoRoot() else { + throw NSError(domain: "programa.remote.daemon", code: 20, userInfo: [ + NSLocalizedDescriptionKey: "cannot locate cmux repo root for dev-only programad-remote build fallback", + ]) + } + let daemonRoot = repoRoot.appendingPathComponent("daemon/remote", isDirectory: true) + let goModPath = daemonRoot.appendingPathComponent("go.mod").path + guard FileManager.default.fileExists(atPath: goModPath) else { + throw NSError(domain: "programa.remote.daemon", code: 21, userInfo: [ + NSLocalizedDescriptionKey: "missing daemon module at \(goModPath)", + ]) + } + guard let goBinary = Self.which("go") else { + throw NSError(domain: "programa.remote.daemon", code: 22, userInfo: [ + NSLocalizedDescriptionKey: "go is required for the dev-only programad-remote build fallback", + ]) + } + + let output = Self.versionedRemoteDaemonBuildURL(goOS: goOS, goArch: goArch, version: version) + try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) + + var env = ProcessInfo.processInfo.environment + env["GOOS"] = goOS + env["GOARCH"] = goArch + env["CGO_ENABLED"] = "0" + let ldflags = "-s -w -X main.version=\(version)" + let result = try runProcess( + executable: goBinary, + arguments: ["build", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, "-o", output.path, "./cmd/programad-remote"], + environment: env, + currentDirectory: daemonRoot, + stdin: nil, + timeout: 90 + ) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "go build failed with status \(result.status)" + throw NSError(domain: "programa.remote.daemon", code: 23, userInfo: [ + NSLocalizedDescriptionKey: "failed to build programad-remote: \(detail)", + ]) + } + guard FileManager.default.isExecutableFile(atPath: output.path) else { + throw NSError(domain: "programa.remote.daemon", code: 24, userInfo: [ + NSLocalizedDescriptionKey: "programad-remote build output is not executable", + ]) + } + debugLog("remote.build.output path=\(output.path)") + return output + } + + func uploadRemoteDaemonBinaryLocked(localBinary: URL, remotePath: String) throws { + let remoteDirectory = (remotePath as NSString).deletingLastPathComponent + let remoteTempPath = "\(remotePath).tmp-\(UUID().uuidString.prefix(8))" + debugLog( + "remote.upload.begin local=\(localBinary.path) remoteTemp=\(remoteTempPath) remote=\(remotePath)" + ) + + let mkdirScript = "mkdir -p \(RemoteSSHConnectionPolicy.shellSingleQuoted(remoteDirectory))" + let mkdirCommand = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(mkdirScript))" + let mkdirResult = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, mkdirCommand], timeout: 12) + guard mkdirResult.status == 0 else { + let detail = Self.bestErrorLine(stderr: mkdirResult.stderr, stdout: mkdirResult.stdout) ?? "ssh exited \(mkdirResult.status)" + throw NSError(domain: "programa.remote.daemon", code: 30, userInfo: [ + NSLocalizedDescriptionKey: "failed to create remote daemon directory: \(detail)", + ]) + } + + let scpSSHOptions = RemoteSSHConnectionPolicy.backgroundOptions(configuration.sshOptions) + var scpArgs: [String] = ["-q"] + scpArgs += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: scpSSHOptions) + scpArgs += ["-o", "ControlMaster=no"] + if let port = configuration.port { + scpArgs += ["-P", String(port)] + } + if let identityFile = configuration.identityFile, + !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + scpArgs += ["-i", identityFile] + } + for option in scpSSHOptions { + scpArgs += ["-o", option] + } + scpArgs += [localBinary.path, "\(configuration.destination):\(remoteTempPath)"] + let scpResult = try scpExec(arguments: scpArgs, timeout: 45) + guard scpResult.status == 0 else { + let detail = Self.bestErrorLine(stderr: scpResult.stderr, stdout: scpResult.stdout) ?? "scp exited \(scpResult.status)" + throw NSError(domain: "programa.remote.daemon", code: 31, userInfo: [ + NSLocalizedDescriptionKey: "failed to upload programad-remote: \(detail)", + ]) + } + + let finalizeScript = """ + chmod 755 \(RemoteSSHConnectionPolicy.shellSingleQuoted(remoteTempPath)) && \ + mv \(RemoteSSHConnectionPolicy.shellSingleQuoted(remoteTempPath)) \(RemoteSSHConnectionPolicy.shellSingleQuoted(remotePath)) + """ + let finalizeCommand = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(finalizeScript))" + let finalizeResult = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, finalizeCommand], timeout: 12) + guard finalizeResult.status == 0 else { + let detail = Self.bestErrorLine(stderr: finalizeResult.stderr, stdout: finalizeResult.stdout) ?? "ssh exited \(finalizeResult.status)" + throw NSError(domain: "programa.remote.daemon", code: 32, userInfo: [ + NSLocalizedDescriptionKey: "failed to install remote daemon binary: \(detail)", + ]) + } + } + + func uploadDroppedFilesLocked( + _ fileURLs: [URL], + operation: TerminalImageTransferOperation + ) throws -> [String] { + let scpSSHOptions = RemoteSSHConnectionPolicy.backgroundOptions(configuration.sshOptions) + return try performSCPUploadWithCancelCleanup( + items: fileURLs, + checkCancelled: { try operation.throwIfCancelled() }, + performUpload: { localURL, record in + let normalizedLocalURL = localURL.standardizedFileURL + guard normalizedLocalURL.isFileURL else { + throw RemoteDropUploadError.invalidFileURL + } + + let remotePath = Self.remoteDropPath(for: normalizedLocalURL) + record(remotePath) + var scpArgs: [String] = ["-q", "-o", "ControlMaster=no"] + scpArgs += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: scpSSHOptions) + if let port = configuration.port { + scpArgs += ["-P", String(port)] + } + if let identityFile = configuration.identityFile, + !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + scpArgs += ["-i", identityFile] + } + for option in scpSSHOptions { + scpArgs += ["-o", option] + } + scpArgs += [normalizedLocalURL.path, "\(configuration.destination):\(remotePath)"] + + let scpResult = try scpExec(arguments: scpArgs, timeout: 45, operation: operation) + guard scpResult.status == 0 else { + let detail = Self.bestErrorLine(stderr: scpResult.stderr, stdout: scpResult.stdout) ?? + "scp exited \(scpResult.status)" + throw RemoteDropUploadError.uploadFailed(detail) + } + }, + cleanup: { cleanupUploadedRemotePaths($0) } + ) + } + + static func remoteDropPath(for fileURL: URL, uuid: UUID = UUID()) -> String { + let extensionSuffix = fileURL.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines) + let lowercasedSuffix = extensionSuffix.isEmpty ? "" : ".\(extensionSuffix.lowercased())" + return "/tmp/programa-drop-\(uuid.uuidString.lowercased())\(lowercasedSuffix)" + } + + func cleanupUploadedRemotePaths(_ remotePaths: [String]) { + guard !remotePaths.isEmpty else { return } + let cleanupScript = "rm -f -- " + remotePaths.map(RemoteSSHConnectionPolicy.shellSingleQuoted).joined(separator: " ") + let cleanupCommand = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(cleanupScript))" + _ = try? sshExec( + arguments: sshCommonArguments(batchMode: true) + [configuration.destination, cleanupCommand], + timeout: 8 + ) + } + + func helloRemoteDaemonLocked(remotePath: String) throws -> DaemonHello { + let request = #"{"id":1,"method":"hello","params":{}}"# + let script = "printf '%s\\n' \(RemoteSSHConnectionPolicy.shellSingleQuoted(request)) | \(RemoteSSHConnectionPolicy.shellSingleQuoted(remotePath)) serve --stdio" + let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(script))" + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 12) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" + throw NSError(domain: "programa.remote.daemon", code: 40, userInfo: [ + NSLocalizedDescriptionKey: "failed to start remote daemon: \(detail)", + ]) + } + + let responseLine = result.stdout + .split(separator: "\n") + .map(String.init) + .first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) ?? "" + guard !responseLine.isEmpty, + let data = responseLine.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { + throw NSError(domain: "programa.remote.daemon", code: 41, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon hello returned invalid JSON", + ]) + } + + if let ok = payload["ok"] as? Bool, !ok { + let errorMessage: String = { + if let errorObject = payload["error"] as? [String: Any], + let message = errorObject["message"] as? String, + !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return message + } + return "hello call failed" + }() + throw NSError(domain: "programa.remote.daemon", code: 42, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon hello failed: \(errorMessage)", + ]) + } + + let resultObject = payload["result"] as? [String: Any] ?? [:] + let name = (resultObject["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let version = (resultObject["version"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let capabilities = (resultObject["capabilities"] as? [String]) ?? [] + return DaemonHello( + name: (name?.isEmpty == false ? name! : "programad-remote"), + version: (version?.isEmpty == false ? version! : "dev"), + capabilities: capabilities, + remotePath: remotePath + ) + } + +} diff --git a/Sources/WorkspaceRemoteSessionController+PortScanning.swift b/Sources/WorkspaceRemoteSessionController+PortScanning.swift new file mode 100644 index 00000000000..b58289f2ee2 --- /dev/null +++ b/Sources/WorkspaceRemoteSessionController+PortScanning.swift @@ -0,0 +1,489 @@ +// Extracted from WorkspaceRemoteSession.swift (nuclear-review #98): remote listening-port scan scheduling, polling, and script builders. + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +extension WorkspaceRemoteSessionController { + func updateRemotePortScanTTYs(_ ttyNames: [UUID: String]) { + queue.async { [weak self] in + self?.updateRemotePortScanTTYsLocked(ttyNames) + } + } + + func kickRemotePortScan(panelId: UUID, reason: PortScanKickReason = .command) { + queue.async { [weak self] in + self?.kickRemotePortScanLocked(panelId: panelId, reason: reason) + } + } + + func updateRemotePortScanTTYsLocked(_ ttyNames: [UUID: String]) { + let previousTTYNames = remotePortScanTTYNames + let nextTTYNames = ttyNames.reduce(into: [UUID: String]()) { result, entry in + guard let ttyName = Self.normalizedRemotePortScanTTYName(entry.value) else { return } + result[entry.key] = ttyName + } + guard previousTTYNames != nextTTYNames else { return } + if !nextTTYNames.isEmpty { + bootstrapRemoteTTYResolved = true + bootstrapRemoteTTYRetryWorkItem?.cancel() + bootstrapRemoteTTYRetryWorkItem = nil + bootstrapRemoteTTYRetryCount = 0 + } + keepPolledRemotePortsUntilTTYScan = + !previousTTYNames.isEmpty + ? keepPolledRemotePortsUntilTTYScan + : shouldUseFallbackRemotePortPollingLocked() && !polledRemotePorts.isEmpty && !nextTTYNames.isEmpty + remoteScannedPortsByPanel = remoteScannedPortsByPanel.filter { panelId, _ in + guard let oldTTY = previousTTYNames[panelId], + let newTTY = nextTTYNames[panelId] else { + return false + } + return oldTTY == newTTY + } + remotePortScanTTYNames = nextTTYNames + if nextTTYNames.isEmpty { + keepPolledRemotePortsUntilTTYScan = false + } + updateRemotePortPollingStateLocked() + publishPortsSnapshotLocked() + } + + func kickRemotePortScanLocked(panelId: UUID, reason: PortScanKickReason) { + guard !isStopping else { return } + guard daemonReady else { return } + guard remotePortScanTTYNames[panelId] != nil else { return } + if remotePortScanBurstActive, remotePortScanActiveReason == .command, reason == .refresh { + return + } + remotePortScanPendingReason = remotePortScanPendingReason?.merged(with: reason) ?? reason + scheduleRemotePortScanCoalesceLocked() + } + + func scheduleRemotePortScanCoalesceLocked() { + guard !remotePortScanBurstActive else { return } + guard remotePortScanCoalesceWorkItem == nil else { return } + + let generation = remotePortScanGeneration + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + guard self.remotePortScanGeneration == generation else { return } + self.remotePortScanCoalesceWorkItem = nil + guard let reason = self.remotePortScanPendingReason else { return } + self.remotePortScanPendingReason = nil + self.remotePortScanBurstActive = true + self.remotePortScanActiveReason = reason + self.runRemotePortScanBurstLocked(index: 0, generation: generation, reason: reason) + } + remotePortScanCoalesceWorkItem = workItem + queue.asyncAfter(deadline: .now() + 0.2, execute: workItem) + } + + func runRemotePortScanBurstLocked( + index: Int, + generation: UInt64, + reason: PortScanKickReason, + burstStart: DispatchTime? = nil + ) { + guard remotePortScanGeneration == generation else { return } + + let burstOffsets = reason.burstOffsets + guard index < burstOffsets.count else { + remotePortScanBurstActive = false + remotePortScanActiveReason = nil + if remotePortScanPendingReason != nil && remotePortScanCoalesceWorkItem == nil { + scheduleRemotePortScanCoalesceLocked() + } + return + } + + let start = burstStart ?? .now() + let deadline = start + burstOffsets[index] + queue.asyncAfter(deadline: deadline) { [weak self] in + guard let self else { return } + guard self.remotePortScanGeneration == generation else { return } + self.performRemotePortScanLocked() + self.runRemotePortScanBurstLocked( + index: index + 1, + generation: generation, + reason: reason, + burstStart: start + ) + } + } + + func performRemotePortScanLocked() { + let ttyNamesByPanel = remotePortScanTTYNames + guard !ttyNamesByPanel.isEmpty else { + remoteScannedPortsByPanel.removeAll() + keepPolledRemotePortsUntilTTYScan = false + publishPortsSnapshotLocked() + return + } + + do { + remoteScannedPortsByPanel = try scanRemotePortsByPanelLocked(ttyNamesByPanel: ttyNamesByPanel) + keepPolledRemotePortsUntilTTYScan = false + polledRemotePorts = [] + publishPortsSnapshotLocked() + } catch { + debugLog("remote.ports.scan.failed error=\(error.localizedDescription) \(debugConfigSummary())") + } + } + + func scanRemotePortsByPanelLocked(ttyNamesByPanel: [UUID: String]) throws -> [UUID: [Int]] { + let ttyNames = Array(Set(ttyNamesByPanel.values)).sorted() + guard !ttyNames.isEmpty else { return [:] } + + let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(Self.remotePortScanScript(ttyNames: ttyNames, excluding: excludedRemoteScanPorts())))" + let result = try sshExec( + arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], + timeout: 8 + ) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" + throw NSError(domain: "programa.remote.ports", code: 90, userInfo: [ + NSLocalizedDescriptionKey: "remote port scan failed: \(detail)", + ]) + } + + let portsByTTY = Self.parseRemoteTTYPortPairs( + output: result.stdout, + trackedTTYNames: Set(ttyNames) + ) + + return ttyNamesByPanel.reduce(into: [UUID: [Int]]()) { result, entry in + result[entry.key] = portsByTTY[entry.value] ?? [] + } + } + + func startRemotePortPollingLocked(mode: RemotePortPollingMode) { + if remotePortPollTimer != nil, remotePortPollMode == mode { + return + } + stopRemotePortPollingLocked() + + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + mode.initialDelay, repeating: mode.repeatInterval) + timer.setEventHandler { [weak self] in + self?.pollRemotePortsLocked() + } + remotePortPollTimer = timer + remotePortPollMode = mode + timer.resume() + pollRemotePortsLocked() + } + + func stopRemotePortPollingLocked() { + remotePortPollTimer?.setEventHandler {} + remotePortPollTimer?.cancel() + remotePortPollTimer = nil + remotePortPollMode = nil + } + + func updateRemotePortPollingStateLocked() { + guard daemonReady, !isStopping, let pollingMode = remotePortPollingModeLocked() else { + stopRemotePortPollingLocked() + if !keepPolledRemotePortsUntilTTYScan { + polledRemotePorts = [] + } + remotePortPollBaselinePorts = nil + return + } + startRemotePortPollingLocked(mode: pollingMode) + } + + func pollRemotePortsLocked() { + guard !isStopping else { return } + guard daemonReady else { return } + if !remotePortScanTTYNames.isEmpty { + guard shouldUseTTYFallbackRemotePortPollingLocked() else { + stopRemotePortPollingLocked() + if !keepPolledRemotePortsUntilTTYScan { + polledRemotePorts = [] + } + publishPortsSnapshotLocked() + return + } + if remotePortScanBurstActive || remotePortScanCoalesceWorkItem != nil || remotePortScanPendingReason != nil { + return + } + performRemotePortScanLocked() + return + } + guard let pollingMode = remotePortPollingModeLocked() else { + stopRemotePortPollingLocked() + polledRemotePorts = [] + remotePortPollBaselinePorts = nil + keepPolledRemotePortsUntilTTYScan = false + publishPortsSnapshotLocked() + return + } + guard remotePortScanTTYNames.isEmpty else { + stopRemotePortPollingLocked() + if !keepPolledRemotePortsUntilTTYScan { + polledRemotePorts = [] + } + remotePortPollBaselinePorts = nil + publishPortsSnapshotLocked() + return + } + + let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(Self.remoteAllPortsScanScript(excluding: excludedRemoteScanPorts())))" + do { + let result = try sshExec( + arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], + timeout: 8 + ) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" + throw NSError(domain: "programa.remote.ports", code: 90, userInfo: [ + NSLocalizedDescriptionKey: "remote port scan failed: \(detail)", + ]) + } + let currentPorts = Set(Self.parseRemotePorts(output: result.stdout)) + switch pollingMode { + case .hostWide: + polledRemotePorts = currentPorts.sorted() + remotePortPollBaselinePorts = nil + case .hostWideDelta: + if let baselinePorts = remotePortPollBaselinePorts { + polledRemotePorts = currentPorts.subtracting(baselinePorts).sorted() + } else { + remotePortPollBaselinePorts = currentPorts + polledRemotePorts = [] + } + case .ttyScoped: + polledRemotePorts = [] + remotePortPollBaselinePorts = nil + } + keepPolledRemotePortsUntilTTYScan = false + publishPortsSnapshotLocked() + } catch { + debugLog("remote.ports.poll.failed error=\(error.localizedDescription) \(debugConfigSummary())") + } + } + + func excludedRemoteScanPorts() -> Set { + var excluded: Set = [] + if let relayPort = configuration.relayPort, relayPort > 0 { + excluded.insert(relayPort) + } + if let configuredPort = configuration.port, configuredPort > 0 { + excluded.insert(configuredPort) + } + return excluded + } + + func shouldUseFallbackRemotePortPollingLocked() -> Bool { + // `cmux ssh` owns the remote shell bootstrap and can report the remote + // TTY precisely. Falling back to host-wide port scans in that path leaks + // unrelated listeners from the remote machine into the workspace card. + let startupCommand = configuration.terminalStartupCommand? + .trimmingCharacters(in: .whitespacesAndNewlines) + return startupCommand?.isEmpty != false + } + + func shouldUseTTYFallbackRemotePortPollingLocked() -> Bool { + // `cmux ssh` can still land in shells without our command hooks, such as + // `/bin/sh` in the Docker fixture. Once the workspace knows the TTY, + // keep a low-frequency TTY-scoped poll so unsupported shells still + // surface ports without bringing back noisy host-wide scans. + let startupCommand = configuration.terminalStartupCommand? + .trimmingCharacters(in: .whitespacesAndNewlines) + return startupCommand?.isEmpty == false + } + + func remotePortPollingModeLocked() -> RemotePortPollingMode? { + if !remotePortScanTTYNames.isEmpty { + return shouldUseTTYFallbackRemotePortPollingLocked() ? .ttyScoped : nil + } + let startupCommand = configuration.terminalStartupCommand? + .trimmingCharacters(in: .whitespacesAndNewlines) + if startupCommand?.isEmpty == false { + return .hostWideDelta + } + return shouldUseFallbackRemotePortPollingLocked() ? .hostWide : nil + } + + static func parseRemoteTTYPortPairs(output: String, trackedTTYNames: Set) -> [String: [Int]] { + var portsByTTY = Dictionary(uniqueKeysWithValues: trackedTTYNames.map { ($0, Set()) }) + + for line in output.split(separator: "\n") { + let parts = line.split(separator: "\t", omittingEmptySubsequences: false) + guard parts.count == 2 else { continue } + let ttyName = String(parts[0]).trimmingCharacters(in: .whitespacesAndNewlines) + guard trackedTTYNames.contains(ttyName), + let port = Int(parts[1]), + port >= 1024, + port <= 65535 else { + continue + } + portsByTTY[ttyName, default: []].insert(port) + } + + return portsByTTY.reduce(into: [String: [Int]]()) { result, entry in + result[entry.key] = entry.value.sorted() + } + } + + static func parseRemotePorts(output: String) -> [Int] { + let values = output + .split(whereSeparator: \.isWhitespace) + .compactMap { Int($0) } + .filter { $0 >= 1024 && $0 <= 65535 } + return Array(Set(values)).sorted() + } + + static func normalizedRemotePortScanTTYName(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let candidate = trimmed.split(separator: "/").last.map(String.init) ?? trimmed + guard !candidate.isEmpty else { return nil } + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) + guard candidate.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { return nil } + return candidate + } + + static func remotePortScanScript(ttyNames: [String], excluding ports: Set) -> String { + let ttySet = ttyNames.joined(separator: " ") + let ttyCSV = ttyNames.joined(separator: ",") + let excludedPorts = ports.sorted().map(String.init).joined(separator: " ") + + return """ + set -eu + programa_tracked_ttys=" \(ttySet) " + programa_tty_csv='\(ttyCSV)' + programa_excluded_ports=" \(excludedPorts) " + + programa_emit_port() { + programa_tty="$1" + programa_port="$2" + case "$programa_tracked_ttys" in + *" $programa_tty "*) ;; + *) return 0 ;; + esac + case "$programa_excluded_ports" in + *" $programa_port "*) return 0 ;; + esac + [ "$programa_port" -ge 1024 ] && [ "$programa_port" -le 65535 ] || return 0 + printf '%s\\t%s\\n' "$programa_tty" "$programa_port" + } + + programa_used_ss=0 + if [ -d /proc ] && command -v ss >/dev/null 2>&1; then + programa_ss_output="$(ss -ltnpH 2>/dev/null || true)" + case "$programa_ss_output" in + *pid=*) + programa_used_ss=1 + printf '%s\\n' "$programa_ss_output" | while IFS= read -r programa_line; do + [ -n "$programa_line" ] || continue + programa_port="$(printf '%s\\n' "$programa_line" | awk '{print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ { print $1; exit }')" + [ -n "$programa_port" ] || continue + printf '%s\\n' "$programa_line" | awk ' + { + line = $0 + while (match(line, /pid=[0-9]+/)) { + print substr(line, RSTART + 4, RLENGTH - 4) + line = substr(line, RSTART + RLENGTH) + } + } + ' | while IFS= read -r programa_pid; do + [ -n "$programa_pid" ] || continue + programa_tty_path="$(readlink "/proc/$programa_pid/fd/0" 2>/dev/null || true)" + [ -n "$programa_tty_path" ] || continue + programa_tty="${programa_tty_path##*/}" + [ -n "$programa_tty" ] || continue + programa_emit_port "$programa_tty" "$programa_port" + done + done + ;; + esac + fi + + if [ "$programa_used_ss" -eq 0 ] && command -v lsof >/dev/null 2>&1 && [ -n "$programa_tty_csv" ]; then + programa_tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t programa-ports)" + trap 'rm -rf "$programa_tmpdir"' EXIT INT TERM + programa_pid_tty_map="$programa_tmpdir/pid_tty" + ps -t "$programa_tty_csv" -o pid=,tty= 2>/dev/null | awk ' + NF >= 2 { + tty = $2 + sub(/^.*\\//, "", tty) + print $1 "\\t" tty + } + ' > "$programa_pid_tty_map" + [ -s "$programa_pid_tty_map" ] || exit 0 + programa_pid_csv="$(awk '{print $1}' "$programa_pid_tty_map" | paste -sd, -)" + [ -n "$programa_pid_csv" ] || exit 0 + lsof -nP -a -p "$programa_pid_csv" -iTCP -sTCP:LISTEN -Fpn 2>/dev/null | awk -v map="$programa_pid_tty_map" ' + BEGIN { + while ((getline < map) > 0) { + pid_to_tty[$1] = $2 + } + close(map) + } + $0 ~ /^p/ { + pid = substr($0, 2) + tty = pid_to_tty[pid] + next + } + $0 ~ /^n/ && tty != "" { + name = substr($0, 2) + sub(/->.*/, "", name) + sub(/^.*:/, "", name) + sub(/[^0-9].*/, "", name) + if (name != "") { + print tty "\\t" name + } + } + ' | while IFS=$'\\t' read -r programa_tty programa_port; do + [ -n "$programa_tty" ] || continue + [ -n "$programa_port" ] || continue + programa_emit_port "$programa_tty" "$programa_port" + done + fi + """ + } + + static func remoteAllPortsScanScript(excluding ports: Set) -> String { + let excludedPorts = ports.sorted().map(String.init).joined(separator: " ") + + return """ + set -eu + programa_excluded_ports=" \(excludedPorts) " + + programa_emit_port() { + programa_port="$1" + case "$programa_excluded_ports" in + *" $programa_port "*) return 0 ;; + esac + [ "$programa_port" -ge 1024 ] && [ "$programa_port" -le 65535 ] || return 0 + printf '%s\\n' "$programa_port" + } + + if command -v ss >/dev/null 2>&1; then + ss -ltnH 2>/dev/null | awk '{print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | while IFS= read -r programa_port; do + [ -n "$programa_port" ] || continue + programa_emit_port "$programa_port" + done + elif command -v netstat >/dev/null 2>&1; then + netstat -lnt 2>/dev/null | awk 'NR > 2 {print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | while IFS= read -r programa_port; do + [ -n "$programa_port" ] || continue + programa_emit_port "$programa_port" + done + elif command -v lsof >/dev/null 2>&1; then + lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk 'NR > 1 {print $9}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | while IFS= read -r programa_port; do + [ -n "$programa_port" ] || continue + programa_emit_port "$programa_port" + done + fi + """ + } + +} diff --git a/Sources/WorkspaceRemoteSessionController+ProcessExecution.swift b/Sources/WorkspaceRemoteSessionController+ProcessExecution.swift new file mode 100644 index 00000000000..6d1bd623212 --- /dev/null +++ b/Sources/WorkspaceRemoteSessionController+ProcessExecution.swift @@ -0,0 +1,193 @@ +// Extracted from WorkspaceRemoteSession.swift (nuclear-review #98): ssh/scp argument assembly and the underlying Process execution primitive. + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +extension WorkspaceRemoteSessionController { + func sshCommonArguments(batchMode: Bool) -> [String] { + let effectiveSSHOptions: [String] = { + if batchMode { + return RemoteSSHConnectionPolicy.backgroundOptions(configuration.sshOptions) + } + return RemoteSSHConnectionPolicy.normalizedOptions(configuration.sshOptions) + }() + var args = RemoteSSHConnectionPolicy.keepaliveArguments + args += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: effectiveSSHOptions) + if batchMode { + args += RemoteSSHConnectionPolicy.batchModeArguments + } + if let port = configuration.port { + args += ["-p", String(port)] + } + if let identityFile = configuration.identityFile, + !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args += ["-i", identityFile] + } + for option in effectiveSSHOptions { + args += ["-o", option] + } + return args + } + + func sshExec(arguments: [String], stdin: Data? = nil, timeout: TimeInterval = 15) throws -> CommandResult { + try runProcess( + executable: "/usr/bin/ssh", + arguments: arguments, + stdin: stdin, + timeout: timeout + ) + } + + func scpExec( + arguments: [String], + timeout: TimeInterval = 30, + operation: TerminalImageTransferOperation? = nil + ) throws -> CommandResult { + try runProcess( + executable: "/usr/bin/scp", + arguments: arguments, + stdin: nil, + timeout: timeout, + operation: operation + ) + } + + func runProcess( + executable: String, + arguments: [String], + environment: [String: String]? = nil, + currentDirectory: URL? = nil, + stdin: Data?, + timeout: TimeInterval, + operation: TerminalImageTransferOperation? = nil + ) throws -> CommandResult { + debugLog( + "remote.proc.start exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + + "timeout=\(Int(timeout)) args=\(debugShellCommand(executable: executable, arguments: arguments))" + ) + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + if let environment { + process.environment = environment + } + if let currentDirectory { + process.currentDirectoryURL = currentDirectory + } + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + if stdin != nil { + process.standardInput = Pipe() + } else { + process.standardInput = FileHandle.nullDevice + } + + let stdoutHandle = stdoutPipe.fileHandleForReading + let stderrHandle = stderrPipe.fileHandleForReading + let captureQueue = DispatchQueue(label: "programa.remote.process.capture") + let exitSemaphore = DispatchSemaphore(value: 0) + var stdoutData = Data() + var stderrData = Data() + let captureGroup = DispatchGroup() + process.terminationHandler = { _ in + exitSemaphore.signal() + } + captureGroup.enter() + DispatchQueue.global(qos: .utility).async { + let data = stdoutHandle.readDataToEndOfFile() + captureQueue.sync { + stdoutData = data + } + captureGroup.leave() + } + captureGroup.enter() + DispatchQueue.global(qos: .utility).async { + let data = stderrHandle.readDataToEndOfFile() + captureQueue.sync { + stderrData = data + } + captureGroup.leave() + } + + do { + try operation?.throwIfCancelled() + try process.run() + } catch { + try? stdoutPipe.fileHandleForWriting.close() + try? stderrPipe.fileHandleForWriting.close() + debugLog( + "remote.proc.launchFailed exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + + "error=\(error.localizedDescription)" + ) + throw NSError(domain: "programa.remote.process", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to launch \(URL(fileURLWithPath: executable).lastPathComponent): \(error.localizedDescription)", + ]) + } + try? stdoutPipe.fileHandleForWriting.close() + try? stderrPipe.fileHandleForWriting.close() + operation?.installCancellationHandler { + if process.isRunning { + process.terminate() + } + } + defer { operation?.clearCancellationHandler() } + + if let stdin, let pipe = process.standardInput as? Pipe { + pipe.fileHandleForWriting.write(stdin) + try? pipe.fileHandleForWriting.close() + } + + func terminateProcessAndWait() { + process.terminate() + let terminatedGracefully = exitSemaphore.wait(timeout: .now() + 2.0) == .success + if !terminatedGracefully, process.isRunning { + _ = Darwin.kill(process.processIdentifier, SIGKILL) + process.waitUntilExit() + } + } + + let didExitBeforeTimeout = exitSemaphore.wait(timeout: .now() + max(0, timeout)) == .success + if !didExitBeforeTimeout, process.isRunning { + if operation?.isCancelled == true { + terminateProcessAndWait() + throw TerminalImageTransferExecutionError.cancelled + } + terminateProcessAndWait() + debugLog( + "remote.proc.timeout exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + + "timeout=\(Int(timeout)) args=\(debugShellCommand(executable: executable, arguments: arguments))" + ) + throw NSError(domain: "programa.remote.process", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "\(URL(fileURLWithPath: executable).lastPathComponent) timed out after \(Int(timeout))s", + ]) + } + + _ = captureGroup.wait(timeout: .now() + 2.0) + try? stdoutHandle.close() + try? stderrHandle.close() + let stdout = String(data: stdoutData, encoding: .utf8) ?? "" + let stderr = String(data: stderrData, encoding: .utf8) ?? "" + if operation?.isCancelled == true { + throw TerminalImageTransferExecutionError.cancelled + } + debugLog( + "remote.proc.end exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + + "status=\(process.terminationStatus) stdout=\(Self.debugLogSnippet(stdout)) " + + "stderr=\(Self.debugLogSnippet(stderr))" + ) + return CommandResult(status: process.terminationStatus, stdout: stdout, stderr: stderr) + } + + +} diff --git a/Sources/WorkspaceRemoteSessionController+ScriptBuilders.swift b/Sources/WorkspaceRemoteSessionController+ScriptBuilders.swift new file mode 100644 index 00000000000..b5910451cb8 --- /dev/null +++ b/Sources/WorkspaceRemoteSessionController+ScriptBuilders.swift @@ -0,0 +1,545 @@ +// Extracted from WorkspaceRemoteSession.swift (nuclear-review #98): debug logging, remote shell script builders, and process/PID utilities. + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +extension WorkspaceRemoteSessionController { + func debugLog(_ message: @autoclosure () -> String) { +#if DEBUG + dlog(message()) +#endif + } + + func debugConfigSummary() -> String { + let controlPath = Self.debugSSHOptionValue(named: "ControlPath", in: configuration.sshOptions) ?? "nil" + return + "target=\(configuration.displayTarget) port=\(configuration.port.map(String.init) ?? "nil") " + + "relayPort=\(configuration.relayPort.map(String.init) ?? "nil") " + + "localSocket=\(configuration.localSocketPath ?? "nil") " + + "controlPath=\(controlPath)" + } + + func debugShellCommand(executable: String, arguments: [String]) -> String { + ([URL(fileURLWithPath: executable).lastPathComponent] + arguments) + .map(RemoteSSHConnectionPolicy.shellSingleQuoted) + .joined(separator: " ") + } + + static func debugSSHOptionValue(named key: String, in options: [String]) -> String? { + let loweredKey = key.lowercased() + for option in options { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let parts = trimmed.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + if parts.count == 2, + parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == loweredKey { + return parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + } + } + return nil + } + + static func debugLogSnippet(_ text: String, limit: Int = 160) -> String { + let normalized = text + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { return "\"\"" } + if normalized.count <= limit { + return normalized + } + return String(normalized.prefix(limit)) + "..." + } + + static func remoteCLIWrapperScript() -> String { + """ + #!/bin/sh + set -eu + + daemon="$HOME/.programa/bin/programad-remote-current" + socket_path="${PROGRAMA_SOCKET_PATH:-}" + if [ -z "$socket_path" ] && [ -r "$HOME/.programa/socket_addr" ]; then + socket_path="$(tr -d '\\r\\n' < "$HOME/.programa/socket_addr")" + fi + + if [ -n "$socket_path" ] && [ "${socket_path#/}" = "$socket_path" ] && [ "${socket_path#*:}" != "$socket_path" ]; then + relay_port="${socket_path##*:}" + relay_map="$HOME/.programa/relay/${relay_port}.daemon_path" + if [ -r "$relay_map" ]; then + mapped_daemon="$(tr -d '\\r\\n' < "$relay_map")" + if [ -n "$mapped_daemon" ] && [ -x "$mapped_daemon" ]; then + daemon="$mapped_daemon" + fi + fi + fi + + exec "$daemon" "$@" + """ + } + + static func remoteCLIWrapperInstallScript(daemonRemotePath: String) -> String { + let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines) + return """ + mkdir -p "$HOME/.programa/bin" "$HOME/.programa/relay" + ln -sf "$HOME/\(trimmedRemotePath)" "$HOME/.programa/bin/programad-remote-current" + wrapper_tmp="$HOME/.programa/bin/.programa-wrapper.tmp.$$" + cat > "$wrapper_tmp" <<'CMUXWRAPPER' + \(remoteCLIWrapperScript()) + CMUXWRAPPER + chmod 755 "$wrapper_tmp" + mv -f "$wrapper_tmp" "$HOME/.programa/bin/programa" + """ + } + + static func remoteRelayMetadataInstallScript( + daemonRemotePath: String, + relayPort: Int, + relayID: String, + relayToken: String + ) -> String { + let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines) + let authPayload = """ + {"relay_id":"\(relayID)","relay_token":"\(relayToken)"} + """ + return """ + umask 077 + mkdir -p "$HOME/.programa" "$HOME/.programa/relay" + chmod 700 "$HOME/.programa/relay" + \(remoteCLIWrapperInstallScript(daemonRemotePath: trimmedRemotePath)) + printf '%s' "$HOME/\(trimmedRemotePath)" > "$HOME/.programa/relay/\(relayPort).daemon_path" + cat > "$HOME/.programa/relay/\(relayPort).auth" <<'PROGRAMARELAYAUTH' + \(authPayload) + PROGRAMARELAYAUTH + chmod 600 "$HOME/.programa/relay/\(relayPort).auth" + printf '%s' '127.0.0.1:\(relayPort)' > "$HOME/.programa/socket_addr" + """ + } + + static func mapUnameOS(_ raw: String) -> String? { + switch raw.lowercased() { + case "linux": + return "linux" + case "darwin": + return "darwin" + case "freebsd": + return "freebsd" + default: + return nil + } + } + + static func mapUnameArch(_ raw: String) -> String? { + switch raw.lowercased() { + case "x86_64", "amd64": + return "amd64" + case "aarch64", "arm64": + return "arm64" + case "armv7l": + return "arm" + default: + return nil + } + } + + static func remoteDaemonVersion() -> String { + let bundleVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let baseVersion = (bundleVersion?.isEmpty == false) ? bundleVersion! : "dev" + guard allowLocalDaemonBuildFallback(), + let sourceFingerprint = remoteDaemonSourceFingerprint(), + !sourceFingerprint.isEmpty else { + return baseVersion + } + return "\(baseVersion)-dev-\(sourceFingerprint)" + } + + static let cachedRemoteDaemonSourceFingerprint: String? = computeRemoteDaemonSourceFingerprint() + + static func remoteDaemonSourceFingerprint() -> String? { + cachedRemoteDaemonSourceFingerprint + } + + static func computeRemoteDaemonSourceFingerprint(fileManager: FileManager = .default) -> String? { + guard let repoRoot = findRepoRoot() else { return nil } + let daemonRoot = repoRoot.appendingPathComponent("daemon/remote", isDirectory: true) + guard let enumerator = fileManager.enumerator( + at: daemonRoot, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + return nil + } + + var relativePaths: [String] = [] + for case let fileURL as URL in enumerator { + guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey]), + resourceValues.isRegularFile == true else { + continue + } + + let relativePath = fileURL.path.replacingOccurrences(of: daemonRoot.path + "/", with: "") + if relativePath == "go.mod" || relativePath == "go.sum" || relativePath.hasSuffix(".go") { + relativePaths.append(relativePath) + } + } + + guard !relativePaths.isEmpty else { return nil } + + let digest = SHA256.hash(data: relativePaths.sorted().reduce(into: Data()) { partialResult, relativePath in + let fileURL = daemonRoot.appendingPathComponent(relativePath, isDirectory: false) + guard let fileData = try? Data(contentsOf: fileURL) else { return } + partialResult.append(Data(relativePath.utf8)) + partialResult.append(0) + partialResult.append(fileData) + partialResult.append(0) + }) + let hex = digest.map { String(format: "%02x", $0) }.joined() + return String(hex.prefix(12)) + } + + static func remoteDaemonPath(version: String, goOS: String, goArch: String) -> String { + ".programa/bin/programad-remote/\(version)/\(goOS)-\(goArch)/programad-remote" + } + + static func orphanedCMUXRemoteSSHPIDs( + psOutput: String, + destination: String, + relayPort: Int? = nil + ) -> [Int] { + let trimmedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedDestination.isEmpty else { return [] } + + return psOutput + .split(separator: "\n", omittingEmptySubsequences: false) + .compactMap { line -> Int? in + guard let parsed = parsePSLine(line) else { return nil } + guard parsed.ppid == 1 else { return nil } + guard isOrphanedCMUXRemoteSSHCommand( + parsed.command, + destination: trimmedDestination, + relayPort: relayPort + ) else { + return nil + } + return parsed.pid + } + .sorted() + } + + static func killOrphanedRemoteSSHProcesses(destination: String, relayPort: Int? = nil) { + guard let output = captureCommandStandardOutput( + executablePath: "/bin/ps", + arguments: ["-axo", "pid=,ppid=,command="] + ) else { + return + } + + for pid in orphanedCMUXRemoteSSHPIDs( + psOutput: output, + destination: destination, + relayPort: relayPort + ) { + _ = Darwin.kill(pid_t(pid), SIGTERM) + } + } + + static func captureCommandStandardOutput( + executablePath: String, + arguments: [String] + ) -> String? { + let process = Process() + let stdoutPipe = Pipe() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + process.standardOutput = stdoutPipe + process.standardError = FileHandle.nullDevice + + do { + try process.run() + let outputData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0, + let output = String(data: outputData, encoding: .utf8), + !output.isEmpty else { + return nil + } + return output + } catch { + // Best effort cleanup only. + return nil + } + } + + static func parsePSLine(_ line: Substring) -> (pid: Int, ppid: Int, command: String)? { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let scanner = Scanner(string: trimmed) + var pidValue: Int = 0 + var ppidValue: Int = 0 + guard scanner.scanInt(&pidValue), scanner.scanInt(&ppidValue) else { + return nil + } + + let commandStart = scanner.currentIndex + let command = String(trimmed[commandStart...]).trimmingCharacters(in: .whitespacesAndNewlines) + guard !command.isEmpty else { return nil } + return (pidValue, ppidValue, command) + } + + static func isOrphanedCMUXRemoteSSHCommand( + _ command: String, + destination: String, + relayPort: Int? + ) -> Bool { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + guard trimmed.hasPrefix("/usr/bin/ssh ") || trimmed.hasPrefix("ssh ") else { return false } + guard commandContainsDestination(trimmed, destination: destination) else { return false } + + if let relayPort { + return trimmed.contains(" -N ") + && trimmed.contains(" -R 127.0.0.1:\(relayPort):127.0.0.1:") + } + + if trimmed.contains(" -N ") && trimmed.contains(" -R 127.0.0.1:") { + return true + } + if trimmed.contains("programad-remote") && trimmed.contains(" serve --stdio") { + return true + } + return false + } + + static func commandContainsDestination(_ command: String, destination: String) -> Bool { + guard !destination.isEmpty else { return false } + let escaped = NSRegularExpression.escapedPattern(for: destination) + guard let regex = try? NSRegularExpression( + pattern: "(^|[\\s'\\\"])\(escaped)($|[\\s'\\\"])", + options: [] + ) else { + return command.contains(destination) + } + let range = NSRange(command.startIndex.. [String] { + var ordered: [String] = [] + var seen: Set = [] + + func appendSearchPath(_ rawPath: String?) { + guard let rawPath else { return } + let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + guard seen.insert(trimmed).inserted else { return } + ordered.append(trimmed) + } + + if let path = environment["PATH"] { + for component in path.split(separator: ":") { + appendSearchPath(String(component)) + } + } + + if let home = environment["HOME"], !home.isEmpty { + appendSearchPath((home as NSString).appendingPathComponent(".local/bin")) + appendSearchPath((home as NSString).appendingPathComponent("go/bin")) + appendSearchPath((home as NSString).appendingPathComponent("bin")) + } + + let helperOutput = pathHelperOutput ?? pathHelperShellOutput() + for component in parsePathHelperPaths(helperOutput) { + appendSearchPath(component) + } + + for component in [ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/local/sbin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ] { + appendSearchPath(component) + } + + return ordered + } + + static func parsePathHelperPaths(_ output: String) -> [String] { + for fragment in output.split(whereSeparator: { $0 == "\n" || $0 == ";" }) { + let trimmed = fragment.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("PATH=\"") else { continue } + let suffix = trimmed.dropFirst("PATH=\"".count) + guard let closingQuote = suffix.firstIndex(of: "\"") else { return [] } + return suffix[.. String { + let executable = "/usr/libexec/path_helper" + guard FileManager.default.isExecutableFile(atPath: executable) else { return "" } + + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = ["-s"] + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + do { + try process.run() + } catch { + return "" + } + + process.waitUntilExit() + guard process.terminationStatus == 0 else { return "" } + let data = stdout.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) ?? "" + } + + static func which(_ executable: String) -> String? { + for component in executableSearchPaths() { + let candidate = (component as NSString).appendingPathComponent(executable) + if FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } + + static func findRepoRoot() -> URL? { + var candidates: [URL] = [] + let compileTimeRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // Sources + .deletingLastPathComponent() // repo root + candidates.append(compileTimeRoot) + let environment = ProcessInfo.processInfo.environment + if let envRoot = environment["PROGRAMA_REMOTE_DAEMON_SOURCE_ROOT"], + !envRoot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + candidates.append(URL(fileURLWithPath: envRoot, isDirectory: true)) + } + if let envRoot = environment["PROGRAMATERM_REPO_ROOT"], + !envRoot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + candidates.append(URL(fileURLWithPath: envRoot, isDirectory: true)) + } + candidates.append(URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)) + if let executable = Bundle.main.executableURL?.deletingLastPathComponent() { + candidates.append(executable) + candidates.append(executable.deletingLastPathComponent()) + candidates.append(executable.deletingLastPathComponent().deletingLastPathComponent()) + } + + let fm = FileManager.default + for base in candidates { + var cursor = base.standardizedFileURL + for _ in 0..<10 { + let marker = cursor.appendingPathComponent("daemon/remote/go.mod").path + if fm.fileExists(atPath: marker) { + return cursor + } + let parent = cursor.deletingLastPathComponent() + if parent.path == cursor.path { + break + } + cursor = parent + } + } + return nil + } + + static func bestErrorLine(stderr: String, stdout: String = "") -> String? { + if let stderrLine = meaningfulErrorLine(in: stderr) { + return stderrLine + } + if let stdoutLine = meaningfulErrorLine(in: stdout) { + return stdoutLine + } + return nil + } + + static func reverseRelayStartupFailureDetail( + process: Process, + stderrPipe: Pipe, + gracePeriod: TimeInterval = reverseRelayStartupGracePeriod + ) -> String? { + if process.isRunning { + let originalTerminationHandler = process.terminationHandler + let exitSemaphore = DispatchSemaphore(value: 0) + process.terminationHandler = { terminated in + originalTerminationHandler?(terminated) + exitSemaphore.signal() + } + if !process.isRunning { + exitSemaphore.signal() + } + guard exitSemaphore.wait(timeout: .now() + max(0, gracePeriod)) == .success else { + return nil + } + } + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stderr = String(data: stderrData, encoding: .utf8) ?? "" + return bestErrorLine(stderr: stderr) ?? "status=\(process.terminationStatus)" + } + + static func meaningfulErrorLine(in text: String) -> String? { + let lines = text + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + for line in lines.reversed() where !isNoiseLine(line) { + return line + } + return lines.last + } + + static func isNoiseLine(_ line: String) -> Bool { + let lowered = line.lowercased() + if lowered.hasPrefix("warning: permanently added") { return true } + if lowered.hasPrefix("debug") { return true } + if lowered.hasPrefix("transferred:") { return true } + if lowered.hasPrefix("openbsd_") { return true } + if lowered.contains("pseudo-terminal will not be allocated") { return true } + return false + } + + static func retrySuffix(retry: Int, delay: TimeInterval) -> String { + let seconds = max(1, Int(delay.rounded())) + return " (retry \(retry) in \(seconds)s)" + } + + static func retryDelay(baseDelay: TimeInterval, retry: Int) -> TimeInterval { + let exponent = Double(max(0, retry - 1)) + return min(baseDelay * pow(2.0, exponent), 60.0) + } + + static func shouldEscalateProxyErrorToBootstrap(_ detail: String) -> Bool { + let lowered = detail.lowercased() + return lowered.contains("remote daemon transport failed") + || lowered.contains("daemon transport closed stdout") + || lowered.contains("daemon transport exited") + || lowered.contains("daemon transport is not connected") + || lowered.contains("daemon transport stopped") + } + +} diff --git a/Sources/WorkspaceShortcutMapper.swift b/Sources/WorkspaceShortcutMapper.swift new file mode 100644 index 00000000000..22ee7da2769 --- /dev/null +++ b/Sources/WorkspaceShortcutMapper.swift @@ -0,0 +1,37 @@ +import AppKit +import SwiftUI +import Bonsplit +import CoreServices +import UserNotifications +import WebKit +import Combine +import ObjectiveC.runtime +import Darwin + +enum WorkspaceShortcutMapper { + /// Maps numbered workspace shortcuts to a zero-based workspace index. + /// 1...8 target fixed indices; 9 always targets the last workspace. + static func workspaceIndex(forDigit digit: Int, workspaceCount: Int) -> Int? { + guard workspaceCount > 0 else { return nil } + guard (1...9).contains(digit) else { return nil } + + if digit == 9 { + return workspaceCount - 1 + } + + let index = digit - 1 + return index < workspaceCount ? index : nil + } + + /// Returns the primary digit badge to display for a workspace row. + /// Picks the lowest digit that maps to that row index. + static func digitForWorkspace(at index: Int, workspaceCount: Int) -> Int? { + guard index >= 0 && index < workspaceCount else { return nil } + for digit in 1...9 { + if workspaceIndex(forDigit: digit, workspaceCount: workspaceCount) == index { + return digit + } + } + return nil + } +}