diff --git a/Geistty/GeisttyTests/TmuxSessionManagerTests.swift b/Geistty/GeisttyTests/TmuxSessionManagerTests.swift index ca1ec80..f4a002a 100644 --- a/Geistty/GeisttyTests/TmuxSessionManagerTests.swift +++ b/Geistty/GeisttyTests/TmuxSessionManagerTests.swift @@ -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 @@ -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() @@ -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() @@ -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() @@ -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: #"(? (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 @@ -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. @@ -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") @@ -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)\"") {