git/orchard/main
tui/internal/ui/loading.go
397 lines10 KB last change raw · history
package ui

import (
	"encoding/base64"
	"fmt"
	"io"
	"os"
	"regexp"
	"strings"
	"time"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/x/ansi"
)

// spinnerFrames is the classic braille cycle. It reads as motion even in a
// terminal whose font lacks the box-drawing glyphs, because the dots move.
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}

// spinnerTickMsg advances the spinner.
type spinnerTickMsg struct{}

// spinnerTick schedules the next frame. 80ms is fast enough to look alive and
// slow enough not to cost anything.
func spinnerTick() tea.Cmd {
	return tea.Tick(80*time.Millisecond, func(time.Time) tea.Msg {
		return spinnerTickMsg{}
	})
}

// loadingModal is a waiting state that says what it is waiting for.
//
// A spinner with no label is a lie: it implies progress that may not exist.
type loadingModal struct {
	title  string
	detail string
	frame  int
	until  time.Time
}

func newLoading(title, detail string) *loadingModal {
	return &loadingModal{title: title, detail: detail}
}

func (l *loadingModal) Update(msg tea.Msg) (modal, tea.Cmd) {
	switch msg.(type) {
	case spinnerTickMsg:
		// advanced by the model, which owns the tick chain
		l.frame++
		return l, nil
	case tea.KeyMsg:
		// waiting is not cancellable yet; esc must not pretend otherwise
		return l, nil
	}
	return l, nil
}

func (l *loadingModal) View(width, height int) string {
	spin := Accent.Render(spinnerFrames[l.frame%len(spinnerFrames)])
	body := Title.Render(l.title) + "\n\n" +
		"  " + spin + "  " + Text.Render(l.detail)
	return place(Box.Render(body), width, height)
}

// textModal is a scrollable block of pre-styled lines.
//
// An advisory, a full audit run, a package's long-form detail: anything that
// would otherwise mean opening a link and reading it elsewhere.
type textModal struct {
	title   string
	lines   []string
	scroll  int
	loading bool
	detail  string
	footer  string
	frame   int

	//: incremental search. A command queue runs to hundreds of lines and the
	//: question is usually "does this touch /etc", not "read all of it".
	//: whether the view tracks the end as new lines arrive. Scrolling up stops
	//: it -- someone reading back does not want the page pulled away -- and G
	//: starts it again.
	follow bool
	//: set once the view has been shown, so "at the end" can be judged
	rows int
	//: colour the lines by how alarming they look, and offer to copy them
	logMode bool

	searching bool
	query     string
	matches   []int
	matchAt   int
	searchErr string
}

// logLine colours a line by how alarming it looks.
//
// A build's output is mostly routine, and the two lines that matter are the
// ones that say something went wrong. Reading a wall of undifferentiated grey
// to find them is the reason log viewers colour at all.
func logLine(line string) string {
	plain := ansi.Strip(line)
	switch {
	case errorLine.MatchString(plain):
		return Err.Render(line)
	case warnLine.MatchString(plain):
		return Warn.Render(line)
	case strings.HasPrefix(strings.TrimSpace(plain), "==>"):
		// makepkg's own headings: structure, not a problem
		return Title.Render(line)
	default:
		return Muted.Render(line)
	}
}

var (
	errorLine = regexp.MustCompile(
		`(?i)\b(error|failed|failure|fatal|cannot|panic|abort|denied|refused|no such file)\b`)
	warnLine = regexp.MustCompile(
		`(?i)\b(warning|warn|deprecated|skipped|missing|conflict)\b`)
)

// copy puts the whole view on the clipboard.
//
// OSC 52 rather than a helper program: it travels over SSH, needs nothing
// installed, and cannot fail because a tool is absent. Terminals cap how much
// they will accept, so this is bounded and says when it truncated.
// clipboard is where a copy is written. A variable so tests do not spray
// ninety kilobytes of base64 across the test runner's output.
var clipboard io.Writer = os.Stdout

func (t *textModal) copy() tea.Cmd {
	const limit = 96 * 1024
	text := strings.Join(t.lines, "\n")
	truncated := false
	if len(text) > limit {
		text, truncated = text[:limit], true
	}
	return func() tea.Msg {
		fmt.Fprint(clipboard, osc52(text))
		if truncated {
			return copiedMsg{what: "copied (truncated; full log on disk)"}
		}
		return copiedMsg{what: fmt.Sprintf("copied %d lines", len(t.lines))}
	}
}

// osc52 wraps text in the terminal's clipboard sequence.
func osc52(text string) string {
	return "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\a"
}

// newText opens at the top. A log is the exception and says so by asking to
// follow -- help that opened scrolled to its last line is help nobody reads.
func newText(title string) *textModal {
	return &textModal{title: title}
}

func newTextLoading(title, detail string) *textModal {
	return &textModal{title: title, loading: true, detail: detail}
}

// textBodyMsg delivers the lines once a fetch finishes.
type textBodyMsg struct {
	title string
	lines []string
	foot  string
	err   error
}

func (t *textModal) Update(msg tea.Msg) (modal, tea.Cmd) {
	switch msg := msg.(type) {
	case spinnerTickMsg:
		// advanced by the model, which owns the tick chain
		t.frame++
		return t, nil

	case textBodyMsg:
		if msg.title != t.title {
			return t, nil // a stale fetch for a modal we have moved on from
		}
		t.loading = false
		if msg.err != nil {
			t.lines = []string{Err.Render("could not load this"), "",
				Muted.Render(msg.err.Error())}
			return t, nil
		}
		t.lines, t.footer = msg.lines, msg.foot
		return t, nil

	case tea.KeyMsg:
		if t.searching {
			return t.updateSearch(msg)
		}
		switch msg.String() {
		case "q", "esc", "ctrl+c":
			return nil, nil
		case "/":
			t.searching = true
			t.query = ""
			t.searchErr = ""
		case "n":
			t.nextMatch(1)
		case "N":
			t.nextMatch(-1)
		case "j", "down":
			t.scroll++
		case "k", "up":
			t.follow = false
			t.scroll--
		case "ctrl+d", "pgdown":
			t.scroll += 10
		case "ctrl+u", "pgup":
			t.follow = false
			t.scroll -= 10
		case "g", "home":
			t.follow = false
			t.scroll = 0
		case "G", "end":
			t.follow = true
			t.scroll = 1 << 30
		case "f":
			// resume following the end, for a view that was scrolled away
			t.follow = !t.follow
			if t.follow {
				t.scroll = 1 << 30
			}
		case "c":
			return t, t.copy()
		}
	}
	return t, nil
}

// updateSearch handles keys while the query is being typed.
func (t *textModal) updateSearch(msg tea.KeyMsg) (modal, tea.Cmd) {
	switch msg.Type {
	case tea.KeyEsc:
		t.searching = false
		t.query = ""
		t.matches = nil
		t.searchErr = ""
		return t, nil
	case tea.KeyEnter:
		t.searching = false
		if t.query != "" && len(t.matches) == 0 {
			t.searchErr = "no match"
		}
		// Asking for something and being carried past it is not an answer:
		// a committed search stops following, so the hit stays on screen.
		if t.query != "" {
			t.follow = false
		} else {
			t.follow = true
		}
		return t, nil
	case tea.KeyBackspace:
		if t.query != "" {
			runes := []rune(t.query)
			t.query = string(runes[:len(runes)-1])
		}
		t.recompute()
		return t, nil
	case tea.KeyRunes, tea.KeySpace:
		t.query += string(msg.Runes)
		if msg.Type == tea.KeySpace {
			t.query += " "
		}
		t.recompute()
		return t, nil
	}
	return t, nil
}

// recompute refreshes the match list and jumps to the first hit.
func (t *textModal) recompute() {
	t.searchErr = ""
	t.matches = t.matches[:0]
	if t.query == "" {
		return
	}
	needle := strings.ToLower(t.query)
	for i, line := range t.lines {
		if strings.Contains(strings.ToLower(ansi.Strip(line)), needle) {
			t.matches = append(t.matches, i)
		}
	}
	t.matchAt = 0
	if len(t.matches) > 0 {
		t.scroll = t.matches[0]
	}
}

func (t *textModal) nextMatch(step int) {
	if len(t.matches) == 0 {
		return
	}
	t.matchAt = (t.matchAt + step + len(t.matches)) % len(t.matches)
	t.scroll = t.matches[t.matchAt]
}

func (t *textModal) View(width, height int) string {
	inner := width - 8
	if inner < 20 {
		inner = 20
	}
	rows := height - 10
	if rows < 3 {
		rows = 3
	}

	if t.loading {
		spin := Accent.Render(spinnerFrames[t.frame%len(spinnerFrames)])
		body := Title.Render(t.title) + "\n\n" + "  " + spin + "  " + Text.Render(t.detail)
		return place(Box.Render(body), width, height)
	}

	var b strings.Builder
	b.WriteString(Title.Render(t.title))
	b.WriteString("\n\n")

	total := len(t.lines)
	// The reader's place is theirs unless they are following the end, in which
	// case new lines carry the view with them.
	t.rows = rows
	if t.scroll > total-rows {
		t.scroll = total - rows
	}
	if t.scroll < 0 {
		t.scroll = 0
	}
	// Following applies only to an unfiltered view. With a query live the
	// reader is looking at a result, and this would drag them back to the end
	// on every render -- which is what made a search report "3 matches" while
	// showing the wrong part of the log.
	if t.follow && !t.searching && t.query == "" {
		t.scroll = max(0, total-rows)
	}
	end := min(total, t.scroll+rows)
	for _, line := range t.lines[t.scroll:end] {
		if t.logMode {
			line = logLine(line)
		}
		b.WriteString(ansi.Truncate(line, inner, "…") + "\n")
	}
	for i := end - t.scroll; i < rows; i++ {
		b.WriteString("\n")
	}

	pos := fmt.Sprintf("[%d/%d]", t.scroll+1, max(1, total))

	// Built from what the view actually offers, rather than the same three
	// keys written out in four places with different accuracy.
	keys := []string{"[j/k] scroll", "[/] search"}
	if !t.follow {
		keys = append([]string{"[f] follow"}, keys...)
	} else {
		keys = append(keys, "[f] unfollow")
	}
	if t.logMode {
		keys = append(keys, "[c] copy")
	}
	keys = append(keys, "[esc] close")
	hint := strings.Join(keys, "  •  ")

	if len(t.matches) > 0 {
		hint = fmt.Sprintf("[n/N] %d/%d  •  ", t.matchAt+1, len(t.matches)) + hint
	} else if t.searchErr != "" {
		hint = Warn.Render(t.searchErr) + "  •  " + hint
	}
	// The position is right-aligned and must survive: a long hint (a full log
	// path, say) would otherwise push it off the end.
	room := inner - len(pos) - 2
	if room < 8 {
		room = 8
	}
	hint = ansi.Truncate(hint, room, "…")
	gap := max(1, inner-ansi.StringWidth(hint)-len(pos))
	b.WriteString("\n" + Desc.Render(hint) + strings.Repeat(" ", gap) + Muted.Render(pos))

	if t.searching || t.query != "" {
		cursor := ""
		if t.searching {
			cursor = Accent.Render("_")
		}
		label := Muted.Render("/") + Text.Render(t.query) + cursor
		if hits := len(t.matches); hits > 0 {
			// Was glamour markup, which lipgloss renders literally -- the
			// footer read "{underline}2 match(es){/}".
			label += "   " + Accent.Render(fmt.Sprintf("%d match(es)", hits))
		}
		b.WriteString("\n" + ansi.Truncate(label, inner, "…"))
	}

	return place(Box.Render(b.String()), width, height)
}