diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index 4c03a34f..f501419f 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -107,8 +107,10 @@ struct ContentView: View { } ) } - .focusedSceneValue(\.toggleLeftSidebarAction, toggleLeftSidebar) - .focusedSceneValue(\.revealInSidebarAction, revealInSidebarAction) + .focusedSceneAction(\.toggleLeftSidebarAction, enabled: true) { + toggleLeftSidebar() + } + .focusedSceneValue(\.revealInSidebarAction, revealInSidebarAction.asFocusedAction()) .overlay { CommandPaletteOverlayView( store: store.scope(state: \.commandPalette, action: \.commandPalette), diff --git a/supacode/App/Models/FocusedAction.swift b/supacode/App/Models/FocusedAction.swift new file mode 100644 index 00000000..b1381836 --- /dev/null +++ b/supacode/App/Models/FocusedAction.swift @@ -0,0 +1,89 @@ +import SwiftUI + +/// Equatable wrapper around a focused-value action closure. +/// +/// SwiftUI's `focusedSceneValue` / `focusedValue` re-publishes whenever the +/// stored value's identity changes. A bare `() -> Void` closure has no +/// Equatable conformance, so every publisher-view body run looks like a +/// "value changed" event to AppKit, which then rebuilds the system menu and +/// drops open-submenu / hover state. During agent activity the detail view's +/// body runs on every OSC-9 progress tick, so the menu bar rebuilds +/// continuously and becomes hard to use. Wrapping the closure in this +/// Equatable adapter keeps the focused value stable across no-op body runs. +/// +/// **Contract**: `token` must hash any captured state that affects the +/// closure's behavior. If the closure captures only stable references +/// (the store, the terminal manager), `token` can stay `nil`. If it captures +/// the selected worktree, a list of targets, an alert payload, etc., set +/// `token` to a hashable projection of those values so a real change triggers +/// a republish. +struct FocusedAction: Equatable { + let isEnabled: Bool + let token: AnyHashable? + private let perform: (Input) -> Void + + init( + isEnabled: Bool, + token: AnyHashable? = nil, + perform: @escaping (Input) -> Void + ) { + self.isEnabled = isEnabled + self.token = token + self.perform = perform + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.isEnabled == rhs.isEnabled && lhs.token == rhs.token + } + + func callAsFunction(_ input: Input) { + guard isEnabled else { return } + perform(input) + } +} + +extension FocusedAction where Input == Void { + func callAsFunction() { + callAsFunction(()) + } +} + +extension Optional { + /// Wraps an optional `() -> Void` closure into a `FocusedAction`, preserving + /// the disabled-as-`nil` convention the fork already uses for focused values: + /// a `nil` closure stays `nil` (disabled), a non-`nil` closure becomes an + /// enabled `FocusedAction` carrying `token`. + func asFocusedAction(token: AnyHashable? = nil) -> FocusedAction? where Wrapped == () -> Void { + map { FocusedAction(isEnabled: true, token: token, perform: $0) } + } +} + +extension View { + /// Publishes a stable `FocusedAction` through `focusedSceneValue`. + /// Prefer this over a raw closure: AppKit only sees a "value changed" + /// event when `enabled` or `token` flip, instead of on every body run. + func focusedSceneAction( + _ keyPath: WritableKeyPath?>, + enabled: Bool, + token: AnyHashable? = nil, + perform: @escaping (Input) -> Void + ) -> some View { + focusedSceneValue( + keyPath, + FocusedAction(isEnabled: enabled, token: token, perform: perform) + ) + } + + /// `focusedValue` variant. Same contract as `focusedSceneAction`. + func focusedAction( + _ keyPath: WritableKeyPath?>, + enabled: Bool, + token: AnyHashable? = nil, + perform: @escaping (Input) -> Void + ) -> some View { + focusedValue( + keyPath, + FocusedAction(isEnabled: enabled, token: token, perform: perform) + ) + } +} diff --git a/supacode/Commands/SidebarCommands.swift b/supacode/Commands/SidebarCommands.swift index 01727d70..e164d686 100644 --- a/supacode/Commands/SidebarCommands.swift +++ b/supacode/Commands/SidebarCommands.swift @@ -113,20 +113,20 @@ struct SidebarCommands: Commands { } private struct ToggleLeftSidebarActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } private struct RevealInSidebarActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var toggleLeftSidebarAction: (() -> Void)? { + var toggleLeftSidebarAction: FocusedAction? { get { self[ToggleLeftSidebarActionKey.self] } set { self[ToggleLeftSidebarActionKey.self] = newValue } } - var revealInSidebarAction: (() -> Void)? { + var revealInSidebarAction: FocusedAction? { get { self[RevealInSidebarActionKey.self] } set { self[RevealInSidebarActionKey.self] = newValue } } diff --git a/supacode/Commands/TerminalCommands.swift b/supacode/Commands/TerminalCommands.swift index 225b19e4..1fa4a41d 100644 --- a/supacode/Commands/TerminalCommands.swift +++ b/supacode/Commands/TerminalCommands.swift @@ -113,121 +113,121 @@ struct TerminalCommands: Commands { } private struct NewTerminalActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var newTerminalAction: (() -> Void)? { + var newTerminalAction: FocusedAction? { get { self[NewTerminalActionKey.self] } set { self[NewTerminalActionKey.self] = newValue } } } private struct CloseSurfaceActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var closeSurfaceAction: (() -> Void)? { + var closeSurfaceAction: FocusedAction? { get { self[CloseSurfaceActionKey.self] } set { self[CloseSurfaceActionKey.self] = newValue } } } private struct CloseTabActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var closeTabAction: (() -> Void)? { + var closeTabAction: FocusedAction? { get { self[CloseTabActionKey.self] } set { self[CloseTabActionKey.self] = newValue } } } private struct ResetFontSizeActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var resetFontSizeAction: (() -> Void)? { + var resetFontSizeAction: FocusedAction? { get { self[ResetFontSizeActionKey.self] } set { self[ResetFontSizeActionKey.self] = newValue } } } private struct IncreaseFontSizeActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var increaseFontSizeAction: (() -> Void)? { + var increaseFontSizeAction: FocusedAction? { get { self[IncreaseFontSizeActionKey.self] } set { self[IncreaseFontSizeActionKey.self] = newValue } } } private struct DecreaseFontSizeActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var decreaseFontSizeAction: (() -> Void)? { + var decreaseFontSizeAction: FocusedAction? { get { self[DecreaseFontSizeActionKey.self] } set { self[DecreaseFontSizeActionKey.self] = newValue } } } private struct StartSearchActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var startSearchAction: (() -> Void)? { + var startSearchAction: FocusedAction? { get { self[StartSearchActionKey.self] } set { self[StartSearchActionKey.self] = newValue } } } private struct SearchSelectionActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var searchSelectionAction: (() -> Void)? { + var searchSelectionAction: FocusedAction? { get { self[SearchSelectionActionKey.self] } set { self[SearchSelectionActionKey.self] = newValue } } } private struct NavigateSearchNextActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var navigateSearchNextAction: (() -> Void)? { + var navigateSearchNextAction: FocusedAction? { get { self[NavigateSearchNextActionKey.self] } set { self[NavigateSearchNextActionKey.self] = newValue } } } private struct NavigateSearchPreviousActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var navigateSearchPreviousAction: (() -> Void)? { + var navigateSearchPreviousAction: FocusedAction? { get { self[NavigateSearchPreviousActionKey.self] } set { self[NavigateSearchPreviousActionKey.self] = newValue } } } private struct EndSearchActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var endSearchAction: (() -> Void)? { + var endSearchAction: FocusedAction? { get { self[EndSearchActionKey.self] } set { self[EndSearchActionKey.self] = newValue } } diff --git a/supacode/Commands/WindowCommands.swift b/supacode/Commands/WindowCommands.swift index f16f94d7..9b95845d 100644 --- a/supacode/Commands/WindowCommands.swift +++ b/supacode/Commands/WindowCommands.swift @@ -190,88 +190,88 @@ struct KeyboardShortcutModifier: ViewModifier { } private struct SelectPreviousTerminalTabActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var selectPreviousTerminalTabAction: (() -> Void)? { + var selectPreviousTerminalTabAction: FocusedAction? { get { self[SelectPreviousTerminalTabActionKey.self] } set { self[SelectPreviousTerminalTabActionKey.self] = newValue } } } private struct SelectNextTerminalTabActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var selectNextTerminalTabAction: (() -> Void)? { + var selectNextTerminalTabAction: FocusedAction? { get { self[SelectNextTerminalTabActionKey.self] } set { self[SelectNextTerminalTabActionKey.self] = newValue } } } private struct SelectPreviousTerminalPaneActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var selectPreviousTerminalPaneAction: (() -> Void)? { + var selectPreviousTerminalPaneAction: FocusedAction? { get { self[SelectPreviousTerminalPaneActionKey.self] } set { self[SelectPreviousTerminalPaneActionKey.self] = newValue } } } private struct SelectNextTerminalPaneActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var selectNextTerminalPaneAction: (() -> Void)? { + var selectNextTerminalPaneAction: FocusedAction? { get { self[SelectNextTerminalPaneActionKey.self] } set { self[SelectNextTerminalPaneActionKey.self] = newValue } } } private struct SelectTerminalPaneAboveActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var selectTerminalPaneAboveAction: (() -> Void)? { + var selectTerminalPaneAboveAction: FocusedAction? { get { self[SelectTerminalPaneAboveActionKey.self] } set { self[SelectTerminalPaneAboveActionKey.self] = newValue } } } private struct SelectTerminalPaneBelowActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var selectTerminalPaneBelowAction: (() -> Void)? { + var selectTerminalPaneBelowAction: FocusedAction? { get { self[SelectTerminalPaneBelowActionKey.self] } set { self[SelectTerminalPaneBelowActionKey.self] = newValue } } } private struct SelectTerminalPaneLeftActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var selectTerminalPaneLeftAction: (() -> Void)? { + var selectTerminalPaneLeftAction: FocusedAction? { get { self[SelectTerminalPaneLeftActionKey.self] } set { self[SelectTerminalPaneLeftActionKey.self] = newValue } } } private struct SelectTerminalPaneRightActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var selectTerminalPaneRightAction: (() -> Void)? { + var selectTerminalPaneRightAction: FocusedAction? { get { self[SelectTerminalPaneRightActionKey.self] } set { self[SelectTerminalPaneRightActionKey.self] = newValue } } diff --git a/supacode/Commands/WorktreeCommands.swift b/supacode/Commands/WorktreeCommands.swift index 9e0e6a1a..1dc7dec5 100644 --- a/supacode/Commands/WorktreeCommands.swift +++ b/supacode/Commands/WorktreeCommands.swift @@ -294,48 +294,48 @@ private struct WorktreeMenuEntry: Identifiable { } private struct ArchiveWorktreeActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } private struct OpenSelectedWorktreeActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } private struct DeleteWorktreeActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } private struct ConfirmWorktreeActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var openSelectedWorktreeAction: (() -> Void)? { + var openSelectedWorktreeAction: FocusedAction? { get { self[OpenSelectedWorktreeActionKey.self] } set { self[OpenSelectedWorktreeActionKey.self] = newValue } } - var confirmWorktreeAction: (() -> Void)? { + var confirmWorktreeAction: FocusedAction? { get { self[ConfirmWorktreeActionKey.self] } set { self[ConfirmWorktreeActionKey.self] = newValue } } - var archiveWorktreeAction: (() -> Void)? { + var archiveWorktreeAction: FocusedAction? { get { self[ArchiveWorktreeActionKey.self] } set { self[ArchiveWorktreeActionKey.self] = newValue } } - var deleteWorktreeAction: (() -> Void)? { + var deleteWorktreeAction: FocusedAction? { get { self[DeleteWorktreeActionKey.self] } set { self[DeleteWorktreeActionKey.self] = newValue } } - var runScriptAction: (() -> Void)? { + var runScriptAction: FocusedAction? { get { self[RunScriptActionKey.self] } set { self[RunScriptActionKey.self] = newValue } } - var stopRunScriptAction: (() -> Void)? { + var stopRunScriptAction: FocusedAction? { get { self[StopRunScriptActionKey.self] } set { self[StopRunScriptActionKey.self] = newValue } } @@ -347,11 +347,11 @@ extension FocusedValues { } private struct RunScriptActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } private struct StopRunScriptActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } private struct VisibleHotkeyWorktreeRowsKey: FocusedValueKey { diff --git a/supacode/Features/DiffView/DiffWindowContentView.swift b/supacode/Features/DiffView/DiffWindowContentView.swift index 4554daf4..9caac76e 100644 --- a/supacode/Features/DiffView/DiffWindowContentView.swift +++ b/supacode/Features/DiffView/DiffWindowContentView.swift @@ -29,7 +29,9 @@ struct DiffWindowContentView: View { } detail: { diffDetail } - .focusedSceneValue(\.toggleLeftSidebarAction, toggleSidebar) + .focusedSceneAction(\.toggleLeftSidebarAction, enabled: true) { + toggleSidebar() + } .toolbar(id: "diffToolbar") { ToolbarItem(id: "sidebarToggle", placement: .navigation) { Button { diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift index bfd0a876..21be338f 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift @@ -425,6 +425,20 @@ extension RepositoriesFeature.State { return nil } + /// Hashable projection of `confirmWorktreeAlert`, used as a `FocusedAction` + /// token so the confirm command republishes only when the pending alert's + /// targets actually change rather than on every view body run. + var confirmWorktreeActionToken: [Worktree.ID]? { + switch confirmWorktreeAlert { + case .confirmArchiveWorktree(let worktreeID, _): + return [worktreeID] + case .confirmArchiveWorktrees(let targets): + return targets.map(\.worktreeID) + default: + return nil + } + } + func isRemovingRepository(_ repository: Repository) -> Bool { removingRepositoryIDs.contains(repository.id) } diff --git a/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift b/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift index 61d32b9a..8d4eec62 100644 --- a/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift +++ b/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift @@ -84,7 +84,7 @@ struct ArchivedWorktreesDetailView: View { } @ToolbarContentBuilder - private func toolbarContent(deleteWorktreeAction: (() -> Void)?) -> some ToolbarContent { + private func toolbarContent(deleteWorktreeAction: FocusedAction?) -> some ToolbarContent { ToolbarItem { let deleteShortcut = KeyboardShortcut(.delete, modifiers: [.command, .shift]).display Button("Delete Selected", systemImage: "trash", role: .destructive) { @@ -100,8 +100,8 @@ struct ArchivedWorktreesDetailView: View { var groupIDs: Set var archivedRowIDs: [Worktree.ID] var archivedWorktreeIDs: Set - var deleteWorktreeAction: (() -> Void)? - var confirmWorktreeAction: (() -> Void)? + var deleteWorktreeAction: FocusedAction? + var confirmWorktreeAction: FocusedAction? } private func makeSnapshot() -> ArchivedSnapshot { @@ -131,20 +131,20 @@ struct ArchivedWorktreesDetailView: View { } } - let deleteWorktreeAction: (() -> Void)? + let deleteWorktreeAction: FocusedAction? if selectedTargets.isEmpty { deleteWorktreeAction = nil } else { let store = self.store - deleteWorktreeAction = { + deleteWorktreeAction = FocusedAction(isEnabled: true, token: selectedTargets.map(\.worktreeID)) { store.send(.worktreeLifecycle(.requestDeleteWorktrees(selectedTargets))) } } - let confirmWorktreeAction: (() -> Void)? + let confirmWorktreeAction: FocusedAction? if let alert = store.state.confirmWorktreeAlert { let store = self.store - confirmWorktreeAction = { + confirmWorktreeAction = FocusedAction(isEnabled: true, token: store.state.confirmWorktreeActionToken) { store.send(.alert(.presented(alert))) } } else { diff --git a/supacode/Features/Repositories/Views/SidebarView.swift b/supacode/Features/Repositories/Views/SidebarView.swift index 35195ced..c2b73fc2 100644 --- a/supacode/Features/Repositories/Views/SidebarView.swift +++ b/supacode/Features/Repositories/Views/SidebarView.swift @@ -33,6 +33,8 @@ struct SidebarView: View { .focusedValue(\.archiveWorktreeAction, archiveWorktreeAction) .focusedValue(\.deleteWorktreeAction, deleteWorktreeAction) .focusedSceneValue(\.visibleHotkeyWorktreeRows, visibleHotkeyRows) + // `visibleHotkeyWorktreeRows` stays a plain value: `WorktreeRowModel` is + // Equatable, so SwiftUI already skips republishing it on no-op body runs. .onAppear { syncSidebarSelections(state: state, visibleWorktreeIDs: visibleWorktreeIDs) } .onChange(of: state.selection) { _, _ in syncSidebarSelections(state: state, visibleWorktreeIDs: visibleWorktreeIDs) @@ -75,16 +77,16 @@ struct SidebarView: View { private func makeConfirmWorktreeAction( state: RepositoriesFeature.State - ) -> (() -> Void)? { + ) -> FocusedAction? { guard let alert = state.confirmWorktreeAlert else { return nil } - return { + return FocusedAction(isEnabled: true, token: state.confirmWorktreeActionToken) { store.send(.alert(.presented(alert))) } } private func makeArchiveWorktreeAction( rows: [WorktreeRowModel] - ) -> (() -> Void)? { + ) -> FocusedAction? { let targets = rows .filter { $0.isRemovable && !$0.isMainWorktree && !$0.isDeleting } @@ -95,7 +97,7 @@ struct SidebarView: View { ) } guard !targets.isEmpty else { return nil } - return { + return FocusedAction(isEnabled: true, token: targets.map(\.worktreeID)) { if targets.count == 1, let target = targets.first { store.send(.worktreeLifecycle(.requestArchiveWorktree(target.worktreeID, target.repositoryID))) } else { @@ -106,7 +108,7 @@ struct SidebarView: View { private func makeDeleteWorktreeAction( rows: [WorktreeRowModel] - ) -> (() -> Void)? { + ) -> FocusedAction? { let targets = rows .filter { $0.isRemovable && !$0.isDeleting } @@ -117,7 +119,7 @@ struct SidebarView: View { ) } guard !targets.isEmpty else { return nil } - return { + return FocusedAction(isEnabled: true, token: targets.map(\.worktreeID)) { if targets.count == 1, let target = targets.first { store.send(.worktreeLifecycle(.requestDeleteWorktree(target.worktreeID, target.repositoryID))) } else { diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 4240533d..33a12150 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -142,7 +142,12 @@ struct WorktreeDetailView: View { runScriptEnabled: runScriptEnabled, runScriptIsRunning: runScriptIsRunning ) - return applyFocusedActions(content: content, actions: actions) + let actionToken = WorktreeActionContext( + selectedWorktreeID: selectedTerminalWorktree?.id, + isShowingCanvas: repositories.isShowingCanvas, + canvasFocusedWorktreeID: repositories.isShowingCanvas ? terminalManager.canvasFocusedWorktreeID : nil + ) + return applyFocusedActions(content: content, actions: actions, token: actionToken) } @ToolbarContentBuilder @@ -530,31 +535,40 @@ struct WorktreeDetailView: View { private func applyFocusedActions( content: Content, - actions: FocusedActions + actions: FocusedActions, + token: WorktreeActionContext ) -> some View { content - .focusedSceneValue(\.openSelectedWorktreeAction, actions.openSelectedWorktree) - .focusedSceneValue(\.newTerminalAction, actions.newTerminal) - .focusedSceneValue(\.closeTabAction, actions.closeTab) - .focusedSceneValue(\.closeSurfaceAction, actions.closeSurface) - .focusedSceneValue(\.resetFontSizeAction, actions.resetFontSize) - .focusedSceneValue(\.increaseFontSizeAction, actions.increaseFontSize) - .focusedSceneValue(\.decreaseFontSizeAction, actions.decreaseFontSize) - .focusedSceneValue(\.startSearchAction, actions.startSearch) - .focusedSceneValue(\.searchSelectionAction, actions.searchSelection) - .focusedSceneValue(\.navigateSearchNextAction, actions.navigateSearchNext) - .focusedSceneValue(\.navigateSearchPreviousAction, actions.navigateSearchPrevious) - .focusedSceneValue(\.endSearchAction, actions.endSearch) - .focusedSceneValue(\.selectPreviousTerminalTabAction, actions.selectPreviousTerminalTab) - .focusedSceneValue(\.selectNextTerminalTabAction, actions.selectNextTerminalTab) - .focusedSceneValue(\.selectPreviousTerminalPaneAction, actions.selectPreviousTerminalPane) - .focusedSceneValue(\.selectNextTerminalPaneAction, actions.selectNextTerminalPane) - .focusedSceneValue(\.selectTerminalPaneAboveAction, actions.selectTerminalPaneAbove) - .focusedSceneValue(\.selectTerminalPaneBelowAction, actions.selectTerminalPaneBelow) - .focusedSceneValue(\.selectTerminalPaneLeftAction, actions.selectTerminalPaneLeft) - .focusedSceneValue(\.selectTerminalPaneRightAction, actions.selectTerminalPaneRight) - .focusedSceneValue(\.runScriptAction, actions.runScript) - .focusedSceneValue(\.stopRunScriptAction, actions.stopRunScript) + .focusedSceneValue(\.openSelectedWorktreeAction, actions.openSelectedWorktree.asFocusedAction(token: token)) + .focusedSceneValue(\.newTerminalAction, actions.newTerminal.asFocusedAction(token: token)) + .focusedSceneValue(\.closeTabAction, actions.closeTab.asFocusedAction(token: token)) + .focusedSceneValue(\.closeSurfaceAction, actions.closeSurface.asFocusedAction(token: token)) + .focusedSceneValue(\.resetFontSizeAction, actions.resetFontSize.asFocusedAction(token: token)) + .focusedSceneValue(\.increaseFontSizeAction, actions.increaseFontSize.asFocusedAction(token: token)) + .focusedSceneValue(\.decreaseFontSizeAction, actions.decreaseFontSize.asFocusedAction(token: token)) + .focusedSceneValue(\.startSearchAction, actions.startSearch.asFocusedAction(token: token)) + .focusedSceneValue(\.searchSelectionAction, actions.searchSelection.asFocusedAction(token: token)) + .focusedSceneValue(\.navigateSearchNextAction, actions.navigateSearchNext.asFocusedAction(token: token)) + .focusedSceneValue( + \.navigateSearchPreviousAction, actions.navigateSearchPrevious.asFocusedAction(token: token) + ) + .focusedSceneValue(\.endSearchAction, actions.endSearch.asFocusedAction(token: token)) + .focusedSceneValue( + \.selectPreviousTerminalTabAction, actions.selectPreviousTerminalTab.asFocusedAction(token: token) + ) + .focusedSceneValue(\.selectNextTerminalTabAction, actions.selectNextTerminalTab.asFocusedAction(token: token)) + .focusedSceneValue( + \.selectPreviousTerminalPaneAction, actions.selectPreviousTerminalPane.asFocusedAction(token: token) + ) + .focusedSceneValue( + \.selectNextTerminalPaneAction, actions.selectNextTerminalPane.asFocusedAction(token: token) + ) + .focusedSceneValue(\.selectTerminalPaneAboveAction, actions.selectTerminalPaneAbove.asFocusedAction(token: token)) + .focusedSceneValue(\.selectTerminalPaneBelowAction, actions.selectTerminalPaneBelow.asFocusedAction(token: token)) + .focusedSceneValue(\.selectTerminalPaneLeftAction, actions.selectTerminalPaneLeft.asFocusedAction(token: token)) + .focusedSceneValue(\.selectTerminalPaneRightAction, actions.selectTerminalPaneRight.asFocusedAction(token: token)) + .focusedSceneValue(\.runScriptAction, actions.runScript.asFocusedAction(token: token)) + .focusedSceneValue(\.stopRunScriptAction, actions.stopRunScript.asFocusedAction(token: token)) } private func makeFocusedActions( @@ -688,6 +702,19 @@ struct WorktreeDetailView: View { } } + /// Hashable identity of the inputs the focused actions capture, used as the + /// `FocusedAction` token. The detail body re-runs on every OSC-9 progress + /// tick during agent activity; without a stable token each run would look + /// like a focused-value change and rebuild the menu bar. Including the + /// selected / canvas-focused worktree here keeps the published actions stable + /// while the same worktree is focused, yet still republishes when the target + /// worktree changes (so a menu item never fires against a stale worktree). + private struct WorktreeActionContext: Hashable { + let selectedWorktreeID: Worktree.ID? + let isShowingCanvas: Bool + let canvasFocusedWorktreeID: Worktree.ID? + } + private struct FocusedActions { let openSelectedWorktree: (() -> Void)? let newTerminal: (() -> Void)? diff --git a/supacodeTests/FocusedActionTests.swift b/supacodeTests/FocusedActionTests.swift new file mode 100644 index 00000000..2bc6bb20 --- /dev/null +++ b/supacodeTests/FocusedActionTests.swift @@ -0,0 +1,58 @@ +import Testing + +@testable import supacode + +struct FocusedActionTests { + @Test func equalWhenEnabledAndTokenMatchDespiteDifferentClosures() { + // Two distinct closure instances with the same enabled flag and token must + // compare equal so SwiftUI does not treat a no-op body run as a value change. + let lhs = FocusedAction(isEnabled: true, token: "a") {} + let rhs = FocusedAction(isEnabled: true, token: "a") {} + #expect(lhs == rhs) + } + + @Test func notEqualWhenEnabledDiffers() { + let lhs = FocusedAction(isEnabled: true, token: "a") {} + let rhs = FocusedAction(isEnabled: false, token: "a") {} + #expect(lhs != rhs) + } + + @Test func notEqualWhenTokenDiffers() { + let lhs = FocusedAction(isEnabled: true, token: "a") {} + let rhs = FocusedAction(isEnabled: true, token: "b") {} + #expect(lhs != rhs) + } + + @Test func nilTokensCompareEqual() { + let lhs = FocusedAction(isEnabled: true, token: nil) {} + let rhs = FocusedAction(isEnabled: true, token: nil) {} + #expect(lhs == rhs) + } + + @Test func callExecutesPerformWhenEnabled() { + var ran = false + let action = FocusedAction(isEnabled: true) { ran = true } + action() + #expect(ran) + } + + @Test func callDoesNotExecutePerformWhenDisabled() { + var ran = false + let action = FocusedAction(isEnabled: false) { ran = true } + action() + #expect(!ran) + } + + @Test func callForwardsInputValue() { + var received: Int? + let action = FocusedAction(isEnabled: true) { received = $0 } + action(42) + #expect(received == 42) + } + + @Test func tokensOfDifferentHashableTypesCompareUnequal() { + let lhs = FocusedAction(isEnabled: true, token: ["w1"]) {} + let rhs = FocusedAction(isEnabled: true, token: ["w2"]) {} + #expect(lhs != rhs) + } +}