diff --git a/supacode/Features/Terminal/Models/SplitTree.swift b/supacode/Features/Terminal/Models/SplitTree.swift index 9c1823df..7deac38d 100644 --- a/supacode/Features/Terminal/Models/SplitTree.swift +++ b/supacode/Features/Terminal/Models/SplitTree.swift @@ -74,6 +74,10 @@ struct SplitTree { if case .split = root { true } else { false } } + var visibleNode: Node? { + zoomed ?? root + } + init() { self.init(root: nil, zoomed: nil) } @@ -267,6 +271,10 @@ struct SplitTree { root?.leaves() ?? [] } + func visibleLeaves() -> [ViewType] { + visibleNode?.leaves() ?? [] + } + var structuralIdentity: StructuralIdentity { StructuralIdentity(self) } diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index ec444576..e8a60fd4 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -31,6 +31,8 @@ final class WorktreeTerminalState { private var isEnsuringInitialTab = false private var lastReportedTaskStatus: WorktreeTaskStatus? private var lastEmittedFocusSurfaceId: UUID? + private var lastWindowIsKey: Bool? + private var lastWindowIsVisible: Bool? var notifications: [WorktreeTerminalNotification] = [] var notificationsEnabled = true private var commandFinishedNotificationEnabled = true @@ -278,16 +280,24 @@ final class WorktreeTerminalState { } func syncFocus(windowIsKey: Bool, windowIsVisible: Bool) { + lastWindowIsKey = windowIsKey + lastWindowIsVisible = windowIsVisible + applySurfaceActivity() + } + + private func applySurfaceActivity() { let selectedTabId = tabManager.selectedTabId var surfaceToFocus: GhosttySurfaceView? for (tabId, tree) in trees { let focusedId = focusedSurfaceIdByTab[tabId] let isSelectedTab = (tabId == selectedTabId) + let visibleSurfaceIDs = Set(tree.visibleLeaves().map(\.id)) for surface in tree.leaves() { let activity = Self.surfaceActivity( + isSurfaceVisibleInTree: visibleSurfaceIDs.contains(surface.id), isSelectedTab: isSelectedTab, - windowIsVisible: windowIsVisible, - windowIsKey: windowIsKey, + windowIsVisible: lastWindowIsVisible == true, + windowIsKey: lastWindowIsKey == true, focusedSurfaceID: focusedId, surfaceID: surface.id ) @@ -304,13 +314,14 @@ final class WorktreeTerminalState { } static func surfaceActivity( + isSurfaceVisibleInTree: Bool = true, isSelectedTab: Bool, windowIsVisible: Bool, windowIsKey: Bool, focusedSurfaceID: UUID?, surfaceID: UUID ) -> SurfaceActivity { - let isVisible = isSelectedTab && windowIsVisible + let isVisible = isSurfaceVisibleInTree && isSelectedTab && windowIsVisible let isFocused = isVisible && windowIsKey && focusedSurfaceID == surfaceID return SurfaceActivity(isVisible: isVisible, isFocused: isFocused) } @@ -450,7 +461,7 @@ final class WorktreeTerminalState { at: targetSurface, direction: mapSplitDirection(direction) ) - trees[tabId] = newTree + updateTree(newTree, for: tabId) focusSurface(newSurface, in: tabId) return true } catch { @@ -470,6 +481,7 @@ final class WorktreeTerminalState { trees[tabId] = tree } focusSurface(nextSurface, in: tabId) + syncFocusIfNeeded() return true case .resizeSplit(let direction, let amount): @@ -481,20 +493,20 @@ final class WorktreeTerminalState { in: spatialDirection, with: CGRect(origin: .zero, size: tree.viewBounds()) ) - trees[tabId] = newTree + updateTree(newTree, for: tabId) return true } catch { return false } case .equalizeSplits: - trees[tabId] = tree.equalized() + updateTree(tree.equalized(), for: tabId) return true case .toggleSplitZoom: guard tree.isSplit else { return false } let newZoomed = (tree.zoomed == targetNode) ? nil : targetNode - trees[tabId] = tree.settingZoomed(newZoomed) + updateTree(tree.settingZoomed(newZoomed), for: tabId) return true } } @@ -507,7 +519,7 @@ final class WorktreeTerminalState { let resizedNode = node.resizing(to: ratio) do { tree = try tree.replacing(node: node, with: resizedNode) - trees[tabId] = tree + updateTree(tree, for: tabId) } catch { return } @@ -525,14 +537,14 @@ final class WorktreeTerminalState { at: destination, direction: mapDropZone(zone) ) - trees[tabId] = newTree + updateTree(newTree, for: tabId) focusSurface(payload, in: tabId) } catch { return } case .equalize: - trees[tabId] = tree.equalized() + updateTree(tree.equalized(), for: tabId) } } @@ -1035,7 +1047,7 @@ final class WorktreeTerminalState { return } let tree = splitTree(for: tabId) - if let surface = tree.root?.leftmostLeaf() { + if let surface = tree.visibleLeaves().first { focusSurface(surface, in: tabId) } } @@ -1271,6 +1283,16 @@ final class WorktreeTerminalState { } } + private func syncFocusIfNeeded() { + guard lastWindowIsKey != nil, lastWindowIsVisible != nil else { return } + applySurfaceActivity() + } + + private func updateTree(_ tree: SplitTree, for tabId: TerminalTabID) { + trees[tabId] = tree + syncFocusIfNeeded() + } + private func isRunningProgressState(_ state: ghostty_action_progress_report_state_e?) -> Bool { switch state { case .some(GHOSTTY_PROGRESS_STATE_SET), @@ -1363,7 +1385,7 @@ final class WorktreeTerminalState { } return } - trees[tabId] = newTree + updateTree(newTree, for: tabId) updateRunningState(for: tabId) if focusedSurfaceIdByTab[tabId] == view.id { if let nextSurface { diff --git a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift index 23322562..27adfb98 100644 --- a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift +++ b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift @@ -22,7 +22,7 @@ struct TerminalSplitTreeView: View { } var body: some View { - if let node = tree.zoomed ?? tree.root { + if let node = tree.visibleNode { SubtreeView(node: node, isRoot: node == tree.root, pinnedSize: pinnedSize, action: action) .id(node.structuralIdentity) } @@ -306,11 +306,9 @@ struct TerminalSplitTreeAXContainer: NSViewRepresentable { } func updateNSView(_ nsView: TerminalSplitAXContainerView, context: Context) { - let visibleNode = tree.zoomed ?? tree.root - let visiblePanes = visibleNode?.leaves() ?? [] nsView.update( rootView: AnyView(TerminalSplitTreeView(tree: tree, action: action)), - panes: visiblePanes + panes: tree.visibleLeaves() ) } } diff --git a/supacodeTests/SplitTreeTests.swift b/supacodeTests/SplitTreeTests.swift index cdab2f19..7055048d 100644 --- a/supacodeTests/SplitTreeTests.swift +++ b/supacodeTests/SplitTreeTests.swift @@ -30,6 +30,20 @@ struct SplitTreeTests { let node = try #require(tree.find(id: third.id)) #expect(tree.focusTargetAfterClosing(node) === second) } + + @Test func visibleLeavesOnlyReturnZoomedPane() throws { + let first = SplitTreeTestView() + let second = SplitTreeTestView() + + let tree = try SplitTree(view: first) + .inserting(view: second, at: first, direction: .right) + + let zoomed = tree.settingZoomed(try #require(tree.find(id: second.id))) + let visibleLeaves = zoomed.visibleLeaves() + + #expect(visibleLeaves.count == 1) + #expect(visibleLeaves.first === second) + } } private final class SplitTreeTestView: NSView, Identifiable { diff --git a/supacodeTests/TerminalRenderingPolicyTests.swift b/supacodeTests/TerminalRenderingPolicyTests.swift index 6e0514cb..06357bc2 100644 --- a/supacodeTests/TerminalRenderingPolicyTests.swift +++ b/supacodeTests/TerminalRenderingPolicyTests.swift @@ -8,6 +8,7 @@ struct TerminalRenderingPolicyTests { @Test func surfaceActivityForSelectedVisibleFocusedSurfaceIsFocused() { let focusedID = UUID() let activity = WorktreeTerminalState.surfaceActivity( + isSurfaceVisibleInTree: true, isSelectedTab: true, windowIsVisible: true, windowIsKey: true, @@ -20,6 +21,7 @@ struct TerminalRenderingPolicyTests { @Test func surfaceActivityForSelectedVisibleUnfocusedSurfaceIsNotFocused() { let activity = WorktreeTerminalState.surfaceActivity( + isSurfaceVisibleInTree: true, isSelectedTab: true, windowIsVisible: true, windowIsKey: true, @@ -33,6 +35,7 @@ struct TerminalRenderingPolicyTests { @Test func surfaceActivityForSelectedTabInBackgroundWindowIsVisibleButNotFocused() { let surfaceID = UUID() let activity = WorktreeTerminalState.surfaceActivity( + isSurfaceVisibleInTree: true, isSelectedTab: true, windowIsVisible: true, windowIsKey: false, @@ -46,6 +49,7 @@ struct TerminalRenderingPolicyTests { @Test func surfaceActivityForOccludedWindowIsHiddenAndUnfocused() { let surfaceID = UUID() let activity = WorktreeTerminalState.surfaceActivity( + isSurfaceVisibleInTree: true, isSelectedTab: true, windowIsVisible: false, windowIsKey: true, @@ -59,6 +63,7 @@ struct TerminalRenderingPolicyTests { @Test func surfaceActivityForUnselectedTabIsHiddenAndUnfocused() { let surfaceID = UUID() let activity = WorktreeTerminalState.surfaceActivity( + isSurfaceVisibleInTree: true, isSelectedTab: false, windowIsVisible: true, windowIsKey: true, @@ -69,6 +74,20 @@ struct TerminalRenderingPolicyTests { #expect(!activity.isFocused) } + @Test func surfaceActivityForZoomHiddenSurfaceIsHiddenAndUnfocused() { + let surfaceID = UUID() + let activity = WorktreeTerminalState.surfaceActivity( + isSurfaceVisibleInTree: false, + isSelectedTab: true, + windowIsVisible: true, + windowIsKey: true, + focusedSurfaceID: surfaceID, + surfaceID: surfaceID + ) + #expect(!activity.isVisible) + #expect(!activity.isFocused) + } + @Test func tabContentStackReturnsSelectedTabWhenItExists() { let selected = TerminalTabID() let tabs = [