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
97 changes: 79 additions & 18 deletions Geistty/GeisttyTests/TmuxSessionManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2021,10 +2021,11 @@ extension TmuxSessionManagerTests {
XCTAssertEqual(mock.setActiveTmuxPaneInputOnlyCalls, [7])
}

/// selectPane() with a non-numeric pane ID should still set focusedPaneId
/// but NOT call setActiveTmuxPaneInputOnly (TmuxId.numericPaneId returns nil).
/// selectPane() with an invalid pane ID should reject the call:
/// no command is sent, focusedPaneId is unchanged, and
/// setActiveTmuxPaneInputOnly is not called.
@MainActor
func testSelectPaneWithInvalidPaneIdDoesNotCallSetActive() {
func testSelectPaneWithInvalidPaneIdDoesNotSendCommand() {
let (mgr, log) = managerWithCommandLog()
let mock = MockTmuxSurface()

Expand All @@ -2034,13 +2035,59 @@ extension TmuxSessionManagerTests {

mgr.selectPane("invalid")

// Command still sent (tmux will reject it, but we send it)
XCTAssertEqual(log.commands, ["select-pane -t 'invalid'\n"])
// focusedPaneId still updated (local state)
XCTAssertEqual(mgr.focusedPaneId, "invalid")
// No setActiveTmuxPaneInputOnly call — numeric conversion failed
// Validation guard rejects malformed pane ID — no command sent
XCTAssertTrue(log.commands.isEmpty,
"No command should be sent for invalid pane ID")
// focusedPaneId unchanged (guard returned early)
XCTAssertEqual(mgr.focusedPaneId, "")
// No setActiveTmuxPaneInputOnly call
XCTAssertTrue(mock.setActiveTmuxPaneInputOnlyCalls.isEmpty,
"setActiveTmuxPaneInputOnly should not be called for non-numeric pane IDs")
"setActiveTmuxPaneInputOnly should not be called for invalid pane IDs")
}

/// closeWindow() with an invalid window ID should not send any command.
@MainActor
func testCloseWindowWithInvalidWindowIdDoesNotSendCommand() {
let (mgr, log) = managerWithCommandLog()

mgr.closeWindow(windowId: "invalid")
XCTAssertTrue(log.commands.isEmpty,
"No command should be sent for invalid window ID")

mgr.closeWindow(windowId: "2")
XCTAssertTrue(log.commands.isEmpty,
"Window ID without @ prefix should be rejected")
}

/// renameWindow(windowId:name:) with an invalid window ID should not send any command.
@MainActor
func testRenameWindowWithInvalidWindowIdDoesNotSendCommand() {
let (mgr, log) = managerWithCommandLog()

mgr.renameWindow(windowId: "bad", name: "test")
XCTAssertTrue(log.commands.isEmpty,
"No command should be sent for invalid window ID")

mgr.renameWindow(windowId: "3", name: "test")
XCTAssertTrue(log.commands.isEmpty,
"Window ID without @ prefix should be rejected")
}

/// selectWindow() with an invalid window ID should not send any command
/// or update focusedWindowId.
@MainActor
func testSelectWindowWithInvalidWindowIdDoesNotSendCommand() {
let (mgr, log) = managerWithCommandLog()

mgr.selectWindow("notawindow")
XCTAssertTrue(log.commands.isEmpty,
"No command should be sent for invalid window ID")
XCTAssertEqual(mgr.focusedWindowId, "",
"focusedWindowId should not be updated for invalid window ID")

mgr.selectWindow("0")
XCTAssertTrue(log.commands.isEmpty,
"Window ID without @ prefix should be rejected")
}

/// selectPane() without a tmuxQuerySurface should still send the command
Expand Down Expand Up @@ -2247,25 +2294,25 @@ extension TmuxSessionManagerTests {

/// selectPane() with a pane ID that has no numeric component should still
/// send the tmux command (fire-and-forget) but NOT call setActiveTmuxPaneInputOnly.
/// This is a pre-existing test (testSelectPaneWithInvalidPaneIdDoesNotCallSetActive)
/// This is a pre-existing test (testSelectPaneWithInvalidPaneIdDoesNotSendCommand)
/// but we re-verify it in the onPaneTap context.
Comment on lines 2295 to 2298
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment at lines 2295-2296 still describes the old pre-guard behavior: "selectPane() with a pane ID that has no numeric component should still send the tmux command (fire-and-forget) but NOT call setActiveTmuxPaneInputOnly." However, the test now verifies the opposite — the guard rejects the invalid pane ID, no command is sent, and setActiveTmuxPaneInputOnly is not called. The comment should be updated to reflect the rejection behavior, e.g., "selectPane() with a pane ID that fails validation should reject the call: no command is sent and setActiveTmuxPaneInputOnly is not called."

Copilot uses AI. Check for mistakes.
@MainActor
func testOnPaneTapWithMalformedPaneIdSendsCommandOnly() {
func testOnPaneTapWithMalformedPaneIdIsRejected() {
let (mgr, log) = managerWithCommandLog()
let mock = MockTmuxSurface()

#if DEBUG
mgr.tmuxQuerySurfaceOverride = mock
#endif

// Simulate tap with a pane ID that can't be parsed as numeric
// Simulate tap with a pane ID that fails validation
let onPaneTap = { mgr.selectPane("invalid") }
onPaneTap()

XCTAssertEqual(log.commands, ["select-pane -t 'invalid'\n"],
"Command should still be sent even with invalid pane ID")
XCTAssertTrue(log.commands.isEmpty,
"No command should be sent for invalid pane ID")
XCTAssertTrue(mock.setActiveTmuxPaneInputOnlyCalls.isEmpty,
"setActiveTmuxPaneInputOnly should NOT be called for non-numeric pane ID")
"setActiveTmuxPaneInputOnly should NOT be called for invalid pane ID")
}
}

Expand Down Expand Up @@ -3375,13 +3422,16 @@ extension TmuxSessionManagerTests {
#if DEBUG
mgr.setPendingOutputForTesting(["%15": [Data([0x41])]])
#endif
mgr.handleSessionRenamed(name: "my-session")
XCTAssertEqual(mgr.sessionName, "my-session")

mgr.prepareForReattach()

XCTAssertFalse(mgr.isConnected)
XCTAssertEqual(mgr.connectionState, .disconnected)
XCTAssertTrue(mgr.pendingOutput.isEmpty)
XCTAssertNil(mgr.currentSession)
XCTAssertEqual(mgr.sessionName, "")
XCTAssertTrue(mgr.windows.isEmpty)
XCTAssertFalse(mgr.viewerReady)
// focusedWindowId and focusedPaneId are preserved for UI continuity
Expand Down Expand Up @@ -4086,7 +4136,7 @@ extension TmuxSessionManagerTests {
// Set clipboard content
UIPasteboard.general.string = "clipboard text"

// Set focused pane (with % prefix, as in production)
// Set focused pane (must use %N format to pass validation)
mgr.setFocusedPane("%5")

mgr.pasteTmuxBuffer()
Expand Down Expand Up @@ -4929,12 +4979,23 @@ extension TmuxSessionManagerTests {
}

@MainActor
func testHandleSessionRenamedDoesNotCrash() {
func testHandleSessionRenamedUpdatesSessionName() {
let mgr = TmuxSessionManager()

// Initially empty
XCTAssertEqual(mgr.sessionName, "")

// Updates to new name
mgr.handleSessionRenamed(name: "my-session")
mgr.handleSessionRenamed(name: "")
XCTAssertEqual(mgr.sessionName, "my-session")

// Updates to name with special characters
mgr.handleSessionRenamed(name: "session with spaces and 'quotes'")
XCTAssertEqual(mgr.sessionName, "session with spaces and 'quotes'")

// Handles empty rename (clears name)
mgr.handleSessionRenamed(name: "")
XCTAssertEqual(mgr.sessionName, "")
}

@MainActor
Expand Down
19 changes: 19 additions & 0 deletions Geistty/Sources/App/GeisttyApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,25 @@ extension Notification.Name {
static let showTmuxSessions = Notification.Name("showTmuxSessions")
}

// MARK: - Typed Keys for tmux Notification userInfo

/// Typed constants for tmux notification `userInfo` dictionary keys.
/// Eliminates raw string literals scattered across posting (Ghostty.App.swift)
/// and consuming (SSHSession.swift) code. All tmux notifications use these
/// keys to pass payload data through NotificationCenter.
enum TmuxNotificationKey {
static let windowCount = "windowCount"
static let paneCount = "paneCount"
static let reason = "reason"
static let content = "content"
static let isError = "isError"
static let windowId = "windowId"
static let text = "text"
static let name = "name"
static let paneId = "paneId"
static let value = "value"
}

/// Global application state
@MainActor
class AppState: ObservableObject {
Expand Down
Loading
Loading