Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 118 additions & 5 deletions Geistty/GeisttyTests/TmuxSessionManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3796,6 +3796,50 @@ extension TmuxSessionManagerTests {
XCTAssertTrue(cmd.contains("11\n"),
"resize-pane should target 11 rows (23 * 0.5), got: \(cmd)")
}

/// syncSplitRatioToTmux should correctly find and resize the RIGHT child pane.
/// This validates finding #14: the old code only checked split.left.leftmostPaneId,
/// which would miss panes that are the right child of a split.
@MainActor
func testSyncSplitRatioRightChildPaneSendsResizePane() {
let (mgr, log) = managerWithCommandLog()
let mock = MockTmuxSurface()
let layout = horizontalSplitLayout(paneA: 0, paneB: 1, totalCols: 80, rows: 24)
mock.stubbedWindows = [TmuxWindowInfo(id: 0, width: 80, height: 24, name: "bash")]
mock.stubbedWindowLayouts = [layout]
mock.stubbedActiveWindowId = 0
mock.stubbedPaneIds = [0, 1]
#if DEBUG
mgr.tmuxQuerySurfaceOverride = mock
#endif

mgr.controlModeActivated()
mgr.viewerBecameReady()
mgr.configureSurfaceManagement(
factory: { _ in nil },
inputHandler: { _, _ in },
resizeHandler: { _, _ in }
)
mgr.handleTmuxStateChanged(windowCount: 1, paneCount: 2)

// Set lastRefreshSize AFTER controlModeActivated (which resets it to nil)
#if DEBUG
mgr.setLastRefreshSizeForTesting(cols: 80, rows: 24)
#endif

log.commands.removeAll()
// Resize pane 1 (the RIGHT child) — this was broken before finding #14 fix
mgr.syncSplitRatioToTmux(forPaneId: 1, ratio: 0.6)

// Should send a resize-pane -x command targeting pane 1
XCTAssertEqual(log.commands.count, 1, "Exactly one resize command should be sent for right-child pane")
let cmd = log.commands.first ?? ""
XCTAssertTrue(cmd.hasPrefix("resize-pane -t %1 -x "),
"Command should target pane 1 with -x flag, got: \(cmd)")
// Expected: available = 80 - 1 (divider) = 79, new size = max(1, Int(79 * 0.6)) = 47
XCTAssertTrue(cmd.contains("47\n"),
"resize-pane should target 47 columns (79 * 0.6), got: \(cmd)")
}
}

// MARK: - Pending Input Display Tests
Expand Down Expand Up @@ -4042,8 +4086,8 @@ extension TmuxSessionManagerTests {
// Set clipboard content
UIPasteboard.general.string = "clipboard text"

// Set focused pane
mgr.setFocusedPane("5")
// Set focused pane (with % prefix, as in production)
mgr.setFocusedPane("%5")

mgr.pasteTmuxBuffer()

Expand Down Expand Up @@ -4096,7 +4140,7 @@ extension TmuxSessionManagerTests {
defer { UIPasteboard.general.string = savedClipboard }

UIPasteboard.general.string = "line with \\backslash and \"quotes\""
mgr.setFocusedPane("0")
mgr.setFocusedPane("%0")

mgr.pasteTmuxBuffer()

Expand All @@ -4121,7 +4165,7 @@ extension TmuxSessionManagerTests {
defer { UIPasteboard.general.string = savedClipboard }

UIPasteboard.general.string = "line1\nline2\rline3"
mgr.setFocusedPane("0")
mgr.setFocusedPane("%0")

mgr.pasteTmuxBuffer()

Expand All @@ -4134,6 +4178,35 @@ extension TmuxSessionManagerTests {
"Raw newlines must not appear in command: got \(cmd)")
}

/// Dollar signs and backticks must be escaped as defense-in-depth
/// against potential expansion when passed to set-buffer.
@MainActor
func testPasteTmuxBufferEscapesDollarAndBacktick() {
let mgr = TmuxSessionManager()
let mock = MockTmuxSurface()
#if DEBUG
mgr.tmuxQuerySurfaceOverride = mock
#endif

let savedClipboard = UIPasteboard.general.string
defer { UIPasteboard.general.string = savedClipboard }

UIPasteboard.general.string = "price is $100 and `command`"
mgr.setFocusedPane("%0")

mgr.pasteTmuxBuffer()

let cmd = mock.sendTmuxCommandCalls.first ?? ""
XCTAssertTrue(cmd.contains("\\$100"),
"Dollar signs should be escaped: got \(cmd)")
XCTAssertTrue(cmd.contains("\\`command\\`"),
"Backticks should be escaped: got \(cmd)")
let unescapedDollarRange = cmd.range(of: #"(?<!\\)\$"#,
options: .regularExpression)
XCTAssertNil(unescapedDollarRange,
"Unescaped dollar sign must not appear: got \(cmd)")
}

/// pasteTmuxBuffer should bail out when no pane is focused.
@MainActor
func testPasteTmuxBufferNoFocusedPaneDoesNothing() {
Expand Down Expand Up @@ -4170,7 +4243,7 @@ extension TmuxSessionManagerTests {
defer { UIPasteboard.general.string = savedClipboard }

UIPasteboard.general.string = "something"
mgr.setFocusedPane("0")
mgr.setFocusedPane("%0")
mgr.pasteTmuxBuffer()

// Simulate error on set-buffer
Expand Down Expand Up @@ -4873,6 +4946,46 @@ extension TmuxSessionManagerTests {
mgr.handleFocusedPaneChanged(windowId: UInt32.max, paneId: UInt32.max)
}

/// When the window matches focusedWindowId, handleFocusedPaneChanged
/// should update focusedPaneId via setFocusedPane.
@MainActor
func testHandleFocusedPaneChangedUpdatesFocusForMatchingWindow() {
let mgr = TmuxSessionManager()
let mock = MockTmuxSurface()
#if DEBUG
mgr.tmuxQuerySurfaceOverride = mock
#endif

// Set focusedWindowId to "@1" so the handler recognizes window 1
mgr.setFocusedWindowIdForTesting("@1")
mgr.setFocusedPane("%0") // initial focus

// Trigger pane change for matching window
mgr.handleFocusedPaneChanged(windowId: 1, paneId: 5)
XCTAssertEqual(mgr.focusedPaneId, "%5",
"focusedPaneId should be updated to %5 for matching window")
}

/// When the window does NOT match focusedWindowId, handleFocusedPaneChanged
/// should leave focusedPaneId unchanged.
@MainActor
func testHandleFocusedPaneChangedIgnoresNonMatchingWindow() {
let mgr = TmuxSessionManager()
let mock = MockTmuxSurface()
#if DEBUG
mgr.tmuxQuerySurfaceOverride = mock
#endif

// Set focusedWindowId to "@1"
mgr.setFocusedWindowIdForTesting("@1")
mgr.setFocusedPane("%0") // initial focus

// Trigger pane change for a different window
mgr.handleFocusedPaneChanged(windowId: 2, paneId: 9)
XCTAssertEqual(mgr.focusedPaneId, "%0",
"focusedPaneId should remain %0 for non-matching window")
}

@MainActor
func testHandleSubscriptionChangedDoesNotCrash() {
let mgr = TmuxSessionManager()
Expand Down
40 changes: 32 additions & 8 deletions Geistty/Sources/SSH/TmuxSessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,11 @@ class TmuxSessionManager: ObservableObject {

// ARCHIVED: Session 127 — needsReattach + needsReattachForTesting removed
// (destroy-and-recreate approach). See docs/archive/REATTACH_PRESERVED_SURFACES_FEB_2026.swift

/// Test-only: set focusedWindowId to test window-matching logic
func setFocusedWindowIdForTesting(_ windowId: String) {
focusedWindowId = windowId
}
#endif

/// Cell size from the primary surface (for calculating terminal dimensions)
Expand Down Expand Up @@ -1470,10 +1475,17 @@ class TmuxSessionManager: ObservableObject {
) -> (direction: TmuxSplitTree.Direction, ratio: Double, totalSize: Int)? {
guard case .split(let split) = node else { return nil }

if split.left.leftmostPaneId == paneId {
// Found the split - calculate total size based on direction
let totalSize = split.direction == .horizontal ? totalCols : totalRows
return (split.direction, split.ratio, totalSize)
// Check if the target pane is a direct child of this split (on either side).
// The old code only checked split.left.leftmostPaneId, which missed panes
// that are a right child or not the leftmost leaf in a subtree.
if split.left.contains(paneId: paneId) || split.right.contains(paneId: paneId) {
// Verify that the pane is an immediate child (leaf), not deeper in a subtree.
// If it's deeper, we need to recurse to find the innermost containing split.
let isDirectChild = split.left.isPane(paneId) || split.right.isPane(paneId)
if isDirectChild {
let totalSize = split.direction == .horizontal ? totalCols : totalRows
return (split.direction, split.ratio, totalSize)
}
}

// Recurse into children with adjusted sizes
Expand Down Expand Up @@ -1610,9 +1622,16 @@ class TmuxSessionManager: ObservableObject {

/// Handle a tmux `%window-pane-changed` notification from the Zig viewer.
/// Fired when the focused pane within a window changes (e.g., via select-pane).
/// Currently log-only; future work may update pane focus indicators.
/// If the window matches our focused window, update input routing to the new pane
/// so that keystrokes are sent to the correct pane via send-keys.
func handleFocusedPaneChanged(windowId: UInt32, paneId: UInt32) {
logger.info("tmux focused pane changed: @\(windowId) %\(paneId)")

// Only update focus if this is our currently focused window.
// focusedWindowId has "@" prefix (e.g. "@0"), so format windowId to match.
if "@\(windowId)" == focusedWindowId {
setFocusedPane("%\(paneId)")
}
}

/// Handle a tmux `%subscription-changed` notification from the Zig viewer.
Expand Down Expand Up @@ -1682,9 +1701,13 @@ class TmuxSessionManager: ObservableObject {
// - Newlines and carriage returns must be escaped to prevent breaking
// the tmux control-mode command framing (one command per line)
// - Use -- to prevent content starting with - from being parsed as flags
// Also escape $ and backtick as defense-in-depth against potential
// expansion in edge cases when the content is passed to set-buffer.
let escaped = clipboardContent
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
.replacingOccurrences(of: "$", with: "\\$")
.replacingOccurrences(of: "`", with: "\\`")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\r")

Expand All @@ -1698,9 +1721,10 @@ class TmuxSessionManager: ObservableObject {
logger.warning("set-buffer failed: \(content)")
return
}
// Buffer set successfully — now paste it into the focused pane
self.sendCommandFireAndForget("paste-buffer -t %\(paneId)")
logger.info("Pasted clipboard to tmux pane %\(paneId) (\(clipboardContent.count) chars)")
// Buffer set successfully — now paste it into the focused pane.
// paneId already has the "%" prefix (e.g. "%0"), so don't add another.
self.sendCommandFireAndForget("paste-buffer -t \(paneId)")
logger.info("Pasted clipboard to tmux pane \(paneId) (\(clipboardContent.count) chars)")
}

if !surface.sendTmuxCommand("set-buffer -- \"\(escaped)\"") {
Expand Down
Loading