From 36708e6b726a304455deba2b3ea404defdfc410e Mon Sep 17 00:00:00 2001 From: khoi <6994441+khoi@users.noreply.github.com> Date: Mon, 16 Feb 2026 12:43:42 +0700 Subject: [PATCH] Wrap Ghostty search navigation at boundaries --- .../WorktreeTerminalManager.swift | 4 +- .../Models/WorktreeTerminalState.swift | 12 ++++ .../Views/GhosttySurfaceSearchOverlay.swift | 12 +--- .../Ghostty/GhosttySearchNavigation.swift | 57 +++++++++++++++++++ .../GhosttySearchNavigatorTests.swift | 45 +++++++++++++++ 5 files changed, 117 insertions(+), 13 deletions(-) create mode 100644 supacode/Infrastructure/Ghostty/GhosttySearchNavigation.swift create mode 100644 supacodeTests/GhosttySearchNavigatorTests.swift diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index ba3af4d9..bbabbc59 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -60,9 +60,9 @@ final class WorktreeTerminalManager { case .searchSelection(let worktree): state(for: worktree).performBindingActionOnFocusedSurface("search_selection") case .navigateSearchNext(let worktree): - state(for: worktree).performBindingActionOnFocusedSurface("navigate_search:next") + state(for: worktree).navigateSearchOnFocusedSurface(.next) case .navigateSearchPrevious(let worktree): - state(for: worktree).performBindingActionOnFocusedSurface("navigate_search:previous") + state(for: worktree).navigateSearchOnFocusedSurface(.previous) case .endSearch(let worktree): state(for: worktree).performBindingActionOnFocusedSurface("end_search") default: diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 87c694f0..2fe81e38 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -288,6 +288,18 @@ final class WorktreeTerminalState { return true } + @discardableResult + func navigateSearchOnFocusedSurface(_ direction: GhosttySearchDirection) -> Bool { + guard let tabId = tabManager.selectedTabId, + let focusedId = focusedSurfaceIdByTab[tabId], + let surface = surfaces[focusedId] + else { + return false + } + surface.navigateSearch(direction) + return true + } + func closeTab(_ tabId: TerminalTabID) { let wasRunScriptTab = tabId == runScriptTabId removeTree(for: tabId) diff --git a/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift b/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift index 10276846..96913249 100644 --- a/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift +++ b/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift @@ -165,12 +165,7 @@ struct GhosttySurfaceSearchOverlay: View { private func navigateSearch(_ direction: GhosttySearchDirection) { flushPendingSearch() - switch direction { - case .next: - surfaceView.performBindingAction("navigate_search:next") - case .previous: - surfaceView.performBindingAction("navigate_search:previous") - } + surfaceView.navigateSearch(direction) } private func closeSearch() { @@ -248,11 +243,6 @@ private struct GhosttySearchOverlayShape: Shape { } } -private enum GhosttySearchDirection { - case next - case previous -} - private struct SearchButtonLabel: View { let title: String let shortcut: String diff --git a/supacode/Infrastructure/Ghostty/GhosttySearchNavigation.swift b/supacode/Infrastructure/Ghostty/GhosttySearchNavigation.swift new file mode 100644 index 00000000..e6d8f7ed --- /dev/null +++ b/supacode/Infrastructure/Ghostty/GhosttySearchNavigation.swift @@ -0,0 +1,57 @@ +enum GhosttySearchDirection { + case next + case previous + + var bindingAction: String { + switch self { + case .next: + return "navigate_search:next" + case .previous: + return "navigate_search:previous" + } + } + + var oppositeBindingAction: String { + switch self { + case .next: + return "navigate_search:previous" + case .previous: + return "navigate_search:next" + } + } +} + +enum GhosttySearchNavigator { + static func bindingActions( + direction: GhosttySearchDirection, + selected: Int?, + total: Int? + ) -> [String] { + let directAction = direction.bindingAction + guard let total, let selected, total > 1, selected >= 0, selected < total else { + return [directAction] + } + + switch direction { + case .next where selected == total - 1: + return Array(repeating: direction.oppositeBindingAction, count: total - 1) + case .previous where selected == 0: + return Array(repeating: direction.oppositeBindingAction, count: total - 1) + default: + return [directAction] + } + } +} + +extension GhosttySurfaceView { + func navigateSearch(_ direction: GhosttySearchDirection) { + let actions = GhosttySearchNavigator.bindingActions( + direction: direction, + selected: bridge.state.searchSelected, + total: bridge.state.searchTotal + ) + for action in actions { + performBindingAction(action) + } + } +} diff --git a/supacodeTests/GhosttySearchNavigatorTests.swift b/supacodeTests/GhosttySearchNavigatorTests.swift new file mode 100644 index 00000000..db15fd2c --- /dev/null +++ b/supacodeTests/GhosttySearchNavigatorTests.swift @@ -0,0 +1,45 @@ +import Testing + +@testable import supacode + +struct GhosttySearchNavigatorTests { + @Test func nextWrapsFromLastToFirst() { + let actions = GhosttySearchNavigator.bindingActions( + direction: .next, + selected: 4, + total: 5 + ) + + #expect(actions == Array(repeating: "navigate_search:previous", count: 4)) + } + + @Test func previousWrapsFromFirstToLast() { + let actions = GhosttySearchNavigator.bindingActions( + direction: .previous, + selected: 0, + total: 5 + ) + + #expect(actions == Array(repeating: "navigate_search:next", count: 4)) + } + + @Test func nonBoundaryUsesDirectAction() { + let actions = GhosttySearchNavigator.bindingActions( + direction: .next, + selected: 1, + total: 5 + ) + + #expect(actions == ["navigate_search:next"]) + } + + @Test func unknownSelectionUsesDirectAction() { + let actions = GhosttySearchNavigator.bindingActions( + direction: .next, + selected: nil, + total: 5 + ) + + #expect(actions == ["navigate_search:next"]) + } +} -- 2.51.2