fix: goto_split:previous/next cycle through all panes with wrapping#2639
fix: goto_split:previous/next cycle through all panes with wrapping#2639mykmelez wants to merge 4 commits intomanaflow-ai:mainfrom
Conversation
Add tests verifying that goto_split:previous and goto_split:next cycle through all panes regardless of split direction (horizontal and vertical) and wrap at the ends. Uses Ghostty's default keybinds (Cmd+]/[). Extends the goto_split test infrastructure with a three_pane_terminal layout mode (CMUX_UI_TEST_GOTO_SPLIT_LAYOUT=three_pane_terminal) and a cycle navigation recorder for test observability. These tests are expected to FAIL without the accompanying fix, because goto_split:previous/next currently map to directional left/right navigation which skips vertically-split panes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously, goto_split:previous and goto_split:next were mapped to directional left/right navigation in Bonsplit, which only found spatially adjacent panes and skipped vertically-split panes entirely. This adds cycle-based navigation that traverses all panes in tree order (using Bonsplit's allPaneIds) and wraps around at the ends, matching Ghostty's intended behavior for these actions. Changes: - Workspace.cycleFocus(forward:) traverses allPaneIds with wrapping - TabManager.cycleSplitFocus delegates to Workspace.cycleFocus - GhosttyTerminalView.handleAction routes PREVIOUS/NEXT through cycle navigation instead of mapping to directional .left/.right - focusDirection() no longer handles PREVIOUS/NEXT cases Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
@mykmelez is attempting to deploy a commit to the Manaflow Team on Vercel. A member of the Team first needs to authorize it. |
|
This review could not be run because your cubic account has exceeded the monthly review limit. If you need help restoring access, please contact contact@cubic.dev. |
📝 WalkthroughWalkthroughAdds cycle-based pane navigation and UI tests: introduces workspace/tab manager cycle focus APIs, routes goto-split previous/next actions to cycle logic, records test setup/moves via AppDelegate, and adds a three-pane UI test suite that validates cycling and wrapping. Changes
Sequence DiagramsequenceDiagram
actor User
participant GhosttyTerminalView
participant TabManager
participant Workspace
participant bonsplitController
participant AppDelegate
User->>GhosttyTerminalView: Trigger GOTO_SPLIT_NEXT (Cmd+])
GhosttyTerminalView->>TabManager: cycleSplitFocus(tabId, forward: true)
TabManager->>Workspace: cycleFocus(forward: true)
Workspace->>Workspace: Determine next pane in tree order
Workspace->>bonsplitController: unfocus(currentPane)
Workspace->>bonsplitController: focusPane(targetPane)
Workspace->>Workspace: applyTabSelection(in targetPane)
Workspace-->>TabManager: return success
TabManager-->>GhosttyTerminalView: return true
GhosttyTerminalView->>AppDelegate: recordGotoSplitCycleMoveIfNeeded(forward: true)
AppDelegate->>AppDelegate: write snapshot JSON (test output)
GhosttyTerminalView-->>User: Focus updated to target pane
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Warning Review ran into problems🔥 ProblemsGit: Failed to clone repository. Please run the Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (5)
Sources/Workspace.swift (1)
10098-10114: Use tree traversal instead ofallPaneIdsfor the cycle order.This method promises tree-order traversal, but it currently builds the sequence from
bonsplitController.allPaneIds. The same file already usesSidebarBranchOrdering.orderedPaneIds(tree:)when pane order matters. IfallPaneIdsever diverges from tree order after split/move churn,goto_split:previous/nextwill walk panes in the wrong sequence. Please either derive the list fromtreeSnapshot()here or verify that Bonsplit guaranteesallPaneIdsis tree-ordered.♻️ Suggested change
- let allPaneIds = bonsplitController.allPaneIds + let orderedPaneIdStrings = SidebarBranchOrdering.orderedPaneIds( + tree: bonsplitController.treeSnapshot() + ) + let panesById = Dictionary( + uniqueKeysWithValues: bonsplitController.allPaneIds.map { ($0.id.uuidString, $0) } + ) + let allPaneIds = orderedPaneIdStrings.compactMap { panesById[$0] }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/Workspace.swift` around lines 10098 - 10114, The current navigation uses bonsplitController.allPaneIds which may not reflect tree-order; change the sequence source to a tree-ordered list by calling treeSnapshot() and passing it to SidebarBranchOrdering.orderedPaneIds(tree:) (or otherwise derive ordered pane ids from the tree) instead of allPaneIds; keep the rest of the logic (unfocusing via focusedPanelId/panels and computing targetIndex) identical but replace references to allPaneIds with the ordered list so goto_split previous/next walks panes in true tree order.cmuxUITests/GotoSplitCycleUITests.swift (4)
12-17: Add tearDown to remove the test data file.The test creates a JSON file at
dataPathbut never cleans it up. Adding atearDownmethod ensures test artifacts don't accumulate on disk, especially during repeated local test runs.♻️ Proposed fix
override func setUp() { super.setUp() continueAfterFailure = false dataPath = "/tmp/cmux-ui-test-goto-split-cycle-\(UUID().uuidString).json" try? FileManager.default.removeItem(atPath: dataPath) } + + override func tearDown() { + try? FileManager.default.removeItem(atPath: dataPath) + super.tearDown() + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@cmuxUITests/GotoSplitCycleUITests.swift` around lines 12 - 17, Add a tearDown method to remove the temporary JSON created in setUp: implement override func tearDown() { try? FileManager.default.removeItem(atPath: dataPath); super.tearDown() } so the file referenced by dataPath is deleted after each test; ensure you call super.tearDown() and use the same FileManager removal logic as in setUp to avoid leftover artifacts.
37-41: Guard against missing dictionary keys to avoid test crashes.The
waitForDatacall confirms keys exist, butloadData()is called again afterward. If the file changes between calls or parsing differs, these force unwraps will crash without a useful diagnostic. Usingguard letprovides clearer failure messages.♻️ Proposed fix
- let allPaneIds = Set(setup["allPaneIds"]!.split(separator: ",").map(String.init)) + guard let allPaneIdsRaw = setup["allPaneIds"] else { + XCTFail("Missing allPaneIds in setup data") + return + } + let allPaneIds = Set(allPaneIdsRaw.split(separator: ",").map(String.init)) XCTAssertEqual(allPaneIds.count, 3, "Expected 3 distinct pane IDs") - let startPane = setup["focusedPaneId"]! + guard let startPane = setup["focusedPaneId"] else { + XCTFail("Missing focusedPaneId in setup data") + return + } XCTAssertTrue(allPaneIds.contains(startPane), "Start pane should be in allPaneIds")🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@cmuxUITests/GotoSplitCycleUITests.swift` around lines 37 - 41, Replace the force-unwrapped dictionary accesses after loadData() with safe guards: instead of using setup["allPaneIds"]! and setup["focusedPaneId"]! directly (used when computing allPaneIds and startPane), add guard let statements to unwrap setup["allPaneIds"] and setup["focusedPaneId"] (and ensure the split/map result exists) and call XCTFail with a clear diagnostic and return if missing; this change should be made in the test function that calls loadData()/waitForData() so the failure is descriptive rather than crashing (reference symbols: loadData(), waitForData(), allPaneIds, startPane).
85-88: Same force unwrap issue as thenexttest.Apply the same guard-let pattern here for consistency and better failure diagnostics.
♻️ Proposed fix
- let allPaneIds = Set(setup["allPaneIds"]!.split(separator: ",").map(String.init)) + guard let allPaneIdsRaw = setup["allPaneIds"] else { + XCTFail("Missing allPaneIds in setup data") + return + } + let allPaneIds = Set(allPaneIdsRaw.split(separator: ",").map(String.init)) XCTAssertEqual(allPaneIds.count, 3, "Expected 3 distinct pane IDs") - let startPane = setup["focusedPaneId"]! + guard let startPane = setup["focusedPaneId"] else { + XCTFail("Missing focusedPaneId in setup data") + return + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@cmuxUITests/GotoSplitCycleUITests.swift` around lines 85 - 88, The test currently force-unwraps values from setup (allPaneIds and focusedPaneId); change to a guard-let pattern to safely unwrap setup["allPaneIds"] and setup["focusedPaneId"], call XCTFail with a clear message and return if either is missing, then proceed to build allPaneIds (split/map) and assert count and use startPane; update references to use the unwrapped variables (e.g., use the local allPaneIdsString and startPane variables instead of force-unwrapped setup accesses).
69-112: Consider extracting shared test logic into a parameterized helper.Both tests share ~90% identical code, differing only in the key (
"]"vs"[") and direction name in assertions. A helper likeverifyCycleNavigation(key:directionName:)would reduce duplication and make adding edge-case tests easier.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@cmuxUITests/GotoSplitCycleUITests.swift` around lines 69 - 112, The two nearly identical tests should be consolidated by extracting the shared logic into a parameterized helper (e.g., verifyCycleNavigation(key: String, directionName: String)); move the setup/teardown, launchWithThreePaneLayout() call, the waitForData checks, loadData usage, the loop that types app.typeKey(...), waitForDataMatch, and the final assertions into that helper, and then replace each testGotoSplitPreviousCyclesAllPanes and its counterpart with a single one-line call to verifyCycleNavigation(key: "[", directionName: "previous") (and the other test calling with key: "]" and directionName: "next"); ensure helper references functions/locals like launchWithThreePaneLayout(), waitForData(...), waitForDataMatch(...), loadData(), and asserted variables such as allPaneIds, focusedPaneId and visited so the behavior and assertion text remain the same.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@cmuxUITests/GotoSplitCycleUITests.swift`:
- Around line 193-203: The helper launchAndEnsureForeground currently returns
success when app.state == .runningBackground, which contradicts its name and can
cause flaky input failures; update it to either (A) rename the function to
launchAndEnsureRunning (or similar) if background is an acceptable end state, or
(B) ensure the app is actually foreground by calling app.activate() (or another
activation attempt) when app.state == .runningBackground and re-checking
app.state before returning, keeping the existing XCTExpectFailure wrapper around
app.launch() and preserving the failure path that calls XCTFail with state info.
In `@Sources/GhosttyTerminalView.swift`:
- Around line 2839-2841: The code uses AppDelegate.shared?.tabManager which
biases to the active window; instead resolve the TabManager that actually owns
the given tabId before calling cycleSplitFocus(tabId:forward:). Replace the
AppDelegate.shared?.tabManager lookup with the cross-window resolver used
elsewhere (e.g. the same lookup pattern as in Sources/TerminalController.swift —
a TabManager lookup by tabId like tabManagerForTab(tabId:) or similar), then
call the found TabManager.cycleSplitFocus(tabId: forward:); if no owning manager
is found, fall back gracefully (no-op or the existing behavior).
---
Nitpick comments:
In `@cmuxUITests/GotoSplitCycleUITests.swift`:
- Around line 12-17: Add a tearDown method to remove the temporary JSON created
in setUp: implement override func tearDown() { try?
FileManager.default.removeItem(atPath: dataPath); super.tearDown() } so the file
referenced by dataPath is deleted after each test; ensure you call
super.tearDown() and use the same FileManager removal logic as in setUp to avoid
leftover artifacts.
- Around line 37-41: Replace the force-unwrapped dictionary accesses after
loadData() with safe guards: instead of using setup["allPaneIds"]! and
setup["focusedPaneId"]! directly (used when computing allPaneIds and startPane),
add guard let statements to unwrap setup["allPaneIds"] and
setup["focusedPaneId"] (and ensure the split/map result exists) and call XCTFail
with a clear diagnostic and return if missing; this change should be made in the
test function that calls loadData()/waitForData() so the failure is descriptive
rather than crashing (reference symbols: loadData(), waitForData(), allPaneIds,
startPane).
- Around line 85-88: The test currently force-unwraps values from setup
(allPaneIds and focusedPaneId); change to a guard-let pattern to safely unwrap
setup["allPaneIds"] and setup["focusedPaneId"], call XCTFail with a clear
message and return if either is missing, then proceed to build allPaneIds
(split/map) and assert count and use startPane; update references to use the
unwrapped variables (e.g., use the local allPaneIdsString and startPane
variables instead of force-unwrapped setup accesses).
- Around line 69-112: The two nearly identical tests should be consolidated by
extracting the shared logic into a parameterized helper (e.g.,
verifyCycleNavigation(key: String, directionName: String)); move the
setup/teardown, launchWithThreePaneLayout() call, the waitForData checks,
loadData usage, the loop that types app.typeKey(...), waitForDataMatch, and the
final assertions into that helper, and then replace each
testGotoSplitPreviousCyclesAllPanes and its counterpart with a single one-line
call to verifyCycleNavigation(key: "[", directionName: "previous") (and the
other test calling with key: "]" and directionName: "next"); ensure helper
references functions/locals like launchWithThreePaneLayout(), waitForData(...),
waitForDataMatch(...), loadData(), and asserted variables such as allPaneIds,
focusedPaneId and visited so the behavior and assertion text remain the same.
In `@Sources/Workspace.swift`:
- Around line 10098-10114: The current navigation uses
bonsplitController.allPaneIds which may not reflect tree-order; change the
sequence source to a tree-ordered list by calling treeSnapshot() and passing it
to SidebarBranchOrdering.orderedPaneIds(tree:) (or otherwise derive ordered pane
ids from the tree) instead of allPaneIds; keep the rest of the logic (unfocusing
via focusedPanelId/panels and computing targetIndex) identical but replace
references to allPaneIds with the ordered list so goto_split previous/next walks
panes in true tree order.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: c787c334-ac1d-44d4-92b4-fa1ea9a4dd44
📒 Files selected for processing (6)
GhosttyTabs.xcodeproj/project.pbxprojSources/AppDelegate.swiftSources/GhosttyTerminalView.swiftSources/TabManager.swiftSources/Workspace.swiftcmuxUITests/GotoSplitCycleUITests.swift
| private func launchAndEnsureForeground(_ app: XCUIApplication, timeout: TimeInterval = 12.0) { | ||
| let options = XCTExpectedFailure.Options() | ||
| options.isStrict = false | ||
| XCTExpectFailure("App activation may fail on headless CI runners", options: options) { | ||
| app.launch() | ||
| } | ||
|
|
||
| if app.state == .runningForeground { return } | ||
| if app.state == .runningBackground { return } | ||
| XCTFail("App failed to start. state=\(app.state.rawValue)") | ||
| } |
There was a problem hiding this comment.
Method accepts runningBackground state despite name suggesting foreground.
The method returns successfully when app.state == .runningBackground, which may not actually ensure the app receives keyboard input reliably. Consider either renaming to launchAndEnsureRunning or attempting to activate the app when in background state. This could cause flaky test failures if key events don't reach a background app.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@cmuxUITests/GotoSplitCycleUITests.swift` around lines 193 - 203, The helper
launchAndEnsureForeground currently returns success when app.state ==
.runningBackground, which contradicts its name and can cause flaky input
failures; update it to either (A) rename the function to launchAndEnsureRunning
(or similar) if background is an acceptable end state, or (B) ensure the app is
actually foreground by calling app.activate() (or another activation attempt)
when app.state == .runningBackground and re-checking app.state before returning,
keeping the existing XCTExpectFailure wrapper around app.launch() and preserving
the failure path that calls XCTFail with state info.
Greptile SummaryThis PR replaces the broken Confidence Score: 5/5This PR is safe to merge — the fix is targeted, follows existing patterns, and is covered by new UI tests. No P0 or P1 issues found. cycleFocus correctly handles edge cases (count > 1 guard, currentIndex not found, modular wrapping) and follows the same focus-reconciliation pattern as moveFocus. Tests exercise actual runtime behavior. No files require special attention. Important Files Changed
Sequence DiagramsequenceDiagram
participant U as User (Cmd+]/[)
participant GTV as GhosttyTerminalView
participant TM as TabManager
participant WS as Workspace
participant BC as BonsplitController
U->>GTV: handleAction(GOTO_SPLIT_NEXT/PREV)
GTV->>GTV: performOnMain { }
GTV->>TM: cycleSplitFocus(tabId:, forward:)
TM->>WS: cycleFocus(forward:)
WS->>BC: allPaneIds
BC-->>WS: [pane0, pane1, pane2]
WS->>BC: focusedPaneId → currentIndex
WS->>WS: targetIndex = (currentIndex ± 1) % count
WS->>BC: focusPane(allPaneIds[targetIndex])
WS->>WS: applyTabSelection(tabId:inPane:)
TM-->>GTV: true
GTV->>GTV: recordGotoSplitCycleMoveIfNeeded (DEBUG)
Reviews (1): Last reviewed commit: "fix: goto_split:previous/next now cycle ..." | Re-trigger Greptile |
The setupThreePaneTerminalLayout helper was writing setupComplete immediately after creating splits, before a terminal surface became first responder. Ghostty keybinds only fire when GhosttyNSView has focus, so early keystrokes could miss. Now waits for .ghosttyDidFocusSurface and verifies a terminal panel is focused before signaling readiness, matching the pattern used by the existing browser split setup. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use tabManagerFor(tabId:) instead of AppDelegate.shared?.tabManager so that goto_split:previous/next routes to the correct window's TabManager in multi-window scenarios, rather than biasing toward the active window. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Addressed two of the CodeRabbit review comments in follow-up commits:
The |
There was a problem hiding this comment.
♻️ Duplicate comments (1)
Sources/AppDelegate.swift (1)
8588-8625:⚠️ Potential issue | 🟠 MajorStill gate readiness on the terminal’s actual keyboard target.
window.firstResponder is NSViewis too weak here. The poll can still writesetupCompletewhile some unrelated view owns focus, and a stale scheduled poll can keep running after a successful notification path. That reintroduces the same flake this helper is meant to remove. Resolve the window from the focused terminal panel and use its hosted view’s preferred-focus check, with aresolvedguard so pending polls stop once setup succeeds.Suggested fix
- var observer: NSObjectProtocol? + var observer: NSObjectProtocol? + var resolved = false let deadline = Date().addingTimeInterval(6.0) func checkAndSignal() { + guard !resolved else { return } guard Date() < deadline else { if let observer { NotificationCenter.default.removeObserver(observer) } + resolved = true self.writeGotoSplitTestData(["setupError": "Timed out waiting for terminal focus"]) return } guard let focusedPanelId = tab.focusedPanelId, - tab.terminalPanel(for: focusedPanelId) != nil, - let window = NSApp.mainWindow ?? NSApp.keyWindow, - window.firstResponder is NSView else { + let terminalPanel = tab.terminalPanel(for: focusedPanelId), + let window = terminalPanel.hostedView.window, + terminalPanel.hostedView.responderMatchesPreferredKeyboardFocus(window.firstResponder) else { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { checkAndSignal() } return } if let observer { NotificationCenter.default.removeObserver(observer) } + resolved = true let allPaneIds = tab.bonsplitController.allPaneIds.map(\.description)Based on learnings: In
Sources/AppDelegate.swift, determine whether to repair focus by asking the focused terminal’s hosted view if the current first responder already matches that panel’s preferred keyboard target.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/AppDelegate.swift` around lines 8588 - 8625, The poll currently uses a weak check (window.firstResponder is NSView) and can report setupComplete while an unrelated view is focused and leave scheduled polls running; update checkAndSignal to resolve the focused terminal panel via tab.focusedPanelId and tab.terminalPanel(for:), obtain that panel’s hosted view / preferred keyboard target (e.g. the hosted view’s preferredFocus or preferredKeyboardTarget API), and only consider readiness true when window.firstResponder matches that specific preferred target; when readiness succeeds remove the observer and set a local resolved flag so any pending DispatchQueue.asyncAfter callbacks return early and do not re-send setupComplete or keep polling. Ensure observer removal happens in both success and timeout paths and reference checkAndSignal, observer, tab.focusedPanelId, tab.terminalPanel(for:), and bonsplitController.focusedPaneId in your changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@Sources/AppDelegate.swift`:
- Around line 8588-8625: The poll currently uses a weak check
(window.firstResponder is NSView) and can report setupComplete while an
unrelated view is focused and leave scheduled polls running; update
checkAndSignal to resolve the focused terminal panel via tab.focusedPanelId and
tab.terminalPanel(for:), obtain that panel’s hosted view / preferred keyboard
target (e.g. the hosted view’s preferredFocus or preferredKeyboardTarget API),
and only consider readiness true when window.firstResponder matches that
specific preferred target; when readiness succeeds remove the observer and set a
local resolved flag so any pending DispatchQueue.asyncAfter callbacks return
early and do not re-send setupComplete or keep polling. Ensure observer removal
happens in both success and timeout paths and reference checkAndSignal,
observer, tab.focusedPanelId, tab.terminalPanel(for:), and
bonsplitController.focusedPaneId in your changes.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 08d7ce14-6eb5-4093-b58d-d662d203bac4
📒 Files selected for processing (2)
Sources/AppDelegate.swiftSources/GhosttyTerminalView.swift
🚧 Files skipped from review as they are similar to previous changes (1)
- Sources/GhosttyTerminalView.swift
Summary
goto_split:previousandgoto_split:nextnow cycle through all panes in tree order regardless of split direction (horizontal/vertical) and wrap at the endsGhosttyTerminalView.swiftacknowledged this: "Bonsplit doesn't have cycle-based navigation")allPaneIdsandfocusPaneAPIs to implement proper cycle traversal without requiring Bonsplit changesTest plan
GotoSplitCycleUITests— creates 3-pane layout (horizontal + vertical splits), verifiesCmd+]andCmd+[visit all panes and wrapgoto_split:nextandgoto_split:previouscycle through all panes in mixed split layoutsBrowserPaneNavigationKeybindUITestsunaffected (directional navigation unchanged)Note: per regression test commit policy, the test commit is first (expected to fail) and the fix commit is second.
🤖 Generated with Claude Code
Summary by cubic
Fixes
goto_split:previousandgoto_split:nextto cycle through all panes in tree order with wrap-around, regardless of split direction. Directional up/down/left/right is unchanged, and multi-window routing now targets the correctTabManager.allPaneIds/focusPane; addWorkspace.cycleFocusandTabManager.cycleSplitFocus, and remove previous/next mapping fromfocusDirection.TabManagerbytabIdin the action handler so previous/next navigates in the correct window.GotoSplitCycleUITestsand a test-only 3-pane terminal layout; wait for terminal focus before signaling setup complete to ensure keystrokes are received.Written for commit a3cd333. Summary will update on new commits.
Summary by CodeRabbit
Bug Fixes
New Features
Tests