From 7eebf033d9e17df3dae8c08c3726c25c198cc479 Mon Sep 17 00:00:00 2001 From: webdevtodayjason Date: Sat, 20 Jun 2026 08:26:10 -0500 Subject: [PATCH] Add Show Next/Previous Tab to the Window menu (#418) Tab switching already worked via Ghostty's next_tab/previous_tab bindings but wasn't exposed as menu items, so it wasn't discoverable and couldn't be rebound from System Settings. Add 'Show Next Tab' / 'Show Previous Tab' to the Window menu, routed through the existing performBindingAction path (next_tab / previous_tab) so custom bindings are honored. Mirrors the splitTerminal wiring: reducer actions + FocusedActions published from WorktreeDetailView, gated on an active worktree. The binding action surfaces as GHOSTTY_ACTION_GOTO_TAB and lands on the same selectTab path the tab bar and Select Tab shortcut use, so it composes with tab hibernation. Classify both actions in AppFeature.Action.affectsWorktreeMenuSnapshot, the post-reduce gate that deliberately has no default arm: neither action writes a snapshot input, so both belong with the other terminal-routing actions. Adds AppFeatureTabNavigationTests covering both directions and the no-selection no-op. --- supacode/Commands/WindowCommands.swift | 42 +++++++++ .../App/Models/WorktreeMenuSnapshot.swift | 3 +- .../Features/App/Reducer/AppFeature.swift | 24 +++++ .../Views/WorktreeDetailView.swift | 6 ++ .../AppFeatureTabNavigationTests.swift | 94 +++++++++++++++++++ 5 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 supacodeTests/AppFeatureTabNavigationTests.swift diff --git a/supacode/Commands/WindowCommands.swift b/supacode/Commands/WindowCommands.swift index a846988d4..6a2323406 100644 --- a/supacode/Commands/WindowCommands.swift +++ b/supacode/Commands/WindowCommands.swift @@ -5,8 +5,28 @@ struct WindowCommands: Commands { @FocusedValue(\.closeSurfaceAction) private var closeSurfaceAction @FocusedValue(\.closeTabAction) private var closeTabAction @FocusedValue(\.terminateAllTerminalSessionsAction) private var terminateAllTerminalSessionsAction + @FocusedValue(\.showNextTabAction) private var showNextTabAction + @FocusedValue(\.showPreviousTabAction) private var showPreviousTabAction var body: some Commands { + // Tab navigation lives in the Window menu so it matches macOS conventions + // and can be rebound from System Settings ▸ Keyboard ▸ Shortcuts (issue #418). + CommandGroup(before: .windowArrangement) { + Button("Show Next Tab") { + showNextTabAction?() + } + .ghosttyKeyboardShortcut("next_tab", in: ghosttyShortcuts) + .disabled(showNextTabAction?.isEnabled != true) + + Button("Show Previous Tab") { + showPreviousTabAction?() + } + .ghosttyKeyboardShortcut("previous_tab", in: ghosttyShortcuts) + .disabled(showPreviousTabAction?.isEnabled != true) + + Divider() + } + let closeSurfaceHotkey = ghosttyShortcuts.keyboardShortcut(for: "close_surface") let isCloseSurfaceOverlapping = closeSurfaceHotkey?.key == "w" && closeSurfaceHotkey?.modifiers == .command @@ -50,3 +70,25 @@ extension FocusedValues { set { self[TerminateAllTerminalSessionsActionKey.self] = newValue } } } + +private struct ShowNextTabActionKey: FocusedValueKey { + typealias Value = FocusedAction +} + +extension FocusedValues { + var showNextTabAction: FocusedAction? { + get { self[ShowNextTabActionKey.self] } + set { self[ShowNextTabActionKey.self] = newValue } + } +} + +private struct ShowPreviousTabActionKey: FocusedValueKey { + typealias Value = FocusedAction +} + +extension FocusedValues { + var showPreviousTabAction: FocusedAction? { + get { self[ShowPreviousTabActionKey.self] } + set { self[ShowPreviousTabActionKey.self] = newValue } + } +} diff --git a/supacode/Features/App/Models/WorktreeMenuSnapshot.swift b/supacode/Features/App/Models/WorktreeMenuSnapshot.swift index f74eab52a..3aaeb80d1 100644 --- a/supacode/Features/App/Models/WorktreeMenuSnapshot.swift +++ b/supacode/Features/App/Models/WorktreeMenuSnapshot.swift @@ -124,7 +124,8 @@ extension AppFeature.Action { .worktreeSettingsLoaded, .openSelectedWorktree, .revealInFinder, .openWorktree, .openWorktreeFailed, .requestQuit, .requestTerminateAllTerminalSessions, .newTerminal, - .selectTerminalTabAtIndex, .splitTerminal, .jumpToLatestUnread, + .selectTerminalTabAtIndex, .splitTerminal, .showNextTab, .showPreviousTab, + .jumpToLatestUnread, .menuBarWorktreeSelected, .markAllNotificationsRead, .runScript, .runNamedScript, .manageRepositoryScripts, .stopScript, .stopRunScripts, .closeTab, .closeSurface, diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index db8ab68d0..3f8a51b6a 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -309,6 +309,8 @@ struct AppFeature { case newTerminal case selectTerminalTabAtIndex(Int) case splitTerminal(TerminalSplitMenuDirection) + case showNextTab + case showPreviousTab case jumpToLatestUnread case menuBarWorktreeSelected(worktreeID: Worktree.ID) case markAllNotificationsRead @@ -898,6 +900,28 @@ struct AppFeature { await terminalClient.send(.performBindingAction(worktree, action: direction.ghosttyBinding)) } + // Route through the same Ghostty binding the keybinding uses, so custom + // `next_tab` / `previous_tab` bindings are honored (see issue #418). + case .showNextTab: + guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID), + !worktree.isMissing + else { + return .none + } + return .run { _ in + await terminalClient.send(.performBindingAction(worktree, action: "next_tab")) + } + + case .showPreviousTab: + guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID), + !worktree.isMissing + else { + return .none + } + return .run { _ in + await terminalClient.send(.performBindingAction(worktree, action: "previous_tab")) + } + case .jumpToLatestUnread: guard let location = terminalClient.latestUnreadNotification() else { jumpLogger.debug("jumpToLatestUnread invoked with no unread notifications.") diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 25beb0283..bdbab694d 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -329,6 +329,12 @@ struct WorktreeDetailView: View { .focusedAction(\.closeTabAction, enabled: hasActiveWorktree) { store.send(.closeTab) } + .focusedAction(\.showNextTabAction, enabled: hasActiveWorktree) { + store.send(.showNextTab) + } + .focusedAction(\.showPreviousTabAction, enabled: hasActiveWorktree) { + store.send(.showPreviousTab) + } .focusedAction(\.closeSurfaceAction, enabled: hasActiveWorktree) { store.send(.closeSurface) } diff --git a/supacodeTests/AppFeatureTabNavigationTests.swift b/supacodeTests/AppFeatureTabNavigationTests.swift new file mode 100644 index 000000000..0aeb6f4df --- /dev/null +++ b/supacodeTests/AppFeatureTabNavigationTests.swift @@ -0,0 +1,94 @@ +import ComposableArchitecture +import DependenciesTestSupport +import Foundation +import Testing + +@testable import SupacodeSettingsFeature +@testable import supacode + +@MainActor +struct AppFeatureTabNavigationTests { + @Test(.dependencies) func showNextTabForwardsNextTabBinding() async { + let worktree = makeWorktree() + let sent = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore( + initialState: AppFeature.State( + repositories: makeRepositoriesState(worktree: worktree), + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + } + + await store.send(.showNextTab) + await store.finish() + #expect(sent.value == [.performBindingAction(worktree, action: "next_tab")]) + } + + @Test(.dependencies) func showPreviousTabForwardsPreviousTabBinding() async { + let worktree = makeWorktree() + let sent = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore( + initialState: AppFeature.State( + repositories: makeRepositoriesState(worktree: worktree), + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + } + + await store.send(.showPreviousTab) + await store.finish() + #expect(sent.value == [.performBindingAction(worktree, action: "previous_tab")]) + } + + @Test(.dependencies) func tabNavigationWithoutSelectionIsNoop() async { + let store = TestStore( + initialState: AppFeature.State( + repositories: RepositoriesFeature.State(), + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { _ in + Issue.record("terminalClient.send should not be called without a selected worktree") + } + } + + await store.send(.showNextTab) + await store.send(.showPreviousTab) + await store.finish() + } + + private func makeWorktree() -> Worktree { + Worktree( + id: "/tmp/repo/wt-1", + name: "wt-1", + detail: "detail", + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") + ) + } + + private func makeRepositoriesState(worktree: Worktree) -> RepositoriesFeature.State { + let repository = Repository( + id: "/tmp/repo", + rootURL: URL(fileURLWithPath: "/tmp/repo"), + name: "repo", + worktrees: [worktree] + ) + var state = RepositoriesFeature.State() + state.repositories = [repository] + state.selection = .worktree(worktree.id) + return state + } +}