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
46 changes: 44 additions & 2 deletions exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import (
"fmt"
"os"
"os/exec"
"os/signal"
"strings"
"syscall"

"golang.org/x/term"
)
Expand Down Expand Up @@ -63,7 +65,7 @@ func runScript(container, script string, extraArgs []string) error {
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
return runWithSignalForwarding(cmd)
}

// runExec opens an interactive shell (or runs a command) in a module's container.
Expand Down Expand Up @@ -92,9 +94,49 @@ func runExec(m *Module, extraArgs []string) error {
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
return runWithSignalForwarding(cmd)
}

func isTTY() bool {
return term.IsTerminal(int(os.Stdin.Fd()))
}

// runWithSignalForwarding starts cmd and blocks until it exits, forwarding
// SIGINT/SIGTERM to it instead of letting Go's default handling kill this
// process immediately. Without this, a single Ctrl+C would kill the devops-cli
// wrapper right away while a child like `docker compose up` kept running its
// own graceful shutdown in the background, printing to the shared terminal
// after control had already returned to the shell. A second signal escalates
// to a hard kill, matching the usual "one Ctrl+C to stop gracefully, two to
// force it" shell convention.
func runWithSignalForwarding(cmd *exec.Cmd) error {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(sigCh)
Comment on lines +112 to +115

if err := cmd.Start(); err != nil {
return err
}

done := make(chan struct{})
go func() {
forwarded := false
for {
select {
case sig := <-sigCh:
if !forwarded {
forwarded = true
_ = cmd.Process.Signal(sig)
} else {
_ = cmd.Process.Kill()
}
case <-done:
return
}
}
}()

err := cmd.Wait()
close(done)
return err
}
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ func runAllCommand(command string, extraArgs []string) int {
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
if err := runWithSignalForwarding(cmd); err != nil {
return 1
}
return 0
Expand Down
61 changes: 52 additions & 9 deletions tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,20 @@ type panel struct {
autoScroll bool
status string // "running" | "done" | "failed"
exitCode int
proc *os.Process
}

// signal delivers sig to the panel's process if it's still running. Used to
// stop a panel's subprocess (e.g. `docker compose up`) instead of leaving it
// running in the background after the TUI quits.
func (p *panel) signal(sig os.Signal) {
p.mu.RLock()
proc := p.proc
running := p.status == "running"
p.mu.RUnlock()
if proc != nil && running {
_ = proc.Signal(sig)
}
}

func newPanel(label, cmd string) *panel {
Expand Down Expand Up @@ -91,6 +105,9 @@ func runPanel(idx int, p *panel, ch chan<- tea.Msg) {
ch <- exitMsg{idx, 1}
return
}
p.mu.Lock()
p.proc = cmd.Process
p.mu.Unlock()
_ = w.Close()

sc := bufio.NewScanner(r)
Expand Down Expand Up @@ -143,13 +160,14 @@ func statusFrame(p *panel, tick int) string {
// ── Model ────────────────────────────────────────────────────────────────────

type model struct {
panels []*panel
focus int
width int
height int
tick int
ch chan tea.Msg
running int
panels []*panel
focus int
width int
height int
tick int
ch chan tea.Msg
running int
stopping bool
}

func newModel(panels []*panel) model {
Expand Down Expand Up @@ -207,16 +225,38 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
p.mu.Unlock()
m.running--
if m.stopping && m.running == 0 {
return m, tea.Quit
}
return m, listenCh(m.ch)

case allDoneMsg:
if m.stopping {
return m, tea.Quit
}
return m, nil

case tea.KeyMsg:
p := m.panels[m.focus]
contentH := m.height - 2
switch msg.String() {
case "q", "Q", "ctrl+c":
if m.running == 0 {
return m, tea.Quit
}
if !m.stopping {
Comment on lines 243 to +247
// First press: ask running panels to stop gracefully and
// wait for them to actually exit before quitting the TUI.
m.stopping = true
for _, panel := range m.panels {
panel.signal(os.Interrupt)
}
return m, nil
}
// Second press: force-kill anything still running and quit now.
for _, panel := range m.panels {
panel.signal(os.Kill)
}
return m, tea.Quit
case "tab", "right", "l":
m.focus = (m.focus + 1) % n
Expand Down Expand Up @@ -328,9 +368,12 @@ func (m model) View() string {
}
}
var barText string
if m.running == 0 {
switch {
case m.running == 0:
barText = fmt.Sprintf(" Done: %d/%d succeeded [q]uit [tab/←→/h/l]focus [↑↓/PgUp/PgDn/j/k]scroll [g/G]top/end", okCount, n)
} else {
case m.stopping:
barText = fmt.Sprintf(" Stopping (%d active)… press again to force quit", m.running)
default:
barText = fmt.Sprintf(" Running (%d active) [q]uit [tab/←→/h/l]focus [↑↓/PgUp/PgDn/j/k]scroll [g/G]top/end", m.running)
}
sb.WriteString(barStyle.Render(fitWidth(barText, m.width)))
Expand Down