git/orchard/main
tui/internal/ui/build.go
1119 lines33 KB last change raw · history
   1
   2
   3
   4
   5
   6
   7
   8
   9
  10
  11
  12
  13
  14
  15
  16
  17
  18
  19
  20
  21
  22
  23
  24
  25
  26
  27
  28
  29
  30
  31
  32
  33
  34
  35
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
package ui

import (
	"fmt"

	"github.com/charmbracelet/lipgloss"
	"math"
	"sort"
	"strings"
	"time"

	"github.com/charmbracelet/x/ansi"

	"updater-tui/internal/plan"
)

// feedEntry is one package that has finished, as it appears in the live feed.
// overlayKind says what the full-screen overlay is showing. One field, one
// meaning: a bool could only say "the log or not", which stopped being true
// once the finished list wanted the same scrolling and search.
type overlayKind int

const (
	overlayNone overlayKind = iota
	overlayLog
	overlayFinished
	overlayMessage
)

type feedEntry struct {
	name    string
	version string
	elapsed float64
	state   string
	shared  bool
	//: why it failed, when it did
	note string
	//: what this package is worth on the progress line; 1 unless it builds
	weight int
}

// BuildView is the live state of one apply run.
//
// It is fed by the backend's event stream and owns no logic of its own: every
// number here came from a measurement on the other side of the pipe.
type BuildView struct {
	Phases    []string
	Phase     string
	Command   string
	Total     int
	Done      int
	Running   []plan.BuildState
	Feed      []feedEntry
	Downloads int64
	Rate      float64

	Started  time.Time
	Elapsed  float64
	Finished bool
	Return   int
	Failed   []string
	Message  string
	//: things worth saying, each with the detail behind it. Kept as a list of
	//: their own rather than bare strings so one can be opened, and dismissed
	//: for the session once it has been read.
	Messages []BuildMessage
	//: cursor over Messages, so a warning can be reached and dealt with
	Cursor int
	//: everything the running command has printed, in order
	Log     []string
	LogOpen bool
	//: the path of the log being followed, so the whole of it can be opened
	LogPath string
	//: a modal opened over the dashboard: either the build's output in full or
	//: one warning. The same modal the drill-down uses, so scrolling and search
	//: behave the way they already do elsewhere.
	Output *textModal
	//: whether Output is the live log. Only the log is refreshed as the build
	//: runs; refreshing a warning replaced its text with the build's output.
	OutputKind overlayKind
	//: the run's total in work, and what each building package is worth
	Weight  int
	Weights map[string]int
	//: advances on every tick, so the display can move
	Frame int
	//: a command that can finish what the unattended run could not, because
	//: the question it hit needs a person rather than a default
	Recovery  *Recovery
	Dashboard bool
}

// BuildMessage is something the run wants to say.
type BuildMessage struct {
	Text    string
	Details []string
}

// NewBuildView starts a live view for the phases the backend announced.
func NewBuildView(phases []string, total int) *BuildView {
	return &BuildView{
		Phases: phases, Total: total, Started: time.Now(), Dashboard: true,
	}
}

// Apply folds one protocol event into the view.
func (b *BuildView) Apply(event plan.Event) {
	switch event.Event {
	case "start":
		b.Phases = event.Phases
		b.Weights = event.Weights
		if event.Total > 0 {
			b.Total = event.Total
		}
		if event.Weight > 0 {
			b.Weight = event.Weight
		}
	case "phase_start":
		b.Phase = event.Phase
		b.Command = event.Command
		if event.Log != "" {
			b.LogPath = event.Log
		}
		// The phase's own count is deliberately not taken as the total.
		// `start` gives the run's total, and the finished list accumulates
		// across phases -- so a phase total smaller than what has already
		// finished made the bar read full while a whole phase was still to
		// come, and left nothing in "waiting".
		if b.Total == 0 && event.Total > 0 {
			b.Total = event.Total
		}
	case "build":
		b.Running = event.Packages
		b.Done = event.Done
		b.Downloads = event.DownloadBytes
		b.Rate = event.DownloadRate
		b.absorbLog(event.LogLines)
		b.Frame++
	case "finished":
		// the feed is append-only and ordered: newest lands at the bottom, which
		// is what makes it readable as a record rather than a status
		b.Feed = append(b.Feed, feedEntry{
			name: event.Name, version: event.Version, elapsed: event.Elapsed,
			state: event.State, shared: event.Shared,
			weight: b.weightOf(event.Name),
		})
	case "failed":
		b.markFailed(event.Name, event.Elapsed, event.FailureReason)
	case "skipped":
		b.markSkipped(event.Name)
	case "phase_end":
		b.Elapsed = event.Elapsed
	case "error":
		b.AddMessage(event.Message, event.Details...)
	case "recovery":
		b.Recovery = &Recovery{
			Label: event.RecoveryLabel, Command: event.RecoveryCommand,
			Note: event.RecoveryNote,
		}
	case "done":
		b.Finished = true
		b.Return = event.Returncode
		b.Failed = event.Failed
		if event.Elapsed > 0 {
			b.Elapsed = event.Elapsed
		}
	}

	// One place refreshes an open overlay. It used to hang off the build
	// event alone, so a list showing finished packages was a tick behind
	// whatever had just finished.
	b.refreshOutput()
}

// markFailed flips a package the watcher had already filed as finished.
//
// The interface watches processes, so it sees a makepkg exit and has no way to
// read the exit code -- paru's own result is the only thing that knows. This
// updates the entry that already exists rather than appending a second one,
// which would make done+failed exceed the total and leave the counter lying.
func (b *BuildView) markFailed(name string, elapsed float64, note string) {
	if name == "" {
		return
	}
	for i := range b.Running {
		if b.Running[i].Name == name {
			b.Running = append(b.Running[:i], b.Running[i+1:]...)
			break
		}
	}
	for i := range b.Feed {
		if b.Feed[i].name == name {
			b.Feed[i].state = "fail"
			b.Feed[i].note = note
			if elapsed > 0 {
				b.Feed[i].elapsed = elapsed
			}
			return
		}
	}
	b.Feed = append(b.Feed, feedEntry{name: name, state: "fail",
		elapsed: elapsed, note: note, weight: b.weightOf(name)})
}

// weightOf is what a package is worth on the progress line.
//
// One unless the backend said otherwise, which it does for the packages that
// actually build. Counting a minute-long compile and a one-second download
// alike is what made the bar empty itself during the repository phase.
func (b *BuildView) weightOf(name string) int {
	if w, ok := b.Weights[name]; ok && w > 0 {
		return w
	}
	return 1
}

// resolvedWeight is how much of the run's work is settled, one way or another.
//
// A failure counts: the package is not going to be built, so the run has moved
// past it. Whether it went well is the colour's job, not the bar's -- an empty
// bar on a run where everything failed would say the run had not started.
func (b *BuildView) resolvedWeight() int {
	n := 0
	for _, entry := range b.Feed {
		n += entry.weight
	}
	return n
}

// markSkipped records that the reader asked for this one to be left.
//
// Not a failure: nothing went wrong, and it is not something to offer back.
// The entry stays in the list so the run still accounts for it, with a mark of
// its own.
func (b *BuildView) markSkipped(name string) {
	if name == "" {
		return
	}
	for i := range b.Running {
		if b.Running[i].Name == name {
			b.Running = append(b.Running[:i], b.Running[i+1:]...)
			break
		}
	}
	for i := range b.Feed {
		if b.Feed[i].name == name {
			b.Feed[i].state = "skip"
			return
		}
	}
	b.Feed = append(b.Feed, feedEntry{name: name, state: "skip",
		weight: b.weightOf(name)})
}

// CanSkip is whether there is a package in progress to leave behind. Only the
// AUR phase has one: the repository and flatpak phases are single transactions,
// and a package cannot be lifted out of one without unpicking what the
// database has already been told.
func (b *BuildView) CanSkip() bool {
	return b.Phase == "aur" && len(b.Running) > 0
}

// Recovery is a way out of a run that stopped on a question only a person can
// answer -- a package conflict pacman must be asked about, or a build that
// wants input. It runs in the real terminal, where prompts work.
type Recovery struct {
	Label   string
	Command []string
	Note    string
}

// maxLog is how much of a build's output is kept. Past this the beginning is
// dropped: a build that prints more than this is not one anybody reads back
// line by line, and the full log is on disk.
const maxLog = 4000

// absorbLog appends the lines the command has printed since the last tick.
//
// The interface keeps the whole stream rather than a window of it, so the
// output can be opened and read back, not just glanced at.
func (b *BuildView) absorbLog(lines []string) {
	for _, line := range lines {
		// collapse the repeated blank-ish chatter a build emits
		if line == "" && len(b.Log) > 0 && b.Log[len(b.Log)-1] == "" {
			continue
		}
		b.Log = append(b.Log, line)
	}
	if len(b.Log) > maxLog {
		b.Log = b.Log[len(b.Log)-maxLog:]
	}
}

// OpenOutput fills the screen with the build's output.
func (b *BuildView) OpenOutput() {
	if b.Output != nil {
		b.Output, b.OutputKind = nil, overlayNone
		return
	}
	if len(b.Log) == 0 {
		return
	}
	title := "output"
	if b.Command != "" {
		title = b.Command
	}
	modal := newText(title)
	modal.logMode = true
	// the log is the one view opened to see the end of it
	modal.follow = true
	modal.Update(textBodyMsg{title: title, lines: b.Log, foot: b.LogPath})
	b.Output = modal
	b.OutputKind = overlayLog
}

// OpenFinished fills the screen with everything that has finished.
//
// The dashboard can only ever show the tail of the list, and "… N earlier" is
// not a thing a reader can look at. The same overlay the log uses gives the
// whole run with scrolling and search, and follows the end while it grows.
func (b *BuildView) OpenFinished() {
	if b.Output != nil {
		b.Output, b.OutputKind = nil, overlayNone
		return
	}
	if len(b.Feed) == 0 {
		return
	}
	done, failed := b.counts()
	title := fmt.Sprintf("finished · %d done", done)
	if failed > 0 {
		title += fmt.Sprintf(", %d failed", failed)
	}
	modal := newText(title)
	modal.follow = true
	modal.Update(textBodyMsg{title: title, lines: b.finishedLines(),
		foot: b.phaseLine()})
	b.Output = modal
	b.OutputKind = overlayFinished
}

// finishedLines is the whole feed, oldest first, in the same shape the
// dashboard uses -- minus the terminal padding, which the overlay supplies.
func (b *BuildView) finishedLines() []string {
	out := make([]string, 0, len(b.Feed))
	for _, entry := range b.Feed {
		// Laid out left to right, not spread to a fixed column: the overlay
		// decides its own width, and a right-aligned trailing field landed
		// past that width and was truncated to an ellipsis.
		mark, style := "+", Incoming
		switch entry.state {
		case "fail":
			mark, style = "x", Outgoing
		case "skip":
			mark, style = "-", Warn
		}
		line := "  " + style.Render(mark) + " " + Text.Render(entry.name)
		if entry.version != "" {
			line += "  " + Ver.Render(entry.version)
		}
		if entry.elapsed > 0 {
			line += "  " + Muted.Render(seconds(entry.elapsed))
		}
		if entry.shared {
			line += "  " + Muted.Render("↻ shared")
		}
		if entry.state == "skip" {
			line += "  " + Muted.Render("skipped at your request")
		}
		if entry.state == "fail" && entry.note != "" {
			line += "  " + Muted.Render(entry.note)
		}
		out = append(out, line)
	}
	if len(out) == 0 {
		out = append(out, Muted.Render("nothing finished yet"))
	}
	return out
}

// OpenMessage shows the selected warning in full.
//
// The dashboard has one line per warning so the list stays readable, which
// means anything longer is cut off with an ellipsis. This is where the rest
// of it lives.
func (b *BuildView) OpenMessage() *textModal {
	if b.Cursor < 0 || b.Cursor >= len(b.Messages) {
		return nil
	}
	message := b.Messages[b.Cursor]
	lines := wrapText(message.Text, 76)
	if len(message.Details) > 0 {
		lines = append(lines, "")
		lines = append(lines, message.Details...)
	}
	title := shortTitle(message.Text)
	modal := newText(title)
	modal.Update(textBodyMsg{title: title, lines: lines})
	return modal
}

// wrapText folds prose to a width so a long sentence can actually be read.
// The line renderers truncate rather than wrap, which is right for a log and
// wrong for a paragraph.
func wrapText(text string, width int) []string {
	words := strings.Fields(text)
	if len(words) == 0 {
		return nil
	}
	var out []string
	line := words[0]
	for _, word := range words[1:] {
		if len(line)+1+len(word) > width {
			out = append(out, line)
			line = word
			continue
		}
		line += " " + word
	}
	return append(out, line)
}

// shortTitle is the headline of a message, for the modal's title bar.
//
// A message is a sentence with its explanation attached after a dash or a
// colon. Using all of it as the title repeats the first line of the body,
// which is what the body is for.
func shortTitle(text string) string {
	text = firstLine(text)
	// Stop at the first separator that fits: "pkg: failed (exit 1) — error: …"
	// is about the package failing, so "pkg: failed (exit 1)" is the headline.
	// Applying every separator in turn reduced it to the package name alone.
	for _, sep := range []string{" — ", ": "} {
		if i := strings.LastIndex(text, sep); i > 12 {
			text = text[:i]
			break
		}
	}
	if len(text) > 60 {
		text = text[:59] + "…"
	}
	return text
}

func firstLine(text string) string {
	if i := strings.IndexByte(text, '\n'); i >= 0 {
		return text[:i]
	}
	return text
}

// refreshOutput keeps an open view of the output current as more arrives.
//
// Only the log view: a warning is a fixed piece of text, and refreshing it
// replaced what the reader had opened with the build's output instead.
func (b *BuildView) refreshOutput() {
	if b.Output == nil {
		return
	}
	if b.OutputKind != overlayLog && b.OutputKind != overlayFinished {
		return
	}
	// hold the reader's place: new lines land at the bottom, and the scroll
	// position is measured from the top
	lines, foot := b.Log, b.LogPath
	if b.OutputKind == overlayFinished {
		lines, foot = b.finishedLines(), b.phaseLine()
	}
	b.Output.Update(textBodyMsg{title: b.Output.title, lines: lines, foot: foot})
}

// tailLog is the last few lines, for the inline panel.
func (b *BuildView) tailLog() []string {
	if len(b.Log) <= 8 {
		return b.Log
	}
	return b.Log[len(b.Log)-8:]
}

// AddProtocolError records a line the reader could not parse.
func (b *BuildView) AddProtocolError(err error) {
	b.AddMessage(err.Error())
}

// AddMessage records something worth saying, once.
//
// The same line arrives more than once in practice -- a signal reaches the
// backend and its child, each reports the interrupt -- and two identical
// warnings stacked on top of each other is how the bottom of the screen
// became a wall of text nobody reads.
func (b *BuildView) AddMessage(text string, details ...string) {
	if text == "" {
		return
	}
	for _, existing := range b.Messages {
		if existing.Text == text {
			return
		}
	}
	if len(b.Messages) >= 8 {
		// older ones scroll off the top; dismissal is how the list shortens
		b.Messages = b.Messages[1:]
		if b.Cursor > 0 {
			b.Cursor--
		}
	}
	b.Messages = append(b.Messages, BuildMessage{Text: text, Details: details})
	// the cursor follows the newest, so the bar marks what just happened
	// rather than the oldest warning on screen
	b.Cursor = len(b.Messages) - 1
}

// Dismiss removes the message under the cursor, for this session only.
//
// A warning that cannot be got rid of is one people learn to read past, and
// these sit on the same lines as the progress they are covering.
func (b *BuildView) Dismiss() {
	if b.Cursor < 0 || b.Cursor >= len(b.Messages) {
		return
	}
	b.Messages = append(b.Messages[:b.Cursor], b.Messages[b.Cursor+1:]...)
	if b.Cursor >= len(b.Messages) {
		b.Cursor = max(0, len(b.Messages)-1)
	}
}

// MoveCursor walks the messages.
func (b *BuildView) MoveCursor(step int) {
	if len(b.Messages) == 0 {
		b.Cursor = 0
		return
	}
	b.Cursor += step
	if b.Cursor < 0 {
		b.Cursor = 0
	}
	if b.Cursor >= len(b.Messages) {
		b.Cursor = len(b.Messages) - 1
	}
}

// clock is how long the run has been going.
//
// The wall clock is the primary source because it ticks continuously. The
// backend reports an elapsed figure only when a phase ends, so that figure
// alone would freeze the display at whatever the last phase took. It still wins
// when it is larger, which covers attaching to a run already in progress.
func (b *BuildView) clock() float64 {
	return math.Max(time.Since(b.Started).Seconds(), b.Elapsed)
}

func seconds(v float64) string {
	total := int(v)
	switch {
	case total < 60:
		return fmt.Sprintf("%ds", total)
	case total < 3600:
		return fmt.Sprintf("%dm%02ds", total/60, total%60)
	default:
		return fmt.Sprintf("%dh%02dm", total/3600, (total%3600)/60)
	}
}

func bytes(n int64) string {
	if n <= 0 {
		return "—"
	}
	units := []string{"B", "KiB", "MiB", "GiB", "TiB"}
	v := float64(n)
	i := 0
	for v >= 1024 && i < len(units)-1 {
		v /= 1024
		i++
	}
	if i == 0 {
		return fmt.Sprintf("%d B", n)
	}
	return fmt.Sprintf("%.1f %s", v, units[i])
}

// View draws the live run. `dashboard` off leaves only the feed, which is the
// same toggle the rest of the tool uses.
func (b *BuildView) View(width int, hideHints bool, height int) string {
	if b.Output != nil {
		return b.Output.View(width, height)
	}
	if !b.Dashboard {
		return b.renderFeedOnly(width, height)
	}

	// Blocks are rendered first and then trimmed, because predicting their
	// heights meant accounting for every placeholder, blank and cursor shift
	// by hand -- and getting it wrong by one line pushed the controls off the
	// bottom, which is exactly the thing this is here to prevent.
	head := []string{
		spread(Accent.Render("updater"), Muted.Render(b.phaseLine()), width),
		b.progressLine(width),
	}

	done := []string{Title.Render("DONE"), ""}
	switch {
	case len(b.Feed) == 0:
		done = append(done, "    "+Muted.Render("nothing finished yet"))
	default:
		done = append(done, b.feedLines(width)...)
	}

	building := []string{"", Title.Render("BUILDING"), ""}
	if len(b.Running) == 0 {
		building = append(building, "    "+Muted.Render("nothing building"))
	}
	for _, state := range b.Running {
		building = append(building, b.runningLine(state, width))
	}

	messages := b.messageLines(width)

	output := []string{}
	if b.LogOpen {
		output = append(output, "", Title.Render("OUTPUT"))
		if len(b.Log) == 0 {
			output = append(output, "    "+Muted.Render("nothing yet"))
		}
		for _, line := range b.tailLog() {
			output = append(output, "    "+Muted.Render(
				ansi.Truncate(line, max(8, width-6), "…")))
		}
	}

	// Split, not a single element: footer() returns five lines as one string,
	// and a budget that counts elements rather than lines comes up four short
	// -- which is exactly how the controls ended up off the bottom.
	tail := append([]string{""}, strings.Split(b.footer(width), "\n")...)
	if !hideHints {
		tail = append(tail, "", b.hintLine(width))
	}
	if b.Recovery != nil {
		hint := b.Recovery.Note
		if hint == "" {
			hint = strings.Join(b.Recovery.Command, " ")
		}
		leader := Accent.Render("R") + " "
		room := max(4, width-ansi.StringWidth(leader)-3)
		tail = append([]string{
			leader + Text.Render(ansi.Truncate(b.Recovery.Label, room, "…")) +
				"  " + Muted.Render(ansi.Truncate(hint, room, "…")),
		}, tail...)
	}

	if height <= 0 {
		return clipLines(strings.Join(
			concat(head, done, building, output, messages, tail), "\n"), height)
	}

	// Problems do not get to crowd out the work they are about. Left alone,
	// half a dozen warnings filled the screen and no finished package was
	// visible at all; dismissing them is how the list gets shorter.
	fixed := len(head) + len(building) + len(output) + len(tail)
	if cap := max(0, (height-fixed)/2); len(messages) > cap {
		messages = messages[len(messages)-cap:]
		// cut at a message boundary so a warning does not start mid-sentence
		for len(messages) > 0 && messages[0] != "" {
			messages = messages[1:]
		}
	}

	// Everything except the finished list is fixed. What is left goes to the
	// feed, newest first, and whatever does not fit is named rather than
	// silently dropped.
	room := height - len(head) - len(building) - len(messages) - len(output) - len(tail)
	if room < len(done) {
		// not even the "DONE" heading fits; drop entries until it does
		keep := max(0, room-3) // heading, its blank, and room for the note
		if keep == 0 {
			done = nil
		} else {
			entries := done[2:]
			if len(entries) > keep {
				dropped := len(entries) - keep
				done = append(done[:2],
					"    "+Muted.Render(fmt.Sprintf("… %d earlier", dropped)))
				done = append(done, entries[dropped:]...)
			}
		}
	}
trim:
	for len(head)+len(done)+len(building)+len(output)+len(messages)+len(tail) > height {
		// shed whatever is least useful, oldest first
		switch {
		case len(done) > 2:
			done = append(done[:2], done[3:]...)
		case len(output) > 0:
			output = output[:len(output)-1]
		case len(messages) > 0:
			messages = messages[1:]
		case len(building) > 3:
			building = building[:len(building)-1]
		default:
			// Nothing left to give: the controls stay, and the backstop below
			// trims whatever is still over. This is a labelled break because
			// an unlabelled one breaks the switch, not the loop -- which is a
			// spin, not a fix.
			break trim
		}
	}
	return clipLines(strings.Join(
		concat(head, done, building, output, messages, tail), "\n"), height)
}

// messageLines renders the warnings, with the selected one opened out.
func (b *BuildView) messageLines(width int) []string {
	out := make([]string, 0, len(b.Messages)*2)
	for i, message := range b.Messages {
		if i == b.Cursor {
			// The same treatment the list gives a selected row: a filled bar,
			// not just a different glyph. A marker alone was too quiet to tell
			// apart from the warning's own styling.
			out = append(out, "",
				Selected.Render(fit(ansi.Strip("▸ "+message.Text), width)))
			for _, line := range message.Details {
				out = append(out, "    "+Muted.Render(
					ansi.Truncate(line, width-6, "…")))
			}
			continue
		}
		out = append(out, "", Warn.Render("! "+
			ansi.Truncate(message.Text, width-4, "…")))
	}
	return out
}

// feedLines renders the finished list, oldest first.
func (b *BuildView) feedLines(width int) []string {
	out := make([]string, 0, len(b.Feed))
	for _, entry := range b.Feed {
		out = append(out, b.feedLine(entry, width))
	}
	return out
}

func concat(parts ...[]string) []string {
	n := 0
	for _, part := range parts {
		n += len(part)
	}
	out := make([]string, 0, n)
	for _, part := range parts {
		out = append(out, part...)
	}
	return out
}

func (b *BuildView) phaseLine() string {
	if b.Finished {
		status := "done"
		if b.Return != 0 {
			status = fmt.Sprintf("exited %d", b.Return)
		}
		return fmt.Sprintf("%s · %s", status, seconds(b.clock()))
	}
	label := b.Phase
	if label == "" && len(b.Phases) > 0 {
		label = b.Phases[0]
	}
	where := ""
	for i, phase := range b.Phases {
		if phase == label {
			where = fmt.Sprintf("%d/%d", i+1, len(b.Phases))
		}
	}
	// The count lives on the progress line below; repeating it here was the
	// third place the same two numbers appeared.
	if where == "" {
		return seconds(b.clock())
	}
	return fmt.Sprintf("%s %s · %s", label, where, seconds(b.clock()))
}

func (b *BuildView) feedLine(entry feedEntry, width int) string {
	mark, style := "+", Incoming
	switch entry.state {
	case "fail":
		mark, style = "x", Outgoing
	case "skip":
		mark, style = "-", Warn
	}
	left := "    " + style.Render(mark) + " " + Text.Render(entry.name)
	if entry.version != "" {
		left += "  " + Ver.Render(entry.version)
	}
	if entry.shared {
		left += "  " + Muted.Render("↻ shared")
	}
	return spread(left, Muted.Render(seconds(entry.elapsed)), width)
}

// minEstimateProgress is how far a run must have got before an estimate means
// anything. The arithmetic divides by progress, so near the start it is both
// unstable and enormous.
const minEstimateProgress = 0.05

// spinner cycles with the frame counter, which the build events advance.
var buildSpinner = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}

// pulse returns the indicator for this frame. A build that takes minutes and
// looks identical every second is indistinguishable from one that has hung.
func (b *BuildView) pulse() string {
	return Accent.Render(buildSpinner[b.Frame%len(buildSpinner)])
}

func (b *BuildView) runningLine(state plan.BuildState, width int) string {
	left := "    " + b.pulse() + " " + Text.Render(state.Name)
	if state.Phase != "" {
		left += "  " + Muted.Render(state.Phase)
	}
	if state.Workers != "" {
		left += "  " + Muted.Render(state.Workers)
	} else if state.Jobs > 1 {
		left += "  " + Muted.Render(fmt.Sprintf("%d jobs", state.Jobs))
	}
	return spread(left, Muted.Render(seconds(state.Elapsed)), width)
}

// fit pads or truncates to an exact visible width, so columns line up whatever
// the content.
func fit(s string, width int) string {
	w := ansi.StringWidth(s)
	if w == width {
		return s
	}
	if w < width {
		return s + strings.Repeat(" ", width-w)
	}
	return ansi.Truncate(s, width, "…")
}

// counts is the single answer to "how many are done" and "how many failed".
//
// One source, counted the same way everywhere: the header must agree with the
// list beneath it, and two tallies that can disagree eventually will.
func (b *BuildView) counts() (done, failed int) {
	for _, entry := range b.Feed {
		if entry.state == "fail" {
			failed++
		} else {
			done++
		}
	}
	return done, failed
}

func (b *BuildView) footer(width int) string {
	done, failed := b.counts()
	waiting := max(0, b.Total-done-failed-len(b.Running))

	failStyle := Muted
	if failed > 0 {
		failStyle = Err
	}

	// One row of counts, leaving the progress line as the only thing that says
	// how far the run has got.
	parts := []string{
		Accent.Render(fmt.Sprintf("▶ %d running", len(b.Running))),
		OK.Render(fmt.Sprintf("✓ %d done", done)),
		failStyle.Render(fmt.Sprintf("✗ %d failed", failed)),
		Muted.Render(fmt.Sprintf("· %d waiting", waiting)),
	}
	line := strings.Join(parts, Desc.Render("   "))

	// Downloads are only known for the repository phase; paru does not report
	// what it fetches, so there is nothing to say during the AUR one.
	if b.Downloads > 0 {
		detail := bytes(b.Downloads)
		if rate := rateLabel(b.Rate); rate != "—" {
			detail += "  " + rate
		}
		return Title.Render("BUILDS") + "\n" +
			spread(line, Muted.Render(detail), width)
	}
	// The one row with no spread, so it must be fitted to the width itself.
	return Title.Render("BUILDS") + "\n" +
		ansi.Truncate(line, width, "…")
}

// progressLine is the bar, drawn across the whole width.
//
// The full width is what makes movement legible; the footer stays a table of
// counts rather than a second place to read the same numbers.
func (b *BuildView) progressLine(width int) string {
	done, failed := b.counts()
	// Two totals, because they answer different questions: the footer counts
	// packages, and the bar measures work. A run of forty one-second downloads
	// and one four-minute build is nearly over when the downloads are done.
	// The package count is the fallback: a run whose weights were never sent,
	// or a stream being replayed, still has to show something sensible.
	total := max(b.Weight, b.Total, b.resolvedWeight())
	worked := min(b.resolvedWeight(), total)

	// Where the run stands, in the fewest words that say it.
	status := fmt.Sprintf("%d/%d", worked, total)
	if failed > 0 {
		status += fmt.Sprintf(" · %d failed", failed)
	}
	if left, ok := b.eta(); ok {
		status += " · ~" + seconds(left) + " left"
	}
	if b.Finished {
		status = fmt.Sprintf("%d/%d · %s", done+failed, total,
			map[bool]string{true: "complete", false: "stopped"}[failed == 0])
		if failed > 0 {
			status = fmt.Sprintf("%d/%d · %d failed", done+failed, total, failed)
		}
	}

	// nothing has succeeded: the bar should not read as progress
	style := Accent
	if failed > 0 && done == 0 {
		style = Err
	}
	info := Muted.Render(status)
	room := width - ansi.StringWidth(info) - 3
	if room < 10 {
		return fit(info, width)
	}
	return styleBarBar(worked, total, room, style) + "  " + info
}

// styleBarBar draws the filled part in the given style.
func styleBarBar(done, total, width int, style lipgloss.Style) string {
	if total <= 0 {
		return Muted.Render(strings.Repeat("─", width))
	}
	filled := min(width, max(0, done*width/total))
	if filled == width {
		return style.Render(strings.Repeat("━", width))
	}
	return style.Render(strings.Repeat("━", filled)) +
		Rule.Render(strings.Repeat("─", width-filled))
}

// progressBar draws completed work against the whole.
func progressBar(done, total, width int) string {
	if width <= 0 {
		return ""
	}
	if total <= 0 {
		return Muted.Render(strings.Repeat("░", width))
	}
	filled := done * width / total
	filled = min(width, max(0, filled))
	if filled == width {
		return OK.Render(strings.Repeat("█", width))
	}
	return OK.Render(strings.Repeat("█", filled)) +
		Muted.Render(strings.Repeat("░", width-filled))
}

// eta estimates what is left from the average of what has finished.
//
// Only offered once two packages are done: one sample is not an average, and
// showing an estimate derived from it would be inventing a number.
// eta is what is left, from how long the run has taken against how far it has
// got.
//
// Linear: half way in the same time again, a quarter of the way in three times
// as long. It uses everything the run has shown rather than a subset, so it is
// available from the first moments and corrects itself as the run goes -- a
// mean over finished packages could not say anything until several had.
//
// It inherits the weight model: "how far it has got" is measured in work, so
// the estimate is only as good as the idea that the remaining work is
// proportional to the remaining weight.
func (b *BuildView) eta() (float64, bool) {
	worked := b.resolvedWeight()
	total := max(b.Weight, b.Total, worked)
	if worked <= 0 || total <= 0 {
		return 0, false
	}
	elapsed := b.clock()
	if elapsed <= 0 {
		return 0, false
	}
	progress := float64(worked) / float64(total)
	if progress < minEstimateProgress {
		// a couple of per cent of a run says nothing about the other
		// ninety-eight, and the arithmetic divides by it
		return 0, false
	}
	return elapsed * (1 - progress) / progress, true
}

func rateLabel(rate float64) string {
	if rate <= 0 {
		return "—"
	}
	return bytes(int64(rate)) + "/s"
}

func (b *BuildView) hintLine(width int) string {
	// Only what applies right now, and only the few that matter. Everything
	// else is behind ?, because a bar of eight bindings is one nobody reads --
	// the same reason the main list keeps its own help behind a key.
	type hint struct {
		key, desc string
		weight    int
	}
	all := []hint{}
	if b.Finished {
		all = append(all, hint{"q", "quit", 100}, hint{"esc", "back to the list", 95})
		if len(b.Messages) == 0 {
			all = append(all, hint{"enter", "back to the list", 90})
		}
	} else {
		all = append(all, hint{"ctrl+c", "abort", 100})
		if b.CanSkip() {
			all = append(all, hint{"s", "skip this package", 96})
		}
	}
	if b.Recovery != nil {
		all = append(all, hint{"R", b.Recovery.Label, 98})
	}
	if len(b.Messages) > 0 {
		all = append(all,
			hint{"enter", "read", 92},
			hint{"j/k", "warnings", 70},
			hint{"d", "dismiss", 65})
	}
	all = append(all,
		hint{"o", "output", 60},
		hint{"D", "finished", 58})
	// last, and never the one dropped
	all = append(all, hint{"?", "help", 1})

	// Bounded twice, because either bound alone gets it wrong. A ceiling keeps
	// the bar from growing with the terminal -- on a wide screen it reached
	// nine bindings simply because there was room. Fitting to the width keeps
	// the important keys on a narrow one, where a fixed ceiling pushed them
	// past the edge and the tail was cut instead.
	const ceiling = 6
	order := make([]int, len(all))
	for i := range order {
		order[i] = i
	}
	sort.SliceStable(order, func(a, c int) bool {
		return all[order[a]].weight > all[order[c]].weight
	})

	sep := Desc.Render("  ·  ")
	part := func(h hint) string {
		return Key.Render(h.key) + " " + Desc.Render(h.desc)
	}

	// The help key is never dropped, so its room is reserved first: fitting
	// the others and appending it anyway overflowed a narrow terminal, and
	// truncating instead cut the one hint that explains the rest.
	helpIndex := len(all) - 1
	help := part(all[helpIndex])
	budget := width - ansi.StringWidth(help) - ansi.StringWidth(sep)

	keep := map[int]bool{helpIndex: true}
	used := 0
	for i, index := range order {
		if i >= ceiling || index == helpIndex {
			continue
		}
		cost := ansi.StringWidth(part(all[index]))
		if used > 0 {
			cost += ansi.StringWidth(sep)
		}
		if used+cost > budget {
			continue
		}
		keep[index] = true
		used += cost
	}

	parts := make([]string, 0, len(keep))
	for i, h := range all {
		if keep[i] {
			parts = append(parts, part(h))
		}
	}
	return strings.Join(parts, sep)
}

// renderFeedOnly is the ledger form: finished packages and nothing else, which
// is what you want when you would rather watch a log than a dashboard.
func (b *BuildView) renderFeedOnly(width, height int) string {
	lines := []string{}
	for _, entry := range b.Feed {
		mark, style := "+", Incoming
		if entry.state == "fail" {
			mark, style = "x", Outgoing
		}
		line := style.Render(mark) + " " + Text.Render(entry.name)
		if entry.version != "" {
			line += " " + Ver.Render(entry.version)
		}
		if entry.elapsed > 0 {
			line += "  " + Muted.Render(seconds(entry.elapsed))
		}
		lines = append(lines, ansi.Truncate(line, width, "…"))
	}
	for _, state := range b.Running {
		line := Accent.Render("▶ ") + Text.Render(state.Name) + "  " +
			Muted.Render(state.Phase)
		lines = append(lines, spread(line, Muted.Render(seconds(state.Elapsed)), width))
	}
	if len(lines) == 0 {
		lines = append(lines, Muted.Render("waiting…"))
	}
	// Show the end: a few thousand finished packages is a megabyte a frame at
	// five frames a second when every entry is rendered.
	if height > 0 && len(lines) > height {
		lines = lines[len(lines)-height:]
	}
	return strings.Join(lines, "\n")
}