Skip to content
Merged
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
100 changes: 91 additions & 9 deletions internal/ui/issue1753_detach_return_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ package ui
import (
"fmt"
"os"
"regexp"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -299,6 +300,26 @@ func TestIssue1753_AttachCmdClearsAttachFlagBeforeReturning(t *testing.T) {
}
}

func TestIssue1753_AttachWindowCmdClearsAttachFlagBeforeReturning(t *testing.T) {
h := NewHome()
h.isAttaching.Store(true)

// A session name that cannot exist makes AttachWindow return after its
// existence probe, without selecting or attaching to a tmux window.
cmd := attachWindowCmd{
session: &tmux.Session{Name: "agentdeck_issue1753_absent_window_session"},
windowIndex: 1,
detachByte: 17,
onExit: func() { h.isAttaching.Store(false) },
}
_ = cmd.Run()

if h.isAttaching.Load() {
t.Fatal("attachWindowCmd.Run returned with isAttaching still set: View() will render " +
"\"\" on the first frame after Bubble Tea resumes")
}
}

// TestIssue1753_AttachReturnHandlersHaveNoInlineTmuxCalls is the source-level guard.
// The behavioural tests above can only observe the snapshot; this one states the rule
// directly, so a future edit that re-adds an inline tmux round-trip to any of the four
Expand Down Expand Up @@ -358,18 +379,51 @@ func TestIssue1753_AttachReturnHandlersHaveNoInlineTmuxCalls(t *testing.T) {
}
}

// attachSession must wire onExit, or the first repaint goes back to racing the
// ExecCallback goroutine.
body := funcBody(t, text, "func (h *Home) attachSession(")
if !strings.Contains(body, "onExit:") || !strings.Contains(body, "isAttaching.Store(false)") {
t.Error("attachSession no longer clears isAttaching via attachCmd.onExit: the first " +
"post-detach View can race the ExecCallback goroutine and render blank (#1753)")
// Every local attach path must wire onExit, or the first repaint goes back to
// racing the ExecCallback goroutine.
mainKeyBody := funcBody(t, text, "func (h *Home) handleMainKey(")
attachSites := []struct {
name string
body string
cmd string
}{
{
name: "session",
body: braceBlock(t, funcBody(t, text, "func (h *Home) attachSession("), "attachCmd{"),
cmd: "attachCmd{",
},
{
name: "window",
body: braceBlock(t, handlerBlock(t, mainKeyBody, `case "enter":`), "attachWindowCmd{"),
cmd: "attachWindowCmd{",
},
{
name: "sandbox terminal",
body: braceBlock(t, handlerBlock(t, mainKeyBody, `case "E":`), "attachCmd{"),
cmd: "attachCmd{",
},
}
for _, site := range attachSites {
if !strings.Contains(site.body, site.cmd) ||
!strings.Contains(site.body, "onExit:") ||
!strings.Contains(site.body, "isAttaching.Store(false)") {
t.Errorf("%s attach no longer clears isAttaching via onExit: the first "+
"post-detach View can race the ExecCallback goroutine and render blank (#1753)",
site.name)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// The pane-CWD probe (two tmux subprocess spawns) must stay behind the
// follow-cwd setting instead of running on every detach.
if strings.Contains(body, "GetWorkDir()") && !strings.Contains(body, "GetFollowCwdOnAttach()") {
t.Error("attachSession probes the pane CWD unconditionally again: GetWorkDir costs two " +
"tmux subprocess spawns on the detach path for a feature that defaults to off (#1753)")
attachSessionBody := funcBody(t, text, "func (h *Home) attachSession(")
workDirHelper := braceBlock(t, attachSessionBody, "workDirIfFollowing := func(")
followCwdGate := regexp.MustCompile(
`if\s+!followCwd\s*\|\|\s*ts\s*==\s*nil\s*\{\s*return\s+""\s*\}`,
)
if !followCwdGate.MatchString(workDirHelper) ||
!strings.Contains(workDirHelper, "GetWorkDir()") {
t.Error("attachSession no longer returns before GetWorkDir when follow-CWD is disabled " +
"or the tmux session is nil (#1753)")
}
}

Expand All @@ -388,6 +442,34 @@ func funcBody(t *testing.T, text, signature string) string {
return rest
}

// braceBlock returns the brace-delimited block following marker.
func braceBlock(t *testing.T, text, marker string) string {
t.Helper()
start := strings.Index(text, marker)
if start < 0 {
t.Fatalf("block marker %q not found in home.go", marker)
}
openOffset := strings.Index(text[start:], "{")
if openOffset < 0 {
t.Fatalf("block marker %q has no opening brace in home.go", marker)
}
open := start + openOffset
depth := 0
for i := open; i < len(text); i++ {
switch text[i] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return text[start : i+1]
}
}
}
t.Fatalf("block marker %q has no closing brace in home.go", marker)
return ""
}

// handlerBlock returns the source text of one `case <label>` arm: from the label up to
// the next `\n\tcase ` at the same indentation, which is where the next arm starts.
func handlerBlock(t *testing.T, text, label string) string {
Expand Down