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)
+ }
+}