8e86b7eadb
Show live repository progress, elapsed time, active work, and recent outcomes during long syncs. Add a persistent post-run summary that prioritizes failures, reports protected and divergent branches, focuses the first issue, and lets users cycle through attention items with f.
639 lines
17 KiB
Go
639 lines
17 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"io"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/lipgloss"
|
||
)
|
||
|
||
var (
|
||
amber = lipgloss.Color("214")
|
||
green = lipgloss.Color("42")
|
||
red = lipgloss.Color("196")
|
||
muted = lipgloss.Color("241")
|
||
panel = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("238")).Padding(0, 1)
|
||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(amber)
|
||
readyStyle = lipgloss.NewStyle().Foreground(green)
|
||
blockStyle = lipgloss.NewStyle().Foreground(red)
|
||
mutedStyle = lipgloss.NewStyle().Foreground(muted)
|
||
)
|
||
|
||
type tuiMode int
|
||
|
||
const (
|
||
modeReady tuiMode = iota
|
||
modeConfirm
|
||
modeSyncing
|
||
)
|
||
|
||
type tickMsg time.Time
|
||
type scanDoneMsg struct {
|
||
repos []repo
|
||
err error
|
||
}
|
||
type syncDoneMsg struct {
|
||
results []syncResult
|
||
repos []repo
|
||
err error
|
||
}
|
||
type syncStartedMsg struct{ path string }
|
||
type syncFinishedMsg struct{ result syncResult }
|
||
|
||
type tuiModel struct {
|
||
root string
|
||
repos []repo
|
||
selected map[string]bool
|
||
opts options
|
||
cursor int
|
||
offset int
|
||
width int
|
||
height int
|
||
mode tuiMode
|
||
frame int
|
||
status string
|
||
lastResult []syncResult
|
||
syncEvents <-chan tea.Msg
|
||
syncTotal int
|
||
syncDone int
|
||
syncOK int
|
||
syncFailed int
|
||
syncSkipped int
|
||
syncActive map[string]bool
|
||
syncRecent []syncResult
|
||
syncStarted time.Time
|
||
}
|
||
|
||
func runTUI(root string, repos []repo, opts options) error {
|
||
m := newTUIModel(root, repos, opts)
|
||
program := tea.NewProgram(m, tea.WithAltScreen())
|
||
_, err := program.Run()
|
||
return err
|
||
}
|
||
|
||
func newTUIModel(root string, repos []repo, opts options) tuiModel {
|
||
selected := make(map[string]bool)
|
||
for _, r := range repos {
|
||
selected[r.Path] = r.Eligible
|
||
}
|
||
return tuiModel{root: root, repos: repos, selected: selected, opts: opts, mode: modeReady}
|
||
}
|
||
|
||
func (m tuiModel) Init() tea.Cmd { return nil }
|
||
|
||
func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
switch msg := msg.(type) {
|
||
case tea.WindowSizeMsg:
|
||
m.width, m.height = msg.Width, msg.Height
|
||
m.clampViewport()
|
||
return m, nil
|
||
case tickMsg:
|
||
if m.mode == modeSyncing {
|
||
m.frame++
|
||
return m, tickCmd()
|
||
}
|
||
return m, nil
|
||
case scanDoneMsg:
|
||
if msg.err != nil {
|
||
m.status = "Refresh failed: " + msg.err.Error()
|
||
return m, nil
|
||
}
|
||
m.repos = msg.repos
|
||
m.reconcileSelection()
|
||
m.status = "Workspace refreshed"
|
||
m.clampViewport()
|
||
return m, nil
|
||
case syncStartedMsg:
|
||
m.syncActive[msg.path] = true
|
||
return m, waitSyncEventCmd(m.syncEvents)
|
||
case syncFinishedMsg:
|
||
delete(m.syncActive, msg.result.Path)
|
||
m.syncDone++
|
||
switch {
|
||
case msg.result.Success:
|
||
m.syncOK++
|
||
case msg.result.Skipped:
|
||
m.syncSkipped++
|
||
default:
|
||
m.syncFailed++
|
||
}
|
||
m.syncRecent = append(m.syncRecent, msg.result)
|
||
if len(m.syncRecent) > 4 {
|
||
m.syncRecent = m.syncRecent[len(m.syncRecent)-4:]
|
||
}
|
||
return m, waitSyncEventCmd(m.syncEvents)
|
||
case syncDoneMsg:
|
||
m.mode = modeReady
|
||
m.syncEvents = nil
|
||
m.lastResult = msg.results
|
||
if msg.err != nil {
|
||
m.status = "Sync failed: " + msg.err.Error()
|
||
return m, nil
|
||
}
|
||
m.repos = msg.repos
|
||
m.reconcileSelection()
|
||
var ok, failed, skipped int
|
||
for _, result := range msg.results {
|
||
switch {
|
||
case result.Success:
|
||
ok++
|
||
case result.Skipped:
|
||
skipped++
|
||
default:
|
||
failed++
|
||
}
|
||
}
|
||
m.status = fmt.Sprintf("Finished: %d synced, %d failed, %d safety-skipped", ok, failed, skipped)
|
||
if path := firstAttentionPath(msg.results); path != "" {
|
||
m.focusPath(path)
|
||
m.status += "; focused first issue"
|
||
}
|
||
return m, nil
|
||
case tea.KeyMsg:
|
||
return m.handleKey(msg)
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m tuiModel) handleKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
value := key.String()
|
||
if m.mode == modeSyncing {
|
||
if value == "q" || value == "ctrl+c" {
|
||
m.status = "Sync is running; wait for it to finish before quitting"
|
||
}
|
||
return m, nil
|
||
}
|
||
if m.mode == modeConfirm {
|
||
switch value {
|
||
case "y", "Y":
|
||
selected := m.selectedRepos()
|
||
m.mode = modeSyncing
|
||
m.status = ""
|
||
m.lastResult = nil
|
||
m.syncTotal = len(selected)
|
||
m.syncDone, m.syncOK, m.syncFailed, m.syncSkipped = 0, 0, 0, 0
|
||
m.syncActive = make(map[string]bool)
|
||
m.syncRecent = nil
|
||
m.syncStarted = time.Now()
|
||
events := make(chan tea.Msg, max(4, len(selected)*2+1))
|
||
m.syncEvents = events
|
||
return m, tea.Batch(m.startSyncCmd(selected, events), tickCmd())
|
||
case "n", "N", "esc", "q":
|
||
m.mode = modeReady
|
||
m.status = "Sync cancelled; nothing changed"
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
switch value {
|
||
case "q", "ctrl+c":
|
||
return m, tea.Quit
|
||
case "up", "k":
|
||
if m.cursor > 0 {
|
||
m.cursor--
|
||
}
|
||
case "down", "j":
|
||
if m.cursor+1 < len(m.repos) {
|
||
m.cursor++
|
||
}
|
||
case " ":
|
||
if len(m.repos) > 0 && m.repos[m.cursor].Eligible {
|
||
path := m.repos[m.cursor].Path
|
||
m.selected[path] = !m.selected[path]
|
||
}
|
||
case "a":
|
||
for _, r := range m.repos {
|
||
if r.Eligible {
|
||
m.selected[r.Path] = true
|
||
}
|
||
}
|
||
m.status = "Selected every eligible repository"
|
||
case "n":
|
||
for path := range m.selected {
|
||
m.selected[path] = false
|
||
}
|
||
m.status = "Selection cleared"
|
||
case "r":
|
||
m.status = "Refreshing workspace…"
|
||
return m, m.scanCmd()
|
||
case "f":
|
||
if !m.focusNextAttention() {
|
||
m.status = "The last run has no failures, skips, warnings, or protected branches"
|
||
}
|
||
case "s", "enter":
|
||
count := len(m.selectedRepos())
|
||
if count == 0 {
|
||
m.status = "Select at least one eligible repository"
|
||
} else {
|
||
m.mode = modeConfirm
|
||
}
|
||
}
|
||
m.clampViewport()
|
||
return m, nil
|
||
}
|
||
|
||
func (m tuiModel) View() string {
|
||
width := m.width
|
||
if width <= 0 {
|
||
width = 100
|
||
}
|
||
contentWidth := max(40, width-6)
|
||
ready, blocked, selected := eligibleCount(m.repos), blockedCount(m.repos), len(m.selectedRepos())
|
||
|
||
var b strings.Builder
|
||
b.WriteString(titleStyle.Render("STACK SYNC"))
|
||
b.WriteString(mutedStyle.Render(" workspace control deck"))
|
||
b.WriteString("\n")
|
||
b.WriteString(mutedStyle.Render(truncateMiddle(m.root, contentWidth)))
|
||
b.WriteString("\n\n")
|
||
b.WriteString(readyStyle.Render(fmt.Sprintf("● %d ready", ready)))
|
||
b.WriteString(" ")
|
||
b.WriteString(blockStyle.Render(fmt.Sprintf("● %d blocked", blocked)))
|
||
b.WriteString(" ")
|
||
b.WriteString(titleStyle.Render(fmt.Sprintf("◆ %d selected", selected)))
|
||
b.WriteString("\n\n")
|
||
|
||
visible := m.visibleRows()
|
||
nameWidth := max(12, min(42, contentWidth-38))
|
||
b.WriteString(mutedStyle.Render(fmt.Sprintf(" %-3s %-*s %-16s %s", "", nameWidth, "REPOSITORY", "BRANCH", "STATE")))
|
||
b.WriteString("\n")
|
||
for i := visible.start; i < visible.end; i++ {
|
||
r := m.repos[i]
|
||
pointer := " "
|
||
if i == m.cursor {
|
||
pointer = "›"
|
||
}
|
||
check := "[ ]"
|
||
if !r.Eligible {
|
||
check = "[–]"
|
||
} else if m.selected[r.Path] {
|
||
check = "[×]"
|
||
}
|
||
state := readyStyle.Render("ready")
|
||
if !r.Eligible {
|
||
state = blockStyle.Render("blocked")
|
||
} else if len(r.Dirty) > 0 {
|
||
state = titleStyle.Render("ready (dirty)")
|
||
}
|
||
line := fmt.Sprintf("%s %-3s %-*s %-16s %s", pointer, check, nameWidth, truncateMiddle(r.RelativePath, nameWidth), truncateMiddle(r.Branch, 16), state)
|
||
if i == m.cursor {
|
||
line = lipgloss.NewStyle().Bold(true).Foreground(amber).Render(line)
|
||
}
|
||
b.WriteString(line)
|
||
b.WriteString("\n")
|
||
}
|
||
if visible.start > 0 || visible.end < len(m.repos) {
|
||
b.WriteString(mutedStyle.Render(fmt.Sprintf(" showing %d–%d of %d", visible.start+1, visible.end, len(m.repos))))
|
||
b.WriteString("\n")
|
||
}
|
||
|
||
b.WriteString("\n")
|
||
b.WriteString(m.detailView(contentWidth))
|
||
b.WriteString("\n")
|
||
|
||
switch m.mode {
|
||
case modeConfirm:
|
||
b.WriteString(blockStyle.Bold(true).Render(fmt.Sprintf("Synchronize branches in %d selected repositories?", selected)))
|
||
b.WriteString("\n")
|
||
b.WriteString(mutedStyle.Render("Dirty checked-out branches are protected; merged branches with deleted upstreams may be removed. y confirm • n/esc cancel"))
|
||
case modeSyncing:
|
||
b.WriteString(m.syncProgressView(contentWidth))
|
||
default:
|
||
if m.status != "" {
|
||
b.WriteString(m.status)
|
||
b.WriteString("\n")
|
||
}
|
||
if len(m.lastResult) > 0 {
|
||
b.WriteString(m.lastRunView(contentWidth))
|
||
b.WriteString("\n")
|
||
}
|
||
b.WriteString(mutedStyle.Render("↑/k ↓/j move • space toggle • a all • n none • s/enter sync • r refresh • f next issue • q quit"))
|
||
}
|
||
return panel.Width(contentWidth).Render(b.String())
|
||
}
|
||
|
||
type rowRange struct{ start, end int }
|
||
|
||
func (m tuiModel) visibleRows() rowRange {
|
||
reserved := 15
|
||
if m.mode == modeSyncing {
|
||
reserved = 20
|
||
} else if len(m.lastResult) > 0 {
|
||
reserved = 20
|
||
}
|
||
available := m.height - reserved
|
||
if available < 4 {
|
||
available = 4
|
||
}
|
||
end := min(len(m.repos), m.offset+available)
|
||
return rowRange{start: min(m.offset, end), end: end}
|
||
}
|
||
|
||
func (m *tuiModel) clampViewport() {
|
||
if len(m.repos) == 0 {
|
||
m.cursor, m.offset = 0, 0
|
||
return
|
||
}
|
||
m.cursor = max(0, min(m.cursor, len(m.repos)-1))
|
||
reserved := 15
|
||
if m.mode == modeSyncing || len(m.lastResult) > 0 {
|
||
reserved = 20
|
||
}
|
||
available := max(4, m.height-reserved)
|
||
if m.cursor < m.offset {
|
||
m.offset = m.cursor
|
||
}
|
||
if m.cursor >= m.offset+available {
|
||
m.offset = m.cursor - available + 1
|
||
}
|
||
}
|
||
|
||
func (m tuiModel) detailView(width int) string {
|
||
if len(m.repos) == 0 {
|
||
return mutedStyle.Render("No repositories found")
|
||
}
|
||
r := m.repos[m.cursor]
|
||
var lines []string
|
||
if r.Eligible {
|
||
state := readyStyle.Render("READY")
|
||
if len(r.Dirty) > 0 {
|
||
state = titleStyle.Render("READY · CHECKED-OUT BRANCH PROTECTED")
|
||
}
|
||
lines = append(lines, state+" "+r.RelativePath+" "+mutedStyle.Render(r.Branch))
|
||
} else {
|
||
lines = append(lines, blockStyle.Render("BLOCKED")+" "+r.RelativePath+" "+r.BlockReason)
|
||
}
|
||
for i, item := range r.Dirty {
|
||
if i == 3 {
|
||
lines = append(lines, mutedStyle.Render(fmt.Sprintf("… and %d more changes", len(r.Dirty)-3)))
|
||
break
|
||
}
|
||
lines = append(lines, " "+truncateMiddle(item, width-4))
|
||
}
|
||
if len(r.NestedRepoEntries) > 0 {
|
||
lines = append(lines, mutedStyle.Render(fmt.Sprintf("%d nested repository path(s) safely excluded from parent dirtiness", len(r.NestedRepoEntries))))
|
||
}
|
||
for _, result := range m.lastResult {
|
||
if result.Path != r.RelativePath || result.Message == "" {
|
||
continue
|
||
}
|
||
state := readyStyle.Render("LAST SYNC")
|
||
if !result.Success {
|
||
state = blockStyle.Render("LAST SYNC FAILED")
|
||
}
|
||
lines = append(lines, state+mutedStyle.Render(" "+result.Duration))
|
||
output := strings.Split(strings.ReplaceAll(result.Message, "\r", "\n"), "\n")
|
||
shown := 0
|
||
for _, outputLine := range output {
|
||
if strings.TrimSpace(outputLine) == "" {
|
||
continue
|
||
}
|
||
if shown == 3 {
|
||
lines = append(lines, mutedStyle.Render(" … more output available in the CLI/JSON interface"))
|
||
break
|
||
}
|
||
lines = append(lines, " "+truncateMiddle(strings.TrimSpace(outputLine), width-4))
|
||
shown++
|
||
}
|
||
break
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func (m tuiModel) selectedRepos() []repo {
|
||
selected := make([]repo, 0)
|
||
for _, r := range m.repos {
|
||
if r.Eligible && m.selected[r.Path] {
|
||
selected = append(selected, r)
|
||
}
|
||
}
|
||
return selected
|
||
}
|
||
|
||
func (m *tuiModel) reconcileSelection() {
|
||
next := make(map[string]bool)
|
||
for _, r := range m.repos {
|
||
next[r.Path] = r.Eligible && m.selected[r.Path]
|
||
}
|
||
m.selected = next
|
||
}
|
||
|
||
func (m tuiModel) scanCmd() tea.Cmd {
|
||
return func() tea.Msg {
|
||
paths, err := discover(m.root, m.opts.exclusions)
|
||
if err != nil {
|
||
return scanDoneMsg{err: err}
|
||
}
|
||
return scanDoneMsg{repos: inspectAll(m.root, paths, m.opts.jobs)}
|
||
}
|
||
}
|
||
|
||
func (m tuiModel) startSyncCmd(selected []repo, events chan tea.Msg) tea.Cmd {
|
||
return func() tea.Msg {
|
||
go func() {
|
||
results := syncSelectedWithProgress(m.root, selected, m.repos, m.opts.jobs, m.opts.timeout, io.Discard, true, false, func(event syncProgressEvent) {
|
||
if event.Started {
|
||
events <- syncStartedMsg{path: event.Path}
|
||
} else {
|
||
events <- syncFinishedMsg{result: event.Result}
|
||
}
|
||
})
|
||
paths, err := discover(m.root, m.opts.exclusions)
|
||
if err != nil {
|
||
events <- syncDoneMsg{results: results, err: err}
|
||
return
|
||
}
|
||
events <- syncDoneMsg{results: results, repos: inspectAll(m.root, paths, m.opts.jobs)}
|
||
}()
|
||
return <-events
|
||
}
|
||
}
|
||
|
||
func waitSyncEventCmd(events <-chan tea.Msg) tea.Cmd {
|
||
if events == nil {
|
||
return nil
|
||
}
|
||
return func() tea.Msg { return <-events }
|
||
}
|
||
|
||
func (m tuiModel) syncProgressView(width int) string {
|
||
spinner := []string{"◐", "◓", "◑", "◒"}[m.frame%4]
|
||
phase := "Synchronizing repositories"
|
||
if m.syncDone == m.syncTotal && m.syncTotal > 0 {
|
||
phase = "Refreshing workspace"
|
||
}
|
||
elapsed := time.Since(m.syncStarted).Round(time.Second)
|
||
if elapsed < 0 {
|
||
elapsed = 0
|
||
}
|
||
barWidth := max(12, min(32, width-30))
|
||
filled := 0
|
||
if m.syncTotal > 0 {
|
||
filled = barWidth * m.syncDone / m.syncTotal
|
||
}
|
||
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
|
||
|
||
var lines []string
|
||
lines = append(lines, titleStyle.Render(fmt.Sprintf("%s %s", spinner, phase))+mutedStyle.Render(" "+elapsed.String()))
|
||
lines = append(lines, readyStyle.Render(bar)+fmt.Sprintf(" %d/%d", m.syncDone, m.syncTotal))
|
||
lines = append(lines, fmt.Sprintf("%s %s %s", readyStyle.Render(fmt.Sprintf("%d synced", m.syncOK)), blockStyle.Render(fmt.Sprintf("%d failed", m.syncFailed)), mutedStyle.Render(fmt.Sprintf("%d skipped", m.syncSkipped))))
|
||
if len(m.syncActive) > 0 {
|
||
active := make([]string, 0, len(m.syncActive))
|
||
for path := range m.syncActive {
|
||
active = append(active, path)
|
||
}
|
||
sort.Strings(active)
|
||
lines = append(lines, mutedStyle.Render("Active: ")+truncateMiddle(strings.Join(active, ", "), max(10, width-8)))
|
||
}
|
||
if len(m.syncRecent) > 0 {
|
||
recent := m.syncRecent[len(m.syncRecent)-1]
|
||
label := readyStyle.Render("Latest ✓")
|
||
if recent.Skipped {
|
||
label = titleStyle.Render("Latest ↷")
|
||
} else if !recent.Success {
|
||
label = blockStyle.Render("Latest ✗")
|
||
}
|
||
lines = append(lines, label+" "+truncateMiddle(recent.Path+resultHeadline(recent), max(10, width-12)))
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func (m tuiModel) lastRunView(width int) string {
|
||
var ok, failed, skipped, protected, warnings int
|
||
var critical, caution []string
|
||
for _, result := range m.lastResult {
|
||
switch {
|
||
case result.Success:
|
||
ok++
|
||
case result.Skipped:
|
||
skipped++
|
||
critical = append(critical, "↷ "+result.Path+": "+firstLine(result.Message))
|
||
default:
|
||
failed++
|
||
critical = append(critical, "✗ "+result.Path+": "+firstLine(result.Message))
|
||
}
|
||
for _, branch := range result.Branches {
|
||
switch branch.Action {
|
||
case "protected":
|
||
protected++
|
||
caution = append(caution, "◆ "+result.Path+" / "+branch.Branch+": "+branch.Message)
|
||
case "warning":
|
||
warnings++
|
||
caution = append(caution, "! "+result.Path+" / "+branch.Branch+": "+branch.Message)
|
||
}
|
||
}
|
||
}
|
||
details := append(critical, caution...)
|
||
|
||
header := titleStyle.Render("LAST RUN") + fmt.Sprintf(" %s %s %s", readyStyle.Render(fmt.Sprintf("%d synced", ok)), blockStyle.Render(fmt.Sprintf("%d failed", failed)), mutedStyle.Render(fmt.Sprintf("%d skipped", skipped)))
|
||
if protected+warnings > 0 {
|
||
header += titleStyle.Render(fmt.Sprintf(" %d protected · %d warnings", protected, warnings))
|
||
}
|
||
lines := []string{header}
|
||
shown := min(3, len(details))
|
||
for _, detail := range details[:shown] {
|
||
lines = append(lines, " "+truncateMiddle(detail, max(10, width-2)))
|
||
}
|
||
if len(details) > shown {
|
||
lines = append(lines, mutedStyle.Render(fmt.Sprintf(" … %d more; press f to cycle through issues", len(details)-shown)))
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func resultHeadline(result syncResult) string {
|
||
if result.Success && len(result.Branches) == 0 {
|
||
return " · up to date"
|
||
}
|
||
if result.Message == "" {
|
||
return ""
|
||
}
|
||
return " · " + firstLine(result.Message)
|
||
}
|
||
|
||
func resultNeedsAttention(result syncResult) bool {
|
||
if !result.Success || result.Skipped {
|
||
return true
|
||
}
|
||
for _, branch := range result.Branches {
|
||
if branch.Action == "warning" || branch.Action == "protected" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func firstAttentionPath(results []syncResult) string {
|
||
for _, result := range results {
|
||
if !result.Success && !result.Skipped {
|
||
return result.Path
|
||
}
|
||
}
|
||
for _, result := range results {
|
||
if result.Skipped {
|
||
return result.Path
|
||
}
|
||
}
|
||
for _, result := range results {
|
||
for _, branch := range result.Branches {
|
||
if branch.Action == "warning" || branch.Action == "protected" {
|
||
return result.Path
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (m *tuiModel) focusPath(path string) bool {
|
||
for i, repo := range m.repos {
|
||
if repo.RelativePath == path {
|
||
m.cursor = i
|
||
m.clampViewport()
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (m *tuiModel) focusNextAttention() bool {
|
||
var paths []string
|
||
for _, result := range m.lastResult {
|
||
if resultNeedsAttention(result) {
|
||
paths = append(paths, result.Path)
|
||
}
|
||
}
|
||
if len(paths) == 0 {
|
||
return false
|
||
}
|
||
current := ""
|
||
if len(m.repos) > 0 {
|
||
current = m.repos[m.cursor].RelativePath
|
||
}
|
||
next := paths[0]
|
||
for i, path := range paths {
|
||
if path == current {
|
||
next = paths[(i+1)%len(paths)]
|
||
break
|
||
}
|
||
}
|
||
m.focusPath(next)
|
||
m.status = "Focused issue in " + next
|
||
return true
|
||
}
|
||
|
||
func tickCmd() tea.Cmd {
|
||
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg(t) })
|
||
}
|
||
|
||
func truncateMiddle(value string, width int) string {
|
||
if width < 2 || len(value) <= width {
|
||
return value
|
||
}
|
||
left := (width - 1) / 2
|
||
right := width - left - 1
|
||
return value[:left] + "…" + value[len(value)-right:]
|
||
}
|