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
14 changes: 9 additions & 5 deletions internal/tui/cloudinit.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,13 +142,17 @@ func cloudInitFraction(status string) float64 {
// hand-rolled track. ViewAs draws a fixed computed value, so none of the
// component's animation machinery runs. The gradient and width handling
// still come free.
//
// "error", "disabled" and "not-run" draw no bar. A bar reads as progress
// toward completion, and none of those three states is heading there; a full
// bar next to a red "failed" label previously said the opposite of the
// label.
func cloudInitBar(p progress.Model, status string) string {
if status == "" || cloudInitDone(status) && status != "running" {
if status == "done" {
return p.ViewAs(1)
}
switch status {
case "waiting", "running", "done":
return p.ViewAs(cloudInitFraction(status))
}
return p.ViewAs(cloudInitFraction(status))
return ""
}

// newCloudInitProgress is the bar used for cloud-init staging.
Expand Down
20 changes: 20 additions & 0 deletions internal/tui/cloudinit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package tui

import (
"os/exec"
"strings"
"testing"
)

Expand Down Expand Up @@ -98,6 +99,25 @@ func TestDecodeCloudInitStatus(t *testing.T) {
}
}

// TestCloudInitBarSuppressedOnTerminalFailure pins the fix for a full bar
// rendering next to a red "failed" label. "error", "disabled" and "not-run"
// are terminal states that never reach 100% install; cloudInitBar must draw
// nothing for them. "running" still gets a bar, so the suppression is
// specific to the non-success terminal states, not to every status.
func TestCloudInitBarSuppressedOnTerminalFailure(t *testing.T) {
p := newCloudInitProgress()

for _, status := range []string{"error", "disabled", "not-run"} {
if got := cloudInitBar(p, status); got != "" {
t.Errorf("cloudInitBar(%q) = %q, want no bar", status, got)
}
}

if got := cloudInitBar(p, "running"); !strings.Contains(got, "█") {
t.Errorf("cloudInitBar(%q) = %q, want a bar", "running", got)
}
}

// TestOutputIgnoresStderrNoise exercises the mechanism checkCloudInit relies
// on: Cmd.Output(), not Cmd.CombinedOutput(). A login banner or stray
// warning an ssh session writes to stderr never reaches the decoder. An ssh
Expand Down
6 changes: 2 additions & 4 deletions internal/tui/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (

tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"

"github.com/novusedge/stoat/internal/core"
)
Expand Down Expand Up @@ -242,10 +243,7 @@ func brokenReason(s string) string {
s = s[:i]
}
const max = 60
if len(s) > max {
s = s[:max-1] + "…"
}
return s
return ansi.Truncate(s, max, "…")
}

// viewList renders the list screen's body: everything below the banner.
Expand Down
7 changes: 3 additions & 4 deletions internal/tui/progressbar.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"fmt"
"time"

"github.com/charmbracelet/x/ansi"

"github.com/novusedge/stoat/internal/core"
)

Expand All @@ -26,10 +28,7 @@ func progressLabel(p Progress, width int) string {
}
out := bar(p.Frac, width) + " " + dimStyle.Render(num)
if l := p.Label; l != "" {
if len(l) > provLabelMax {
l = l[:provLabelMax-1] + "…"
}
out += dimStyle.Render(" · " + l)
out += dimStyle.Render(" · " + ansi.Truncate(l, provLabelMax, "…"))
}
return out
}
Expand Down
7 changes: 2 additions & 5 deletions internal/tui/provstep.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"time"

"charm.land/bubbles/v2/spinner"
"github.com/charmbracelet/x/ansi"

"github.com/novusedge/stoat/internal/core"
"github.com/novusedge/stoat/internal/sshx"
Expand Down Expand Up @@ -128,11 +129,7 @@ func provLine(spin spinner.Model, name string, st provState, now time.Time) stri
case st.hasProg:
out += " " + progressLabel(st.prog, provBarWidth)
case st.last != "":
l := st.last
if len(l) > provMaxLast {
l = l[:provMaxLast-1] + "…"
}
out += dimStyle.Render(" · " + l)
out += dimStyle.Render(" · " + ansi.Truncate(st.last, provMaxLast, "…"))
}
return out + dimStyle.Render(" · "+provElapsed(now.Sub(st.start)))
}
Expand Down
42 changes: 42 additions & 0 deletions internal/tui/truncate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package tui

import (
"strings"
"testing"
"time"
"unicode/utf8"

"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)

// longMultiByte is a run of 2-byte runes, so every rune boundary sits at an
// even byte offset. Every truncation budget in this package (max 60,
// provMaxLast 34, provLabelMax 18) cuts at an odd byte index (budget - 1), so
// a byte-index slice always lands mid-rune here. ansi.Truncate cuts on rune
// and cell boundaries instead.
var longMultiByte = strings.Repeat("é", 40)

func TestTruncationKeepsValidUTF8(t *testing.T) {
outs := map[string]string{
"brokenReason": brokenReason(longMultiByte),
"provLine": ansi.Strip(provLine(newSpinner(), "vm", provState{last: longMultiByte}, time.Now())),
"progressLabel": ansi.Strip(progressLabel(Progress{Frac: 0.5, Label: longMultiByte}, provBarWidth)),
}
for name, out := range outs {
if !utf8.ValidString(out) {
t.Errorf("%s: output is not valid UTF-8: %q", name, out)
}
}
}

// TestBrokenReasonStaysWithinWidthBudget pins brokenReason's own budget,
// the one truncation site that returns the truncated string directly rather
// than wrapping it with other row content.
func TestBrokenReasonStaysWithinWidthBudget(t *testing.T) {
const budget = 60
out := brokenReason(longMultiByte)
if w := lipgloss.Width(out); w > budget {
t.Errorf("brokenReason width = %d, want <= %d: %q", w, budget, out)
}
}
Loading