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
36 changes: 24 additions & 12 deletions cmd/spinloop/dashboard_detail.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ func (m *dashModel) openDetail() tea.Cmd {
m.detailLogGen++
m.detailLogBusy = false
m.detailLogFollow = true
m.detailLogClip = false
m.detailLogOffset = daemon.TailLog
m.detailLogContent = ""
m.detailLogNote = ""
Expand All @@ -69,11 +70,11 @@ func (m *dashModel) openDetail() tea.Cmd {
// updateDetailKey answers the keys the detail view reads: the same
// start/stop/abort the grid applies to the node under the cursor — which is
// the node in view, since the cursor never moves while the view is open —
// escape to close it, and f to pause or resume the log tail. Quit lives on
// the grid only: leaving the dashboard from inside the detail view means
// escaping back to it first, the same as any other nested screen. Navigation
// and the manual refresh are the grid's own keys and are not read here
// either.
// escape to close it, f to pause or resume the log tail, and w to turn log
// wrapping on and off. Quit lives on the grid only: leaving the dashboard
// from inside the detail view means escaping back to it first, the same as
// any other nested screen. Navigation and the manual refresh are the grid's
// own keys and are not read here either.
func (m *dashModel) updateDetailKey(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "esc":
Expand All @@ -83,6 +84,8 @@ func (m *dashModel) updateDetailKey(msg tea.KeyMsg) tea.Cmd {
// standing node) for the life of the view; flipping the flag is all
// a resume needs — the next tick picks it straight back up.
m.detailLogFollow = !m.detailLogFollow
case "w":
m.detailLogClip = !m.detailLogClip
case "s":
if len(m.entries) > 0 && m.actions[m.cursor].verb == "" {
return m.beginAction("start")
Expand Down Expand Up @@ -210,7 +213,8 @@ func detailLogLines(content, note string) []string {

// detailView draws the full-screen frame: header, the node's metrics
// unclipped to terminal width, a divider, the tailed log filling the
// remaining rows, a divider, and the footer naming the view's keys.
// remaining rows — wrapped, by default, the way a terminal wraps its own
// output — a divider, and the footer naming the view's keys.
func (m dashModel) detailView() string {
w := m.effWidth()
e := m.entries[m.cursor]
Expand All @@ -226,6 +230,13 @@ func (m dashModel) detailView() string {
}
detail += " log: " + logState
}
// Wrapping is a rendering fact, not a polling one: it holds the pane
// together for a standing node's note as much as for a live tail.
wrapState := "on"
if m.detailLogClip {
wrapState = "off"
}
detail += " wrap: " + wrapState
header := dashTitleBar("fleet dashboard · node: "+e.name, detail, w)
divider := strings.Repeat("─", w)

Expand All @@ -234,19 +245,20 @@ func (m dashModel) detailView() string {
content = append(content, "")
}
logLines := detailLogLines(m.detailLogContent, m.detailLogNote)
if len(logLines) > avail {
logLines = logLines[len(logLines)-avail:]
// The pane counts rows, not lines: a wrapped line contributes as many
// rows as it needs, and the tail's newest rows sit at the bottom.
logRows := dashLogRows(logLines, w, !m.detailLogClip)
if len(logRows) > avail {
logRows = logRows[len(logRows)-avail:]
}

parts := make([]string, 0, len(content)+len(logLines)+4)
parts := make([]string, 0, len(content)+len(logRows)+4)
parts = append(parts, header, divider)
for _, line := range content {
parts = append(parts, dashClip(line, w))
}
parts = append(parts, divider)
for _, line := range logLines {
parts = append(parts, dashClip(line, w))
}
parts = append(parts, logRows...)
parts = append(parts, divider)
parts = append(parts, m.footerLine(w, m.detailKeys()))
return strings.Join(parts, "\n")
Expand Down
4 changes: 2 additions & 2 deletions cmd/spinloop/dashboard_keep_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ func TestDashKeepHintOnlyWhereItDrivesSomething(t *testing.T) {
if got, want := m.gridKeys(), "↑↓←→ move s start k keep g format r refresh q quit"; got != want {
t.Errorf("grid hint:\ngot: %q\nwant: %q", got, want)
}
if got, want := m.detailKeys(), "esc back s start k keep f follow"; got != want {
if got, want := m.detailKeys(), "esc back s start k keep f follow w wrap"; got != want {
t.Errorf("detail hint:\ngot: %q\nwant: %q", got, want)
}
})
Expand All @@ -386,7 +386,7 @@ func TestDashKeepHintOnlyWhereItDrivesSomething(t *testing.T) {
if got, want := m.gridKeys(), "↑↓←→ move k keep x stop g format r refresh q quit"; got != want {
t.Errorf("grid hint:\ngot: %q\nwant: %q", got, want)
}
if got, want := m.detailKeys(), "esc back k keep x stop f follow"; got != want {
if got, want := m.detailKeys(), "esc back k keep x stop f follow w wrap"; got != want {
t.Errorf("detail hint:\ngot: %q\nwant: %q", got, want)
}
})
Expand Down
3 changes: 2 additions & 1 deletion cmd/spinloop/dashboard_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ type dashModel struct {
detailLogGen int // bumped on every open, so a reply from a closed or superseded view is discarded
detailLogBusy bool // a log round is in flight for the node in view
detailLogFollow bool // whether the tick is allowed to start a round; f toggles it
detailLogClip bool // whether long log lines are cut rather than wrapped; w toggles it, and the view opens wrapped, so the zero value is the default
detailLogOffset int64 // where the next log round resumes from
detailLogContent string // the tailed content, trimmed to what the pane can show
detailLogNote string // why the pane has no content — empty once it does
Expand Down Expand Up @@ -835,7 +836,7 @@ func (m dashModel) detailKeys() string {
if m.canAbort() {
parts = append(parts, "a abort")
}
return strings.Join(append(parts, "f follow"), dashHintGap)
return strings.Join(append(parts, "f follow", "w wrap"), dashHintGap)
}

// indexOf finds an entry by name. Fleet-file names are unique — the fleet
Expand Down
43 changes: 42 additions & 1 deletion cmd/spinloop/dashboard_render.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,55 @@ func dashClampScroll(scrollRow, entries, w, h int) int {
// dashClip hard-cuts a line at width display columns. It is a cut, not a wrap
// — wrapping would push a bar line onto two rows and break the block's
// alignment — and it is ANSI-aware (ansi.CutWc), so a colour code mid-line
// survives and a wide character counts as two.
// survives and a wide character counts as two. Log lines are the one pane
// content that does wrap rather than cut; see dashWrapLines.
func dashClip(line string, width int) string {
if lipgloss.Width(line) <= width {
return line
}
return ansi.CutWc(line, 0, width)
}

// dashWrapLines breaks a line into rows of at most width display columns the
// way a terminal breaks its own output: hard at the column, not on words —
// log lines are data, not prose, and a JSON blob or a prompt dump may carry
// no space to break on at all. Wide characters count two columns, and a
// cluster that would straddle the edge starts the next row whole rather than
// being split. It sits beside dashClip because the two answer the pane's two
// questions: a bar must be cut to keep its shape, a log line must be wrapped
// to keep its text. A width that cannot hold a single wide glyph leaves the
// rest of the line on one row — there is no column to break at.
func dashWrapLines(line string, width int) []string {
if width < 1 || ansi.StringWidth(line) <= width {
return []string{line}
}
var rows []string
for ansi.StringWidth(line) > width {
head := ansi.TruncateWc(line, width, "")
if head == "" {
return append(rows, line)
}
rows = append(rows, head)
line = ansi.TruncateLeftWc(line, width, "")
}
return append(rows, line)
}

// dashLogRows expands a pane's log lines into the rows to draw at the pane's
// width: wrapped rows where wrapping is on, single cut rows — the old
// behaviour, one row per line — where it is off.
func dashLogRows(lines []string, width int, wrap bool) []string {
rows := make([]string, 0, len(lines))
for _, line := range lines {
if wrap {
rows = append(rows, dashWrapLines(line, width)...)
continue
}
rows = append(rows, dashClip(line, width))
}
return rows
}

// dashHintGap separates one key-help entry from the next, and is what
// dashKeyHints splits a hint line on.
const dashHintGap = " "
Expand Down
64 changes: 64 additions & 0 deletions cmd/spinloop/dashboard_render_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package main

import (
"strings"
"testing"
)

// A wrapped line is the terminal's own break: hard at the column, wide
// clusters whole on the next row, and a line the pane can hold arriving as
// the single row it always was.
func TestDashWrapLines(t *testing.T) {
cases := []struct {
name string
line string
width int
want []string
}{
{"fits as one row", "short", 10, []string{"short"}},
{"exact multiple", "abcdefgh", 4, []string{"abcd", "efgh"}},
{"remainder row", "abcdefghij", 4, []string{"abcd", "efgh", "ij"}},
{"wide cluster moves whole", "a漢b", 2, []string{"a", "漢", "b"}},
{"empty line stays a row", "", 10, []string{""}},
{"zero width draws the line whole", "漢漢", 0, []string{"漢漢"}},
{"pane narrower than one wide glyph", "漢漢", 1, []string{"漢漢"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := dashWrapLines(tc.line, tc.width)
if len(got) != len(tc.want) {
t.Fatalf("rows = %q, want %q", got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("rows = %q, want %q", got, tc.want)
}
}
})
}
}

// Nothing is lost or duplicated across the break: the rows reassemble the
// line exactly.
func TestDashWrapLinesLosesNothing(t *testing.T) {
line := strings.Repeat("漢x", 60)
rows := dashWrapLines(line, 7)
if strings.Join(rows, "") != line {
t.Errorf("rejoining the rows did not recover the line:\n%q", rows)
}
}

// Off is today's clip: one row per line, cut at the pane's width.
func TestDashLogRowsClippedVersusWrapped(t *testing.T) {
lines := []string{strings.Repeat("x", 10), "y"}

wrapped := dashLogRows(lines, 4, true)
if len(wrapped) != 4 || wrapped[0] != "xxxx" || wrapped[3] != "y" {
t.Errorf("wrapped rows = %q", wrapped)
}

clipped := dashLogRows(lines, 4, false)
if len(clipped) != 2 || clipped[0] != "xxxx" || clipped[1] != "y" {
t.Errorf("clipped rows = %q", clipped)
}
}
Loading
Loading