diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 3b619d06..6360a77f 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -18,6 +18,14 @@ final class WorktreeTerminalState { let isFocused: Bool } + struct SurfaceRenderContext: Equatable { + let isSelectedTab: Bool + let windowIsVisible: Bool + let windowIsKey: Bool + let focusedSurfaceID: UUID? + let firstResponderSurfaceID: UUID? + } + let tabManager: TerminalTabManager private let runtime: GhosttyRuntime private let worktree: Worktree @@ -46,6 +54,15 @@ final class WorktreeTerminalState { var hasUnseenNotification: Bool { notifications.contains { !$0.isRead } } + var surfaceIDs: Set { + Set(surfaces.keys) + } + var focusFollowsMouseEnabled: Bool { + runtime.focusFollowsMouse() + } + var visibleSurfaceIDUnderMouse: UUID? { + selectedVisibleSurfaceIDUnderMouse() + } #if DEBUG var debugRecentHookCount: Int { recentHookBySurfaceID.count @@ -316,42 +333,92 @@ final class WorktreeTerminalState { private func applySurfaceActivity() { let selectedTabId = tabManager.selectedTabId - var surfaceToFocus: GhosttySurfaceView? + let firstResponderSurfaceID = currentFirstResponderSurfaceID() + let windowIsVisible = lastWindowIsVisible == true + let windowIsKey = lastWindowIsKey == true for (tabId, tree) in trees { - let focusedId = focusedSurfaceIdByTab[tabId] - let isSelectedTab = (tabId == selectedTabId) let visibleSurfaceIDs = Set(tree.visibleLeaves().map(\.id)) + let renderContext = SurfaceRenderContext( + isSelectedTab: tabId == selectedTabId, + windowIsVisible: windowIsVisible, + windowIsKey: windowIsKey, + focusedSurfaceID: focusedSurfaceIdByTab[tabId], + firstResponderSurfaceID: firstResponderSurfaceID + ) for surface in tree.leaves() { let activity = Self.surfaceActivity( isSurfaceVisibleInTree: visibleSurfaceIDs.contains(surface.id), - isSelectedTab: isSelectedTab, - windowIsVisible: lastWindowIsVisible == true, - windowIsKey: lastWindowIsKey == true, - focusedSurfaceID: focusedId, + renderContext: renderContext, surfaceID: surface.id ) surface.setOcclusion(activity.isVisible) surface.focusDidChange(activity.isFocused) - if activity.isFocused { - surfaceToFocus = surface - } } } - if let surfaceToFocus, surfaceToFocus.window?.firstResponder is GhosttySurfaceView { - surfaceToFocus.window?.makeFirstResponder(surfaceToFocus) + } + + private func currentFirstResponderSurfaceID() -> UUID? { + let selectedWindow: NSWindow? + if let selectedTabId = tabManager.selectedTabId { + guard let selectedTree = trees[selectedTabId] else { + layoutLogger.warning( + "Missing split tree for selected tab \(selectedTabId.rawValue) in worktree \(worktree.id)" + ) + let window = surfaces.values.compactMap(\.window).first + return (window?.firstResponder as? GhosttySurfaceView)?.id + } + selectedWindow = selectedTree.leaves().compactMap(\.window).first + } else { + selectedWindow = nil + } + let window = selectedWindow ?? surfaces.values.compactMap(\.window).first + return (window?.firstResponder as? GhosttySurfaceView)?.id + } + + private func selectedVisibleSurfaceIDUnderMouse() -> UUID? { + guard focusFollowsMouseEnabled, + let selectedTabId = tabManager.selectedTabId, + let tree = trees[selectedTabId] + else { + return nil } + + let visibleLeaves = tree.visibleLeaves() + guard !visibleLeaves.isEmpty else { return nil } + + let window = visibleLeaves.compactMap(\.window).first ?? NSApp.keyWindow + guard let window, let contentView = window.contentView else { return nil } + + let locationInContent = contentView.convert(window.mouseLocationOutsideOfEventStream, from: nil) + var hitView = contentView.hitTest(locationInContent) + let visibleSurfaceIDs = Set(visibleLeaves.map(\.id)) + + while let currentView = hitView { + if let surface = currentView as? GhosttySurfaceView, + visibleSurfaceIDs.contains(surface.id) + { + return surface.id + } + hitView = currentView.superview + } + + return nil } static func surfaceActivity( isSurfaceVisibleInTree: Bool = true, - isSelectedTab: Bool, - windowIsVisible: Bool, - windowIsKey: Bool, - focusedSurfaceID: UUID?, + renderContext: SurfaceRenderContext, surfaceID: UUID ) -> SurfaceActivity { - let isVisible = isSurfaceVisibleInTree && isSelectedTab && windowIsVisible - let isFocused = isVisible && windowIsKey && focusedSurfaceID == surfaceID + let isVisible = + isSurfaceVisibleInTree + && renderContext.isSelectedTab + && renderContext.windowIsVisible + let isFocused = + isVisible + && renderContext.windowIsKey + && renderContext.focusedSurfaceID == surfaceID + && renderContext.firstResponderSurfaceID == surfaceID return SurfaceActivity(isVisible: isVisible, isFocused: isFocused) } @@ -1027,6 +1094,7 @@ final class WorktreeTerminalState { view.onFocusChange = { [weak self, weak view] focused in guard let self, let view, focused else { return } self.focusedSurfaceIdByTab[tabId] = view.id + self.syncFocusIfNeeded() self.markNotificationsRead(forSurfaceID: view.id) self.updateTabTitle(for: tabId) self.emitFocusChangedIfNeeded(view.id) diff --git a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift index e32a1d17..ed856dcd 100644 --- a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift +++ b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift @@ -2,6 +2,12 @@ import AppKit import SwiftUI struct WorktreeTerminalTabsView: View { + enum FocusTarget: Equatable { + case hoveredSurface(UUID) + case selectedTab + case none + } + let worktree: Worktree let manager: WorktreeTerminalManager let shouldRunSetupScript: Bool @@ -57,27 +63,72 @@ struct WorktreeTerminalTabsView: View { ) .onAppear { state.ensureInitialTab(focusing: false) - if shouldAutoFocusTerminal { - state.focusSelectedTab() - } + applyKeyboardFocus(for: state) let activity = resolvedWindowActivity state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) } .onChange(of: state.tabManager.selectedTabId) { _, _ in - if shouldAutoFocusTerminal { - state.focusSelectedTab() - } + applyKeyboardFocus(for: state) let activity = resolvedWindowActivity state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) } } - private var shouldAutoFocusTerminal: Bool { + private func applyKeyboardFocus(for state: WorktreeTerminalState) { + apply(focusTarget(for: state), to: state) + + guard state.focusFollowsMouseEnabled else { return } + Task { @MainActor in + await Task.yield() + guard state.isSelected() else { return } + if case .hoveredSurface(let surfaceID) = focusTarget(for: state) { + _ = state.focusSurface(id: surfaceID) + } + } + } + + private func apply(_ focusTarget: FocusTarget, to state: WorktreeTerminalState) { + switch focusTarget { + case .hoveredSurface(let surfaceID): + _ = state.focusSurface(id: surfaceID) + case .selectedTab: + state.focusSelectedTab() + case .none: + break + } + } + + private func focusTarget(for state: WorktreeTerminalState) -> FocusTarget { + Self.focusTarget( + forceAutoFocus: forceAutoFocus, + responder: NSApp.keyWindow?.firstResponder, + ownedSurfaceIDs: state.surfaceIDs, + focusFollowsMouseEnabled: state.focusFollowsMouseEnabled, + hoveredSurfaceID: state.visibleSurfaceIDUnderMouse + ) + } + + static func focusTarget( + forceAutoFocus: Bool, + responder: NSResponder?, + ownedSurfaceIDs: Set, + focusFollowsMouseEnabled: Bool, + hoveredSurfaceID: UUID? + ) -> FocusTarget { + if focusFollowsMouseEnabled, let hoveredSurfaceID { + return .hoveredSurface(hoveredSurfaceID) + } if forceAutoFocus { - return true + return .selectedTab + } + guard let responder else { return .selectedTab } + if responder is NSTableView || responder is NSOutlineView { + return .none + } + guard let surface = responder as? GhosttySurfaceView else { + return .selectedTab } - guard let responder = NSApp.keyWindow?.firstResponder else { return true } - return !(responder is NSTableView) && !(responder is NSOutlineView) + return ownedSurfaceIDs.contains(surface.id) ? .selectedTab : .none } private var resolvedWindowActivity: WindowActivityState { diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index 230de30f..76a9c56c 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -654,6 +654,9 @@ final class GhosttySurfaceView: NSView, Identifiable { override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) sendMousePosition(event) + if let window, window.isKeyWindow, !focused, runtime.focusFollowsMouse() { + requestFocus() + } } override func mouseExited(with event: NSEvent) { diff --git a/supacodeTests/TerminalLayoutSnapshotTests.swift b/supacodeTests/TerminalLayoutSnapshotTests.swift index 1bfe8600..da5c54fa 100644 --- a/supacodeTests/TerminalLayoutSnapshotTests.swift +++ b/supacodeTests/TerminalLayoutSnapshotTests.swift @@ -86,7 +86,7 @@ struct TerminalLayoutSnapshotTests { tintColor: nil, layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(workingDirectory: "/home")), focusedLeafIndex: 0 - ), + ) ], selectedTabIndex: 0 ) diff --git a/supacodeTests/TerminalRenderingPolicyTests.swift b/supacodeTests/TerminalRenderingPolicyTests.swift index 06357bc2..32f47796 100644 --- a/supacodeTests/TerminalRenderingPolicyTests.swift +++ b/supacodeTests/TerminalRenderingPolicyTests.swift @@ -1,3 +1,5 @@ +import AppKit +import GhosttyKit import SwiftUI import Testing @@ -5,14 +7,30 @@ import Testing @MainActor struct TerminalRenderingPolicyTests { + private func renderContext( + isSelectedTab: Bool = true, + windowIsVisible: Bool = true, + windowIsKey: Bool = true, + focusedSurfaceID: UUID?, + firstResponderSurfaceID: UUID? + ) -> WorktreeTerminalState.SurfaceRenderContext { + WorktreeTerminalState.SurfaceRenderContext( + isSelectedTab: isSelectedTab, + windowIsVisible: windowIsVisible, + windowIsKey: windowIsKey, + focusedSurfaceID: focusedSurfaceID, + firstResponderSurfaceID: firstResponderSurfaceID + ) + } + @Test func surfaceActivityForSelectedVisibleFocusedSurfaceIsFocused() { let focusedID = UUID() let activity = WorktreeTerminalState.surfaceActivity( isSurfaceVisibleInTree: true, - isSelectedTab: true, - windowIsVisible: true, - windowIsKey: true, - focusedSurfaceID: focusedID, + renderContext: renderContext( + focusedSurfaceID: focusedID, + firstResponderSurfaceID: focusedID + ), surfaceID: focusedID ) #expect(activity.isVisible) @@ -22,10 +40,10 @@ struct TerminalRenderingPolicyTests { @Test func surfaceActivityForSelectedVisibleUnfocusedSurfaceIsNotFocused() { let activity = WorktreeTerminalState.surfaceActivity( isSurfaceVisibleInTree: true, - isSelectedTab: true, - windowIsVisible: true, - windowIsKey: true, - focusedSurfaceID: UUID(), + renderContext: renderContext( + focusedSurfaceID: UUID(), + firstResponderSurfaceID: UUID() + ), surfaceID: UUID() ) #expect(activity.isVisible) @@ -36,10 +54,11 @@ struct TerminalRenderingPolicyTests { let surfaceID = UUID() let activity = WorktreeTerminalState.surfaceActivity( isSurfaceVisibleInTree: true, - isSelectedTab: true, - windowIsVisible: true, - windowIsKey: false, - focusedSurfaceID: surfaceID, + renderContext: renderContext( + windowIsKey: false, + focusedSurfaceID: surfaceID, + firstResponderSurfaceID: surfaceID + ), surfaceID: surfaceID ) #expect(activity.isVisible) @@ -50,10 +69,11 @@ struct TerminalRenderingPolicyTests { let surfaceID = UUID() let activity = WorktreeTerminalState.surfaceActivity( isSurfaceVisibleInTree: true, - isSelectedTab: true, - windowIsVisible: false, - windowIsKey: true, - focusedSurfaceID: surfaceID, + renderContext: renderContext( + windowIsVisible: false, + focusedSurfaceID: surfaceID, + firstResponderSurfaceID: surfaceID + ), surfaceID: surfaceID ) #expect(!activity.isVisible) @@ -64,10 +84,11 @@ struct TerminalRenderingPolicyTests { let surfaceID = UUID() let activity = WorktreeTerminalState.surfaceActivity( isSurfaceVisibleInTree: true, - isSelectedTab: false, - windowIsVisible: true, - windowIsKey: true, - focusedSurfaceID: surfaceID, + renderContext: renderContext( + isSelectedTab: false, + focusedSurfaceID: surfaceID, + firstResponderSurfaceID: surfaceID + ), surfaceID: surfaceID ) #expect(!activity.isVisible) @@ -78,16 +99,139 @@ struct TerminalRenderingPolicyTests { let surfaceID = UUID() let activity = WorktreeTerminalState.surfaceActivity( isSurfaceVisibleInTree: false, - isSelectedTab: true, - windowIsVisible: true, - windowIsKey: true, - focusedSurfaceID: surfaceID, + renderContext: renderContext( + focusedSurfaceID: surfaceID, + firstResponderSurfaceID: surfaceID + ), surfaceID: surfaceID ) #expect(!activity.isVisible) #expect(!activity.isFocused) } + @Test func surfaceActivityRequiresTerminalFirstResponderToMatchFocusIntent() { + let intendedSurfaceID = UUID() + let activity = WorktreeTerminalState.surfaceActivity( + isSurfaceVisibleInTree: true, + renderContext: renderContext( + focusedSurfaceID: intendedSurfaceID, + firstResponderSurfaceID: nil + ), + surfaceID: intendedSurfaceID + ) + + #expect(activity.isVisible) + #expect(!activity.isFocused) + } + + @Test func surfaceActivityDoesNotFocusWhenSiblingPaneOwnsFirstResponder() { + let intendedSurfaceID = UUID() + let siblingSurfaceID = UUID() + let activity = WorktreeTerminalState.surfaceActivity( + isSurfaceVisibleInTree: true, + renderContext: renderContext( + focusedSurfaceID: intendedSurfaceID, + firstResponderSurfaceID: siblingSurfaceID + ), + surfaceID: intendedSurfaceID + ) + + #expect(activity.isVisible) + #expect(!activity.isFocused) + } + + @Test func worktreeSwitchWithoutTerminalAutoFocusKeepsAllVisiblePanesUnfocused() { + let intendedSurfaceID = UUID() + let siblingSurfaceID = UUID() + + let intendedActivity = WorktreeTerminalState.surfaceActivity( + isSurfaceVisibleInTree: true, + renderContext: renderContext( + focusedSurfaceID: intendedSurfaceID, + firstResponderSurfaceID: nil + ), + surfaceID: intendedSurfaceID + ) + let siblingActivity = WorktreeTerminalState.surfaceActivity( + isSurfaceVisibleInTree: true, + renderContext: renderContext( + focusedSurfaceID: intendedSurfaceID, + firstResponderSurfaceID: nil + ), + surfaceID: siblingSurfaceID + ) + + #expect(intendedActivity.isVisible) + #expect(!intendedActivity.isFocused) + #expect(siblingActivity.isVisible) + #expect(!siblingActivity.isFocused) + } + + @Test func worktreeSwitchDoesNotAutoFocusFromDifferentWorktreeTerminalResponder() { + let currentSurfaceID = UUID() + let foreignSurface = GhosttySurfaceView( + id: UUID(), + runtime: GhosttyRuntime(), + workingDirectory: nil, + context: GHOSTTY_SURFACE_CONTEXT_TAB + ) + + let focusTarget = WorktreeTerminalTabsView.focusTarget( + forceAutoFocus: false, + responder: foreignSurface, + ownedSurfaceIDs: [currentSurfaceID], + focusFollowsMouseEnabled: false, + hoveredSurfaceID: nil + ) + + #expect(focusTarget == .none) + } + + @Test func currentWorktreeTerminalResponderPreservesAutoFocusOnTabSwitch() { + let currentSurfaceID = UUID() + let currentSurface = GhosttySurfaceView( + id: currentSurfaceID, + runtime: GhosttyRuntime(), + workingDirectory: nil, + context: GHOSTTY_SURFACE_CONTEXT_TAB + ) + + let focusTarget = WorktreeTerminalTabsView.focusTarget( + forceAutoFocus: false, + responder: currentSurface, + ownedSurfaceIDs: [currentSurfaceID], + focusFollowsMouseEnabled: false, + hoveredSurfaceID: nil + ) + + #expect(focusTarget == .selectedTab) + } + + @Test func sidebarSelectionTransfersFocusToHoveredPaneWhenMouseFocusIsEnabled() { + let hoveredSurfaceID = UUID() + let focusTarget = WorktreeTerminalTabsView.focusTarget( + forceAutoFocus: false, + responder: NSTableView(), + ownedSurfaceIDs: [hoveredSurfaceID], + focusFollowsMouseEnabled: true, + hoveredSurfaceID: hoveredSurfaceID + ) + + #expect(focusTarget == .hoveredSurface(hoveredSurfaceID)) + } + + @Test func sidebarSelectionKeepsKeyboardFocusWhenMouseFocusIsDisabled() { + let focusTarget = WorktreeTerminalTabsView.focusTarget( + forceAutoFocus: false, + responder: NSTableView(), + ownedSurfaceIDs: [UUID()], + focusFollowsMouseEnabled: false, + hoveredSurfaceID: UUID() + ) + + #expect(focusTarget == .none) + } + @Test func tabContentStackReturnsSelectedTabWhenItExists() { let selected = TerminalTabID() let tabs = [ diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index 3c037fbd..56d265f9 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -1,3 +1,4 @@ +import AppKit import Dependencies import Foundation import Testing @@ -722,6 +723,56 @@ struct WorktreeTerminalManagerTests { #expect(state.tabManager.selectedTabId == selectedBefore) } + @Test func focusChangedEventReconcilesResponderDrivenPaneSwitch() async { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + state.pendingLayoutSnapshot = makeTwoPaneLayoutSnapshot() + state.ensureInitialTab(focusing: false) + + let stream = manager.eventStream() + + guard let tabId = state.tabManager.selectedTabId else { + Issue.record("Expected selected tab") + return + } + + let leaves = state.splitTree(for: tabId).visibleLeaves() + guard leaves.count == 2 else { + Issue.record("Expected two visible panes") + return + } + + let firstPane = leaves[0] + let secondPane = leaves[1] + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentView?.bounds ?? .zero) + container.autoresizingMask = [.width, .height] + window.contentView = container + firstPane.frame = NSRect(x: 0, y: 0, width: 400, height: 600) + secondPane.frame = NSRect(x: 400, y: 0, width: 400, height: 600) + container.addSubview(firstPane) + container.addSubview(secondPane) + + #expect(window.makeFirstResponder(firstPane)) + state.syncFocus(windowIsKey: true, windowIsVisible: true) + + #expect(window.makeFirstResponder(secondPane)) + + let event = await nextEvent(stream) { event in + event == .focusChanged(worktreeID: worktree.id, surfaceID: secondPane.id) + } + + #expect(event == .focusChanged(worktreeID: worktree.id, surfaceID: secondPane.id)) + #expect((window.firstResponder as? GhosttySurfaceView) === secondPane) + #expect(state.captureLayoutSnapshot()?.tabs.first?.focusedLeafIndex == 1) + } + private func makeWorktree(id: String = "/tmp/repo/wt-1") -> Worktree { let name = URL(fileURLWithPath: id).lastPathComponent return Worktree( @@ -768,7 +819,37 @@ struct WorktreeTerminalManagerTests { ) ), focusedLeafIndex: 0 - ), + ) + ], + selectedTabIndex: 0 + ) + } + + private func makeTwoPaneLayoutSnapshot() -> TerminalLayoutSnapshot { + TerminalLayoutSnapshot( + tabs: [ + TerminalLayoutSnapshot.TabSnapshot( + title: "Terminal 1", + icon: nil, + tintColor: nil, + layout: .split( + TerminalLayoutSnapshot.SplitSnapshot( + direction: .horizontal, + ratio: 0.5, + left: .leaf( + TerminalLayoutSnapshot.SurfaceSnapshot( + workingDirectory: "/tmp/repo/wt-1" + ) + ), + right: .leaf( + TerminalLayoutSnapshot.SurfaceSnapshot( + workingDirectory: "/tmp/repo/wt-1" + ) + ) + ) + ), + focusedLeafIndex: 0 + ) ], selectedTabIndex: 0 )