a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
706 lines
18 KiB
Go
706 lines
18 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/list"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"github.com/zzet/gortex/internal/daemon"
|
|
"github.com/zzet/gortex/internal/progress"
|
|
)
|
|
|
|
var osUserHomeDir = os.UserHomeDir
|
|
|
|
// ---- styles ------------------------------------------------------------
|
|
|
|
var (
|
|
tuiAccent = lipgloss.NewStyle().Foreground(progress.ColorAccent)
|
|
tuiTitle = lipgloss.NewStyle().Bold(true).Foreground(progress.ColorFg)
|
|
tuiSub = lipgloss.NewStyle().Foreground(progress.ColorFgDim)
|
|
tuiHeading = lipgloss.NewStyle().Bold(true).Foreground(progress.ColorFgDim)
|
|
tuiCount = lipgloss.NewStyle().Foreground(progress.ColorMuted)
|
|
tuiKey = lipgloss.NewStyle().Bold(true).Foreground(progress.ColorFg)
|
|
tuiVal = lipgloss.NewStyle().Foreground(progress.ColorFgDim)
|
|
tuiSelected = lipgloss.NewStyle().Foreground(progress.ColorAccent).Bold(true)
|
|
tuiHint = lipgloss.NewStyle().Foreground(progress.ColorMuted)
|
|
tuiErrInline = lipgloss.NewStyle().Foreground(progress.ColorErr)
|
|
tuiKeybindKey = lipgloss.NewStyle().Bold(true).Foreground(progress.ColorFg)
|
|
tuiKeybindHint = lipgloss.NewStyle().Foreground(progress.ColorMuted)
|
|
)
|
|
|
|
// ---- repo item / delegate ---------------------------------------------
|
|
|
|
type repoItem struct {
|
|
name string
|
|
workspace string
|
|
memory string
|
|
memBytes uint64
|
|
files int
|
|
nodes int
|
|
edges int
|
|
path string
|
|
}
|
|
|
|
func (r repoItem) FilterValue() string {
|
|
// Concatenate every searchable field so users can match by name,
|
|
// workspace, or path fragment via the bubbles list's `/` filter.
|
|
return r.name + " " + r.workspace + " " + r.path
|
|
}
|
|
|
|
type repoDelegate struct{ width int }
|
|
|
|
func (d repoDelegate) Height() int { return 1 }
|
|
func (d repoDelegate) Spacing() int { return 0 }
|
|
func (d repoDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
|
|
func (d repoDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
|
|
r, ok := listItem.(repoItem)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
cursor := " "
|
|
style := tuiVal
|
|
if index == m.Index() {
|
|
cursor = tuiAccent.Render("▸ ")
|
|
style = tuiSelected
|
|
}
|
|
|
|
// Adaptive column widths: drop the path column entirely under ~120 cols.
|
|
nameW := 26
|
|
wsW := 18
|
|
memW := 10
|
|
statsW := 32
|
|
pathOK := d.width >= 120
|
|
pathW := 0
|
|
if pathOK {
|
|
pathW = d.width - (2 + nameW + 2 + wsW + 2 + memW + 3 + statsW + 3)
|
|
if pathW < 20 {
|
|
pathW = 20
|
|
}
|
|
}
|
|
|
|
stats := fmt.Sprintf("%4df · %9sn · %9se", r.files, humanizeInt(r.nodes), humanizeInt(r.edges))
|
|
row := fmt.Sprintf("%-*s %-*s %*s %s",
|
|
nameW, truncate(r.name, nameW),
|
|
wsW, truncate(r.workspace, wsW),
|
|
memW, r.memory,
|
|
stats,
|
|
)
|
|
if pathOK {
|
|
row += " " + tuiHint.Render(truncate(shortenPath(r.path), pathW))
|
|
}
|
|
|
|
fmt.Fprint(w, cursor+style.Render(row))
|
|
}
|
|
|
|
// ---- model -------------------------------------------------------------
|
|
|
|
type tuiTickMsg time.Time
|
|
type tuiStatusMsg daemon.StatusResponse
|
|
type tuiFetchErrMsg struct{ err error }
|
|
|
|
type statusTUI struct {
|
|
interval time.Duration
|
|
width int
|
|
height int
|
|
|
|
status daemon.StatusResponse
|
|
hasStatus bool
|
|
err error
|
|
lastUpdate time.Time
|
|
meshTick int
|
|
|
|
repos list.Model
|
|
}
|
|
|
|
func newStatusTUI(interval time.Duration) statusTUI {
|
|
delegate := repoDelegate{}
|
|
l := list.New([]list.Item{}, delegate, 0, 0)
|
|
l.SetShowTitle(false)
|
|
l.SetShowStatusBar(true)
|
|
l.SetShowHelp(false)
|
|
l.SetShowPagination(true)
|
|
l.SetFilteringEnabled(true)
|
|
l.DisableQuitKeybindings()
|
|
l.Styles.NoItems = tuiHint
|
|
l.Styles.StatusBar = tuiHint
|
|
l.Styles.StatusEmpty = tuiHint
|
|
l.Styles.PaginationStyle = tuiHint
|
|
l.Styles.HelpStyle = tuiHint
|
|
l.SetStatusBarItemName("repo", "repos")
|
|
return statusTUI{interval: interval, repos: l}
|
|
}
|
|
|
|
func (m statusTUI) Init() tea.Cmd {
|
|
return tea.Batch(
|
|
tuiFetchCmd(),
|
|
tea.Tick(m.interval, func(t time.Time) tea.Msg { return tuiTickMsg(t) }),
|
|
tea.Tick(150*time.Millisecond, func(t time.Time) tea.Msg { return tuiMeshTickMsg(t) }),
|
|
)
|
|
}
|
|
|
|
type tuiMeshTickMsg time.Time
|
|
|
|
func tuiFetchCmd() tea.Cmd {
|
|
return func() tea.Msg {
|
|
st, err := fetchDaemonStatusForCLI()
|
|
if err != nil {
|
|
return tuiFetchErrMsg{err: err}
|
|
}
|
|
return tuiStatusMsg(st)
|
|
}
|
|
}
|
|
|
|
func (m statusTUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
m.repos.SetSize(m.reposListWidth(), m.reposListHeight())
|
|
// Update the delegate's width so each row knows whether to draw the path column.
|
|
m.repos.SetDelegate(repoDelegate{width: m.reposListWidth()})
|
|
|
|
case tuiTickMsg:
|
|
return m, tea.Batch(
|
|
tuiFetchCmd(),
|
|
tea.Tick(m.interval, func(t time.Time) tea.Msg { return tuiTickMsg(t) }),
|
|
)
|
|
|
|
case tuiMeshTickMsg:
|
|
m.meshTick++
|
|
return m, tea.Tick(150*time.Millisecond, func(t time.Time) tea.Msg { return tuiMeshTickMsg(t) })
|
|
|
|
case tuiStatusMsg:
|
|
m.status = daemon.StatusResponse(msg)
|
|
m.hasStatus = true
|
|
m.err = nil
|
|
m.lastUpdate = time.Now()
|
|
items := make([]list.Item, 0, len(m.status.TrackedRepos))
|
|
for _, r := range m.status.TrackedRepos {
|
|
items = append(items, repoItem{
|
|
name: firstNonEmpty(r.Prefix, r.Name),
|
|
workspace: workspaceLabel(r),
|
|
memory: humanizeBytes(r.Memory.TotalBytes),
|
|
memBytes: r.Memory.TotalBytes,
|
|
files: r.Files,
|
|
nodes: r.Nodes,
|
|
edges: r.Edges,
|
|
path: r.Path,
|
|
})
|
|
}
|
|
// Sort by total memory desc — biggest repos first, like top.
|
|
sort.Slice(items, func(i, j int) bool {
|
|
a, b := items[i].(repoItem), items[j].(repoItem)
|
|
if a.memBytes != b.memBytes {
|
|
return a.memBytes > b.memBytes
|
|
}
|
|
return a.name < b.name
|
|
})
|
|
m.repos.SetItems(items)
|
|
m.repos.SetSize(m.reposListWidth(), m.reposListHeight())
|
|
|
|
case tuiFetchErrMsg:
|
|
m.err = msg.err
|
|
m.lastUpdate = time.Now()
|
|
|
|
case tea.KeyMsg:
|
|
// Don't intercept keys while the list is in filter mode — user is
|
|
// typing the query.
|
|
if m.repos.FilterState() == list.Filtering {
|
|
break
|
|
}
|
|
switch msg.String() {
|
|
case "q", "ctrl+c", "esc":
|
|
return m, tea.Quit
|
|
}
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
m.repos, cmd = m.repos.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
func (m statusTUI) View() string {
|
|
if m.width == 0 {
|
|
return ""
|
|
}
|
|
// Inset every section by tuiPadding columns and weave explicit blank
|
|
// lines between sections so the mesh logo and section blocks have
|
|
// clear breathing room. Empty sections (e.g. no sessions yet) are
|
|
// elided along with their trailing blank.
|
|
var lines []string
|
|
push := func(s string) {
|
|
if s != "" {
|
|
lines = append(lines, indentBlock(s, tuiPadding))
|
|
}
|
|
}
|
|
gap := func(n int) {
|
|
for i := 0; i < n; i++ {
|
|
lines = append(lines, "")
|
|
}
|
|
}
|
|
|
|
gap(1) // top breathing room
|
|
push(m.renderHeader())
|
|
gap(2) // logo gets extra space below
|
|
if w := m.renderWorkspaces(); w != "" {
|
|
push(w)
|
|
gap(1)
|
|
}
|
|
push(m.renderRepos())
|
|
gap(1)
|
|
if s := m.renderSessions(); s != "" {
|
|
push(s)
|
|
gap(1)
|
|
}
|
|
if s := m.renderServers(); s != "" {
|
|
push(s)
|
|
gap(1)
|
|
}
|
|
if s := m.renderLSPRouter(); s != "" {
|
|
push(s)
|
|
gap(1)
|
|
}
|
|
push(m.renderFooter())
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// tuiPadding is the left inset (in columns) applied to every section.
|
|
const tuiPadding = 2
|
|
|
|
// indentBlock prefixes every non-empty line of s with n spaces.
|
|
func indentBlock(s string, n int) string {
|
|
if s == "" {
|
|
return s
|
|
}
|
|
pad := strings.Repeat(" ", n)
|
|
lines := strings.Split(s, "\n")
|
|
for i, line := range lines {
|
|
lines[i] = pad + line
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// ---- layout sections ---------------------------------------------------
|
|
|
|
func (m statusTUI) renderHeader() string {
|
|
mesh := miniMesh(m.meshTick)
|
|
right := m.headerRightCol()
|
|
// Give the logo room to breathe — 4 columns of gap, vertically centered
|
|
// on the right column so the title line sits on the second mesh row.
|
|
right = "\n" + right // shift down one row so "gortex daemon" lines up with the mesh middle
|
|
return lipgloss.JoinHorizontal(lipgloss.Top, mesh, " ", right)
|
|
}
|
|
|
|
func (m statusTUI) headerRightCol() string {
|
|
if m.err != nil && !m.hasStatus {
|
|
return lipgloss.JoinVertical(
|
|
lipgloss.Left,
|
|
tuiTitle.Render("gortex daemon"),
|
|
"",
|
|
tuiErrInline.Render("✗ "+m.err.Error()),
|
|
tuiHint.Render("retrying every "+m.interval.String()),
|
|
)
|
|
}
|
|
if !m.hasStatus {
|
|
return lipgloss.JoinVertical(
|
|
lipgloss.Left,
|
|
tuiTitle.Render("gortex daemon"),
|
|
"",
|
|
tuiHint.Render("connecting…"),
|
|
)
|
|
}
|
|
st := m.status
|
|
state := tuiAccent.Render("ready")
|
|
if !st.Ready {
|
|
state = lipgloss.NewStyle().Foreground(progress.ColorWarn).Render("warming")
|
|
}
|
|
row1 := tuiTitle.Render("gortex daemon")
|
|
row2 := strings.Join([]string{
|
|
tuiSub.Render(versionString(st.Version)),
|
|
tuiSub.Render("pid " + fmt.Sprintf("%d", st.PID)),
|
|
state,
|
|
tuiSub.Render("up " + humanizeDur(time.Duration(st.UptimeSeconds)*time.Second)),
|
|
}, tuiHint.Render(" · "))
|
|
row3 := strings.Join([]string{
|
|
tuiKey.Render(humanizeBytes(st.Runtime.Alloc)) + tuiSub.Render(" alloc"),
|
|
tuiKey.Render(humanizeInt(st.SearchBackend.DocCount)) + tuiSub.Render(" docs"),
|
|
tuiKey.Render(fmt.Sprintf("%d", st.Sessions)) + tuiSub.Render(" sessions"),
|
|
tuiKey.Render(fmt.Sprintf("%d", st.Runtime.NumGoroutine)) + tuiSub.Render(" goroutines"),
|
|
}, tuiHint.Render(" · "))
|
|
return lipgloss.JoinVertical(lipgloss.Left, row1, "", row2, row3)
|
|
}
|
|
|
|
func (m statusTUI) renderWorkspaces() string {
|
|
if !m.hasStatus || len(m.status.Workspaces) == 0 {
|
|
return ""
|
|
}
|
|
var lines []string
|
|
lines = append(lines, tuiHeading.Render("workspaces")+" "+tuiCount.Render(fmt.Sprintf("· %d", len(m.status.Workspaces))))
|
|
for _, ws := range m.status.Workspaces {
|
|
projects := ""
|
|
if len(ws.Projects) > 0 {
|
|
projects = " " + tuiHint.Render(strings.Join(ws.Projects, " · "))
|
|
}
|
|
stats := fmt.Sprintf("%2d %s · %s files · %s nodes · %s edges",
|
|
len(ws.Repos), pluralize(len(ws.Repos), "repo", "repos"),
|
|
humanizeInt(ws.Files), humanizeInt(ws.Nodes), humanizeInt(ws.Edges))
|
|
line := " " + tuiKey.Render(padRight(ws.Slug, 12)) + " " + tuiVal.Render(stats) + projects
|
|
lines = append(lines, line)
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func (m statusTUI) renderRepos() string {
|
|
if !m.hasStatus {
|
|
return ""
|
|
}
|
|
header := tuiHeading.Render("repos") + " " + tuiCount.Render(fmt.Sprintf("· %d", len(m.status.TrackedRepos)))
|
|
hint := tuiKeybindKey.Render("/") + tuiKeybindHint.Render(" filter ") +
|
|
tuiKeybindKey.Render("↑↓") + tuiKeybindHint.Render(" navigate")
|
|
row := lipgloss.JoinHorizontal(
|
|
lipgloss.Top,
|
|
header,
|
|
strings.Repeat(" ", maxInt(2, m.width-lipgloss.Width(header)-lipgloss.Width(hint))),
|
|
hint,
|
|
)
|
|
return row + "\n" + m.repos.View()
|
|
}
|
|
|
|
func (m statusTUI) renderSessions() string {
|
|
if !m.hasStatus || len(m.status.MCPSessions) == 0 {
|
|
return ""
|
|
}
|
|
var lines []string
|
|
lines = append(lines, tuiHeading.Render("sessions")+" "+tuiCount.Render(fmt.Sprintf("· %d", len(m.status.MCPSessions))))
|
|
for _, s := range m.status.MCPSessions {
|
|
client := s.ClientName
|
|
if client == "" {
|
|
client = "(unknown)"
|
|
}
|
|
if s.ClientVersion != "" {
|
|
client += " " + tuiHint.Render(s.ClientVersion)
|
|
}
|
|
dur := humanizeDur(time.Duration(s.ConnectedSecs) * time.Second)
|
|
shortID := truncate(s.ID, 16)
|
|
cwd := shortenPath(s.Cwd)
|
|
line := " " + tuiKey.Render(padRight(shortID, 16)) + " " +
|
|
tuiVal.Render(padRight(client, 22)) + " " +
|
|
tuiSub.Render(padRight(dur, 7)) + " " +
|
|
tuiHint.Render(cwd)
|
|
lines = append(lines, line)
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func (m statusTUI) renderServers() string {
|
|
if !m.hasStatus || len(m.status.ConfiguredServers) == 0 {
|
|
return ""
|
|
}
|
|
var lines []string
|
|
lines = append(lines, tuiHeading.Render("servers")+" "+tuiCount.Render(fmt.Sprintf("· %d", len(m.status.ConfiguredServers))))
|
|
for _, s := range m.status.ConfiguredServers {
|
|
var flags []string
|
|
if s.Local {
|
|
flags = append(flags, "local")
|
|
}
|
|
if s.Default {
|
|
flags = append(flags, "default")
|
|
}
|
|
if s.HasAuth {
|
|
flags = append(flags, "auth")
|
|
}
|
|
if len(s.Workspaces) > 0 {
|
|
flags = append(flags, "ws="+strings.Join(s.Workspaces, ","))
|
|
}
|
|
flagLine := ""
|
|
if len(flags) > 0 {
|
|
flagLine = " " + tuiHint.Render(strings.Join(flags, " · "))
|
|
}
|
|
line := " " + tuiKey.Render(padRight(s.Slug, 12)) + " " + tuiVal.Render(s.URL) + flagLine
|
|
lines = append(lines, line)
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func (m statusTUI) renderLSPRouter() string {
|
|
if !m.hasStatus || m.status.LSPRouter == nil {
|
|
return ""
|
|
}
|
|
r := m.status.LSPRouter
|
|
specs := r.EnabledSpecs
|
|
active := r.ActiveProviders
|
|
if len(specs) == 0 && len(active) == 0 {
|
|
return ""
|
|
}
|
|
var lines []string
|
|
header := tuiHeading.Render("lsp router")
|
|
header += " " + tuiCount.Render(fmt.Sprintf("· %d enabled · %d alive", len(specs), len(active)))
|
|
lines = append(lines, header)
|
|
for _, s := range specs {
|
|
state := "·"
|
|
stateRender := tuiSub
|
|
if s.Available {
|
|
state = "✓"
|
|
stateRender = tuiAccent
|
|
}
|
|
line := " " + stateRender.Render(state) + " " +
|
|
tuiKey.Render(padRight(s.Name, 28)) + " " +
|
|
tuiVal.Render(padRight(s.Languages, 28))
|
|
lines = append(lines, line)
|
|
}
|
|
for _, a := range active {
|
|
lines = append(lines, " "+tuiHint.Render("alive: "+a.Spec+"@"+a.Workspace+" last_used="+a.LastUsed))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func (m statusTUI) renderFooter() string {
|
|
left := tuiHint.Render(time.Now().Format("15:04:05") + " · refreshing every " + m.interval.String())
|
|
right := strings.Join([]string{
|
|
tuiKeybindKey.Render("/") + tuiKeybindHint.Render(" filter"),
|
|
tuiKeybindKey.Render("↑↓") + tuiKeybindHint.Render(" scroll"),
|
|
tuiKeybindKey.Render("q") + tuiKeybindHint.Render(" quit"),
|
|
}, tuiKeybindHint.Render(" "))
|
|
pad := m.width - lipgloss.Width(left) - lipgloss.Width(right)
|
|
if pad < 1 {
|
|
pad = 1
|
|
}
|
|
return left + strings.Repeat(" ", pad) + right
|
|
}
|
|
|
|
// ---- sizing -----------------------------------------------------------
|
|
|
|
func (m statusTUI) reposListWidth() int {
|
|
w := m.width - 2
|
|
if w < 40 {
|
|
w = 40
|
|
}
|
|
return w
|
|
}
|
|
|
|
func (m statusTUI) reposListHeight() int {
|
|
if m.height == 0 {
|
|
return 10
|
|
}
|
|
// Mirror the layout in View(): top gap (1) + header (5) + gap-below-logo
|
|
// (2) + each non-empty section's heading + rows + 1 gap line + repos
|
|
// heading (1) + list status/pagination (2) + footer (1) + small slack.
|
|
reserved := 1 + 5 + 2
|
|
if m.hasStatus {
|
|
if len(m.status.Workspaces) > 0 {
|
|
reserved += 1 + len(m.status.Workspaces) + 1
|
|
}
|
|
if len(m.status.MCPSessions) > 0 {
|
|
reserved += 1 + len(m.status.MCPSessions) + 1
|
|
}
|
|
if len(m.status.ConfiguredServers) > 0 {
|
|
reserved += 1 + len(m.status.ConfiguredServers) + 1
|
|
}
|
|
}
|
|
reserved += 1 // repos heading row
|
|
reserved += 2 // bubbles list status bar + pagination
|
|
reserved += 1 // gap below repos
|
|
reserved += 1 // footer
|
|
h := m.height - reserved
|
|
if h < 6 {
|
|
h = 6
|
|
}
|
|
return h
|
|
}
|
|
|
|
// ---- helpers ----------------------------------------------------------
|
|
|
|
// miniMesh returns a 5-line mesh logo with the active node walking once per
|
|
// tick, sized for the header (no label/sub — just the mark).
|
|
func miniMesh(tick int) string {
|
|
const reset = "\x1b[0m"
|
|
cAccent := "\x1b[38;2;95;214;122m"
|
|
cPerim := "\x1b[38;2;240;240;240m"
|
|
cInner := "\x1b[38;2;70;70;78m"
|
|
|
|
perimeter := [12][2]int{
|
|
{0, 1}, {0, 2}, {0, 3},
|
|
{1, 4}, {2, 4}, {3, 4},
|
|
{4, 3}, {4, 2}, {4, 1},
|
|
{3, 0}, {2, 0}, {1, 0},
|
|
}
|
|
innerAccents := map[[2]int]bool{
|
|
{2, 2}: true,
|
|
{2, 3}: true,
|
|
}
|
|
cellAt := func(r, c int) int {
|
|
corner := (r == 0 || r == 4) && (c == 0 || c == 4)
|
|
if corner {
|
|
return 0
|
|
}
|
|
if r == 0 || r == 4 || c == 0 || c == 4 {
|
|
return 1
|
|
}
|
|
if innerAccents[[2]int{r, c}] {
|
|
return 1
|
|
}
|
|
return 2
|
|
}
|
|
|
|
active := perimeter[tick%len(perimeter)]
|
|
var lines [5]string
|
|
for r := 0; r < 5; r++ {
|
|
var b strings.Builder
|
|
for c := 0; c < 5; c++ {
|
|
switch cellAt(r, c) {
|
|
case 0:
|
|
b.WriteString(" ")
|
|
case 1:
|
|
if r == active[0] && c == active[1] {
|
|
b.WriteString(cAccent + "●" + reset)
|
|
} else {
|
|
b.WriteString(cPerim + "●" + reset)
|
|
}
|
|
case 2:
|
|
b.WriteString(cInner + "·" + reset)
|
|
}
|
|
if c < 4 {
|
|
b.WriteString(" ")
|
|
}
|
|
}
|
|
lines[r] = b.String()
|
|
}
|
|
return strings.Join(lines[:], "\n")
|
|
}
|
|
|
|
func humanizeBytes(b uint64) string {
|
|
const unit = 1024
|
|
if b < unit {
|
|
return fmt.Sprintf("%d B", b)
|
|
}
|
|
div, exp := uint64(unit), 0
|
|
for n := b / unit; n >= unit; n /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
v := float64(b) / float64(div)
|
|
suffix := []string{"KiB", "MiB", "GiB", "TiB"}[exp]
|
|
return fmt.Sprintf("%.1f %s", v, suffix)
|
|
}
|
|
|
|
func humanizeDur(d time.Duration) string {
|
|
if d < time.Second {
|
|
return "0s"
|
|
}
|
|
if d < time.Minute {
|
|
return fmt.Sprintf("%ds", int(d.Seconds()))
|
|
}
|
|
if d < time.Hour {
|
|
return fmt.Sprintf("%dm", int(d.Minutes()))
|
|
}
|
|
if d < 24*time.Hour {
|
|
h := int(d.Hours())
|
|
m := int(d.Minutes()) - h*60
|
|
if m == 0 {
|
|
return fmt.Sprintf("%dh", h)
|
|
}
|
|
return fmt.Sprintf("%dh%dm", h, m)
|
|
}
|
|
days := int(d.Hours()) / 24
|
|
h := int(d.Hours()) - days*24
|
|
if h == 0 {
|
|
return fmt.Sprintf("%dd", days)
|
|
}
|
|
return fmt.Sprintf("%dd%dh", days, h)
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
if lipgloss.Width(s) <= n {
|
|
return s
|
|
}
|
|
if n <= 1 {
|
|
return "…"
|
|
}
|
|
// Naive byte truncation works fine for the ASCII-heavy fields here;
|
|
// when content is multibyte (paths, names with Unicode), the trailing
|
|
// ellipsis covers any visual jitter.
|
|
return s[:n-1] + "…"
|
|
}
|
|
|
|
func padRight(s string, n int) string {
|
|
w := lipgloss.Width(s)
|
|
if w >= n {
|
|
return s
|
|
}
|
|
return s + strings.Repeat(" ", n-w)
|
|
}
|
|
|
|
func shortenPath(p string) string {
|
|
home := homeDir()
|
|
if home != "" && strings.HasPrefix(p, home) {
|
|
return "~" + p[len(home):]
|
|
}
|
|
return p
|
|
}
|
|
|
|
func homeDir() string {
|
|
if h, err := userHomeDir(); err == nil {
|
|
return h
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// userHomeDir is a thin alias so tests can stub it without pulling in
|
|
// os in this file's import set.
|
|
var userHomeDir = osUserHomeDir
|
|
|
|
func pluralize(n int, singular, plural string) string {
|
|
if n == 1 {
|
|
return singular
|
|
}
|
|
return plural
|
|
}
|
|
|
|
// versionString prefixes the version with "v" only when the string isn't
|
|
// already prefixed; the daemon may report "0.19.1" or "v0.19.1" depending on
|
|
// how the binary was built.
|
|
func versionString(v string) string {
|
|
if v == "" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(v, "v") || strings.HasPrefix(v, "V") {
|
|
return v
|
|
}
|
|
return "v" + v
|
|
}
|
|
|
|
func firstNonEmpty(s ...string) string {
|
|
for _, v := range s {
|
|
if v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func workspaceLabel(r daemon.TrackedRepoStatus) string {
|
|
if r.Workspace == "" {
|
|
return ""
|
|
}
|
|
if r.WorkspaceProject != "" && r.WorkspaceProject != r.Workspace {
|
|
return r.Workspace + "/" + r.WorkspaceProject
|
|
}
|
|
return r.Workspace
|
|
}
|
|
|
|
func maxInt(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|