add mascot

This commit is contained in:
2026-07-29 08:33:37 +02:00
committed by pablu
parent 027057e85f
commit 28e418abd6
8 changed files with 994 additions and 64 deletions

View File

@@ -237,6 +237,9 @@ repository = "" # optional "owner/repository"
show_all = false # requires repository show_all = false # requires repository
limit = 50 # 1-1000 limit = 50 # 1-1000
endpoint = "https://api.github.com/graphql" endpoint = "https://api.github.com/graphql"
mascot = false # show the optional Difflet terminal mascot
mascot_expressive = false # allow emotional Difflet expressions
mascot_animated = false # allow brief state-driven motion
[display] [display]
fold_resolved = true fold_resolved = true
@@ -301,6 +304,18 @@ showing every repeated `COMMENTED` event.
`viewer_label = "login"` shows your GitHub username like every other author. `viewer_label = "login"` shows your GitHub username like every other author.
Set it to `"you"` to replace your username with `@you` throughout the UI. Set it to `"you"` to replace your username with `@you` throughout the UI.
Difflet is disabled by default. Set `mascot = true` to keep it visible to the
right next to the active view's naturally sized header, separated by a
small gap. On normal terminal widths Difflet is centered horizontally and the
header uses the space to its left. When centering would make the header too
narrow, Difflet falls back to a small right-edge inset. Header information
wraps when the combined header and mascot do not fit. The layout adds only the
vertical rows required to display the four-line mascot.
`mascot_animated` controls brief loading, blink, success, and error motion
independently from `mascot_expressive`, which permits stronger emotional
faces. Disabling animation leaves the appropriate final state visible.
Disabling the mascot preserves the normal header layout.
Thread categories are: Thread categories are:
- `unresolved`: current unresolved threads; - `unresolved`: current unresolved threads;

View File

@@ -31,6 +31,9 @@ type Config struct {
ShowAll bool `toml:"show_all"` ShowAll bool `toml:"show_all"`
Limit int `toml:"limit"` Limit int `toml:"limit"`
Endpoint string `toml:"endpoint"` Endpoint string `toml:"endpoint"`
Mascot bool `toml:"mascot"`
MascotExpressive bool `toml:"mascot_expressive"`
MascotAnimated bool `toml:"mascot_animated"`
Display DisplayConfig `toml:"display"` Display DisplayConfig `toml:"display"`
Paths PathConfig `toml:"paths"` Paths PathConfig `toml:"paths"`
Threads ThreadConfig `toml:"threads"` Threads ThreadConfig `toml:"threads"`
@@ -101,6 +104,9 @@ func defaultConfig() Config {
RefreshInterval: configDuration{10 * time.Second}, RefreshInterval: configDuration{10 * time.Second},
Limit: 50, Limit: 50,
Endpoint: "https://api.github.com/graphql", Endpoint: "https://api.github.com/graphql",
Mascot: false,
MascotExpressive: false,
MascotAnimated: false,
Display: DisplayConfig{ Display: DisplayConfig{
FoldResolved: true, FoldResolved: true,
ThreadListWidthPercent: 33, ThreadListWidthPercent: 33,

View File

@@ -34,6 +34,9 @@ repository = "owner/repo"
show_all = true show_all = true
limit = 75 limit = 75
endpoint = "https://github.example.com/api/graphql" endpoint = "https://github.example.com/api/graphql"
mascot = true
mascot_expressive = true
mascot_animated = true
[display] [display]
fold_resolved = false fold_resolved = false
@@ -74,6 +77,7 @@ up = ["ctrl+k"]
} }
if got.Theme != "light" || got.RefreshInterval.Duration != 25*time.Second || if got.Theme != "light" || got.RefreshInterval.Duration != 25*time.Second ||
got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 || got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 ||
!got.Mascot || !got.MascotExpressive || !got.MascotAnimated ||
got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 || got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 ||
got.Display.DashboardMode != "hotkey" || got.Display.DashboardMode != "hotkey" ||
got.Display.CompactReviews || got.Display.ViewerLabel != "you" || got.Display.CompactReviews || got.Display.ViewerLabel != "you" ||

328
difflet.go Normal file
View File

@@ -0,0 +1,328 @@
package main
import (
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const (
diffletWidth = 9
diffletHeight = 4
diffletGap = 2
diffletMinimumHeaderWidth = 10
diffletFallbackRightPadding = 1
)
type DiffletExpression string
const (
DiffletIdle DiffletExpression = "idle"
DiffletFocused DiffletExpression = "focused"
DiffletHappy DiffletExpression = "happy"
DiffletApproved DiffletExpression = "approved"
DiffletSurprised DiffletExpression = "surprised"
DiffletConcerned DiffletExpression = "concerned"
DiffletConfused DiffletExpression = "confused"
DiffletSad DiffletExpression = "sad"
DiffletAnnoyed DiffletExpression = "annoyed"
DiffletError DiffletExpression = "error"
DiffletSleeping DiffletExpression = "sleeping"
DiffletCurious DiffletExpression = "curious"
)
var diffletFaces = map[DiffletExpression]string{
DiffletIdle: "•_•",
DiffletFocused: "-_-",
DiffletHappy: "^_^",
DiffletApproved: "^o^",
DiffletSurprised: "•o•",
DiffletConcerned: "•^•",
DiffletConfused: "•~•",
DiffletSad: ";_;",
DiffletAnnoyed: ">_<",
DiffletError: "x_x",
DiffletSleeping: "u_u",
DiffletCurious: "o_o",
}
type DiffletFrame struct {
Expression DiffletExpression
Feet string
}
func (frame DiffletFrame) lines() []string {
feet := frame.Feet
if feet == "" {
feet = " ▝ ▘"
}
return []string{
"▄███████▄",
"█+ " + diffletFaces[frame.Expression] + " -█",
"▀███████▀",
feet + strings.Repeat(" ", max(0, diffletWidth-lipgloss.Width(feet))),
}
}
func (frame DiffletFrame) render() []string {
lines := frame.lines()
lines[1] = "█" + okStyle.Render("+") + " " + diffletFaces[frame.Expression] +
" " + badStyle.Render("-") + "█"
return lines
}
type diffletAnimation struct {
frames []DiffletFrame
interval time.Duration
loop bool
settle DiffletExpression
}
type diffletState int
const (
diffletIdle diffletState = iota
diffletLoading
diffletFocused
diffletConcerned
diffletHappy
diffletApproved
diffletSleeping
diffletNewComment
diffletSuccess
diffletRecoverableError
diffletFatalError
diffletSad
)
type diffletTickMsg struct {
generation uint64
}
type diffletModel struct {
enabled bool
visible bool
expressive bool
animated bool
state diffletState
expression DiffletExpression
animation diffletAnimation
frame int
generation uint64
blinking bool
blinkCycle uint64
}
func newDifflet(enabled, expressive, animated bool) diffletModel {
model := diffletModel{
enabled: enabled, visible: true, expressive: expressive, animated: animated,
state: diffletLoading, expression: DiffletIdle,
}
_ = model.setState(diffletLoading)
return model
}
func (d *diffletModel) start() tea.Cmd {
if !d.enabled || !d.visible || !d.animated {
return nil
}
if d.state == diffletIdle {
return d.tick(d.blinkDelay())
}
if len(d.animation.frames) > 0 {
return d.tick(d.animation.interval)
}
return nil
}
func (d *diffletModel) setState(state diffletState) tea.Cmd {
d.state = state
d.generation++
d.frame = 0
d.blinking = false
d.animation = diffletAnimation{}
d.expression = d.staticExpression(state)
if !d.enabled || !d.visible || !d.animated {
return nil
}
switch state {
case diffletIdle:
return d.tick(d.blinkDelay())
case diffletLoading:
d.animation = diffletAnimation{
frames: []DiffletFrame{
{Expression: DiffletIdle, Feet: " ▝ ▘"},
{Expression: DiffletIdle, Feet: " ▝ ▘"},
{Expression: DiffletIdle, Feet: " ▝ ▘"},
{Expression: DiffletIdle, Feet: " ▝ ▘"},
},
interval: 180 * time.Millisecond,
loop: true,
settle: DiffletIdle,
}
case diffletNewComment:
d.animation = diffletAnimation{
frames: []DiffletFrame{
{Expression: DiffletIdle},
{Expression: DiffletSurprised},
{Expression: DiffletConcerned},
},
interval: 200 * time.Millisecond,
settle: DiffletConcerned,
}
case diffletSuccess:
d.animation = diffletAnimation{
frames: []DiffletFrame{
{Expression: DiffletIdle},
{Expression: DiffletHappy},
{Expression: DiffletApproved},
{Expression: DiffletHappy},
},
interval: 175 * time.Millisecond,
settle: DiffletHappy,
}
case diffletRecoverableError:
if d.expressive {
d.animation = diffletAnimation{
frames: []DiffletFrame{
{Expression: DiffletIdle},
{Expression: DiffletAnnoyed},
{Expression: DiffletSad},
},
interval: 220 * time.Millisecond,
settle: DiffletSad,
}
}
case diffletFatalError:
if d.expressive {
d.animation = diffletAnimation{
frames: []DiffletFrame{
{Expression: DiffletIdle},
{Expression: DiffletAnnoyed},
{Expression: DiffletError},
},
interval: 220 * time.Millisecond,
settle: DiffletError,
}
}
}
if len(d.animation.frames) == 0 {
return nil
}
d.expression = d.animation.frames[0].Expression
return d.tick(d.animation.interval)
}
func (d *diffletModel) setVisible(visible bool) tea.Cmd {
if d.visible == visible {
return nil
}
d.visible = visible
if !visible {
d.generation++
d.animation = diffletAnimation{}
d.blinking = false
return nil
}
return d.setState(d.state)
}
func (d *diffletModel) staticExpression(state diffletState) DiffletExpression {
switch state {
case diffletFocused:
return DiffletFocused
case diffletConcerned, diffletNewComment:
return DiffletConcerned
case diffletHappy, diffletSuccess:
return DiffletHappy
case diffletApproved:
return DiffletApproved
case diffletSleeping:
return DiffletSleeping
case diffletRecoverableError:
if d.expressive {
return DiffletAnnoyed
}
return DiffletConcerned
case diffletFatalError:
return DiffletError
case diffletSad:
if d.expressive {
return DiffletSad
}
return DiffletConcerned
default:
return DiffletIdle
}
}
func (d *diffletModel) update(msg diffletTickMsg) tea.Cmd {
if !d.enabled || !d.visible || !d.animated || msg.generation != d.generation {
return nil
}
if d.state == diffletIdle {
if !d.blinking {
d.blinking = true
d.expression = DiffletFocused
return d.tick(125 * time.Millisecond)
}
d.blinking = false
d.expression = DiffletIdle
d.blinkCycle++
return d.tick(d.blinkDelay())
}
if len(d.animation.frames) == 0 {
return nil
}
next := d.frame + 1
if next >= len(d.animation.frames) {
if !d.animation.loop {
settle := d.animation.settle
d.animation = diffletAnimation{}
d.expression = settle
return nil
}
next = 0
}
d.frame = next
d.expression = d.animation.frames[next].Expression
return d.tick(d.animation.interval)
}
func (d diffletModel) frameLines() []string {
if !d.enabled || !d.visible {
return nil
}
if len(d.animation.frames) > 0 && d.frame < len(d.animation.frames) {
frame := d.animation.frames[d.frame]
frame.Expression = d.expression
return frame.render()
}
return (DiffletFrame{Expression: d.expression}).render()
}
func diffletHeaderWidth(width int) int {
centeredLeft := max(0, (width-diffletWidth)/2)
if centeredLeft-diffletGap >= diffletMinimumHeaderWidth {
return centeredLeft - diffletGap
}
rightPadding := 0
if width-diffletWidth-diffletGap > 1 {
rightPadding = diffletFallbackRightPadding
}
return max(1, width-diffletWidth-diffletGap-rightPadding)
}
func (d diffletModel) tick(after time.Duration) tea.Cmd {
generation := d.generation
return tea.Tick(after, func(time.Time) tea.Msg {
return diffletTickMsg{generation: generation}
})
}
func (d diffletModel) blinkDelay() time.Duration {
return 5*time.Second + time.Duration((d.generation+d.blinkCycle*3)%6)*650*time.Millisecond
}

370
difflet_test.go Normal file
View File

@@ -0,0 +1,370 @@
package main
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/muesli/termenv"
)
func TestDiffletExpressionsAndFramesHaveStableCellDimensions(t *testing.T) {
expressions := []DiffletExpression{
DiffletIdle, DiffletFocused, DiffletHappy, DiffletApproved,
DiffletSurprised, DiffletConcerned, DiffletConfused, DiffletSad,
DiffletAnnoyed, DiffletError, DiffletSleeping, DiffletCurious,
}
for _, expression := range expressions {
face := diffletFaces[expression]
if width := lipgloss.Width(face); width != 3 {
t.Errorf("%s face width = %d, want 3: %q", expression, width, face)
}
lines := (DiffletFrame{Expression: expression}).lines()
if len(lines) != diffletHeight {
t.Fatalf("%s has %d lines, want %d", expression, len(lines), diffletHeight)
}
for row, line := range lines {
if width := lipgloss.Width(line); width != diffletWidth {
t.Errorf("%s row %d width = %d, want %d: %q",
expression, row, width, diffletWidth, line)
}
}
}
}
func TestDiffletCanonicalFramePreservesGlyphsAndSpacing(t *testing.T) {
got := (DiffletFrame{Expression: DiffletIdle}).lines()
want := []string{
"▄███████▄",
"█+ •_• -█",
"▀███████▀",
" ▝ ▘ ",
}
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("canonical frame:\n%q\nwant:\n%q", got, want)
}
}
func TestDiffletStylesOnlyDiffSigns(t *testing.T) {
previousProfile := lipgloss.ColorProfile()
lipgloss.SetColorProfile(termenv.TrueColor)
t.Cleanup(func() { lipgloss.SetColorProfile(previousProfile) })
previousOK, previousBad := okStyle, badStyle
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00ff00"))
badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000"))
t.Cleanup(func() { okStyle, badStyle = previousOK, previousBad })
rendered := (DiffletFrame{Expression: DiffletIdle}).render()
wantMiddle := "█" + okStyle.Render("+") + " •_• " + badStyle.Render("-") + "█"
if rendered[1] != wantMiddle {
t.Fatalf("middle row = %q, want %q", rendered[1], wantMiddle)
}
if ansi.Strip(strings.Join(rendered, "\n")) !=
strings.Join((DiffletFrame{Expression: DiffletIdle}).lines(), "\n") {
t.Fatal("styling changed Difflet glyphs or spacing")
}
for _, row := range []int{0, 2, 3} {
if strings.Contains(rendered[row], "\x1b[") {
t.Fatalf("row %d unexpectedly styled: %q", row, rendered[row])
}
}
}
func TestDiffletEnabledSitsRightOfWrappingHeader(t *testing.T) {
app := NewAppWithSettings(
&recordingService{}, "owner", "repository", false, 10, 10,
AppSettings{Mascot: true},
)
updated, _ := app.Update(tea.WindowSizeMsg{Width: 24, Height: 12})
app = updated.(App)
rendered := strings.Split(app.View(), "\n")
if len(rendered) < diffletHeight+2 {
t.Fatalf("rendered rows = %d, want at least %d", len(rendered), diffletHeight+2)
}
headerWidth := diffletHeaderWidth(app.width)
content := app
content.headerWidth = headerWidth
_, _, ok := splitHeader(content.viewContent())
if !ok {
t.Fatal("test view did not expose a header")
}
wantMascotLeft := headerWidth + diffletGap
for row, mascotLine := range (DiffletFrame{Expression: DiffletIdle}).lines() {
plain := ansi.Strip(rendered[row])
if !strings.Contains(plain, strings.TrimRight(mascotLine, " ")) {
t.Fatalf("mascot row %d is not visible: %q", row, plain)
}
left := strings.Index(plain, strings.TrimRight(mascotLine, " "))
if left != wantMascotLeft {
t.Fatalf("mascot row %d starts at %d, want adjacent position %d",
row, left, wantMascotLeft)
}
}
blankRow := -1
var headerText strings.Builder
for row := 0; row < len(rendered); row++ {
line := rendered[row]
plain := ansi.Strip(line)
if strings.TrimSpace(plain) == "" {
blankRow = row
break
}
headerText.WriteString(strings.TrimSpace(
ansi.Cut(plain, 0, wantMascotLeft-diffletGap),
))
}
normalizedHeader := strings.ReplaceAll(headerText.String(), " ", "")
for _, information := range []string{"diple", "owner/repository", "assignedtoyou"} {
if !strings.Contains(normalizedHeader, information) {
t.Fatalf("wrapped header does not contain %q: %s", information, normalizedHeader)
}
}
if blankRow < diffletHeight {
t.Fatalf("blank row = %d, want at or below mascot row %d",
blankRow, diffletHeight)
}
}
func TestDiffletIsCenteredAtNormalWidths(t *testing.T) {
mascot := (DiffletFrame{Expression: DiffletIdle}).lines()
const width = 40
headerWidth := diffletHeaderWidth(width)
rendered := strings.Split(
renderHeaderWithDifflet([]string{"diple"}, mascot, width, headerWidth, diffletGap),
"\n",
)
for row, line := range rendered[:diffletHeight] {
mascotText := strings.TrimRight(mascot[row], " ")
wantLeft := (width - diffletWidth) / 2
if left := strings.Index(ansi.Strip(line), mascotText); left != wantLeft {
t.Fatalf("row %d mascot starts at %d, want %d: %q",
row, left, wantLeft, line)
}
}
}
func TestDiffletLongHeaderKeepsMascotCentered(t *testing.T) {
const width = 80
headerWidth := diffletHeaderWidth(width)
if headerWidth != 33 {
t.Fatalf("header width = %d, want 33", headerWidth)
}
mascot := (DiffletFrame{Expression: DiffletIdle}).lines()
rendered := strings.Split(
renderHeaderWithDifflet(
[]string{strings.Repeat("x", headerWidth)},
mascot,
width,
headerWidth,
diffletGap,
),
"\n",
)
mascotLeft := strings.Index(ansi.Strip(rendered[0]), strings.TrimRight(mascot[0], " "))
if mascotLeft != headerWidth+diffletGap {
t.Fatalf("mascot starts at %d, want %d", mascotLeft, headerWidth+diffletGap)
}
if mascotLeft != (width-diffletWidth)/2 {
t.Fatalf("mascot starts at %d, want centered position %d",
mascotLeft, (width-diffletWidth)/2)
}
}
func TestDiffletFallsBackToSmallRightPadding(t *testing.T) {
const width = 24
headerWidth := diffletHeaderWidth(width)
mascotLeft := headerWidth + diffletGap
if rightPadding := width - mascotLeft - diffletWidth; rightPadding != 1 {
t.Fatalf("fallback right padding = %d, want 1", rightPadding)
}
}
func TestDiffletDisabledPreservesViewExactly(t *testing.T) {
app := NewAppWithSettings(
&recordingService{}, "", "", false, 10, 10,
AppSettings{MascotExpressive: true, MascotAnimated: true},
)
updated, _ := app.Update(tea.WindowSizeMsg{Width: 20, Height: 12})
app = updated.(App)
got := app.View()
want := app.viewContent()
if got != want {
t.Fatalf("disabled mascot changed rendered view:\ngot:\n%q\nwant:\n%q", got, want)
}
}
func TestDiffletHeaderMeasurementMatchesRenderedHeader(t *testing.T) {
tests := []struct {
name string
screen screen
}{
{name: "picker", screen: prScreen},
{name: "dashboard", screen: dashboardScreen},
{name: "threads", screen: threadScreen},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
app := NewAppWithSettings(
&recordingService{}, "owner", "repository", false, 10, 10,
AppSettings{Mascot: true},
)
app.screen = test.screen
app.details = PRDetails{PullRequest: PullRequest{
Owner: "owner", Repository: "repository",
RepoWithOwner: "owner/repository", Number: 42,
Title: "A pull request with a useful title",
}}
updated, _ := app.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
app = updated.(App)
app.headerWidth = diffletHeaderWidth(app.width)
measured, ok := app.diffletHeaderLineCount()
if !ok {
t.Fatal("normal screen did not expose measurable header lines")
}
renderedHeader, _, ok := splitHeader(app.viewContent())
if !ok {
t.Fatal("rendered normal screen did not expose a header")
}
if measured != len(renderedHeader) {
t.Fatalf("measured header lines = %d, rendered = %d",
measured, len(renderedHeader))
}
})
}
}
func TestDiffletKeepsCompleteFrameAtNarrowPhysicalMinimum(t *testing.T) {
app := NewAppWithSettings(
&recordingService{}, "", "", false, 10, 10,
AppSettings{Mascot: true},
)
updated, _ := app.Update(tea.WindowSizeMsg{Width: diffletWidth, Height: 12})
app = updated.(App)
rendered := strings.Split(app.View(), "\n")
for row, mascotLine := range (DiffletFrame{Expression: DiffletIdle}).lines() {
if got := ansi.Strip(rendered[row]); got != mascotLine {
t.Fatalf("mascot row %d = %q, want %q", row, got, mascotLine)
}
}
headerBelowMascot := ansi.Strip(strings.Join(rendered[diffletHeight:], "\n"))
normalizedHeader := strings.NewReplacer("\n", "", " ", "").Replace(headerBelowMascot)
if !strings.Contains(normalizedHeader, "diple") {
t.Fatalf("header did not wrap below complete mascot: %q", headerBelowMascot)
}
}
func TestDiffletStopsTicksBelowPhysicalMinimum(t *testing.T) {
app := NewAppWithSettings(
&recordingService{}, "", "", false, 10, 10,
AppSettings{Mascot: true, MascotAnimated: true},
)
if command := app.difflet.start(); command != nil {
t.Fatal("hidden startup mascot scheduled a tick")
}
updated, command := app.Update(tea.WindowSizeMsg{Width: 12, Height: 12})
app = updated.(App)
if command == nil {
t.Fatal("visible animated mascot did not schedule a tick")
}
generation := app.difflet.generation
updated, command = app.Update(tea.WindowSizeMsg{Width: 8, Height: 12})
app = updated.(App)
if command != nil {
t.Fatal("mascot below its nine-cell minimum scheduled a tick")
}
if app.difflet.visible {
t.Fatal("mascot below its nine-cell minimum remained visible")
}
updated, command = app.Update(diffletTickMsg{generation: generation})
app = updated.(App)
if command != nil || app.difflet.visible {
t.Fatal("stale tick restarted hidden mascot")
}
}
func TestDiffletStaticOptionsSelectFinalSafeExpression(t *testing.T) {
model := newDifflet(true, false, false)
if command := model.setState(diffletSuccess); command != nil {
t.Fatal("animation-disabled Difflet scheduled a tick")
}
if model.expression != DiffletHappy {
t.Fatalf("static success = %s, want %s", model.expression, DiffletHappy)
}
if command := model.setState(diffletRecoverableError); command != nil {
t.Fatal("non-expressive Difflet scheduled an error animation")
}
if model.expression != DiffletConcerned {
t.Fatalf("non-expressive error = %s, want %s", model.expression, DiffletConcerned)
}
model.setState(diffletFatalError)
if model.expression != DiffletError {
t.Fatalf("non-expressive fatal error = %s, want %s", model.expression, DiffletError)
}
}
func TestDiffletFocusedStateDoesNotLoop(t *testing.T) {
model := newDifflet(true, true, true)
if command := model.setState(diffletFocused); command != nil {
t.Fatal("focused state scheduled continuous animation")
}
if model.expression != DiffletFocused || len(model.animation.frames) != 0 {
t.Fatalf("focused state = expression %s, animation %#v",
model.expression, model.animation)
}
}
func TestDiffletLoadingFootShuffleSequence(t *testing.T) {
model := newDifflet(true, false, true)
if command := model.setState(diffletLoading); command == nil {
t.Fatal("loading animation did not schedule a tick")
}
want := []string{" ▝ ▘ ", " ▝ ▘ ", " ▝ ▘ ", " ▝ ▘ "}
for index, feet := range want {
if got := model.frameLines()[3]; got != feet {
t.Fatalf("loading frame %d feet = %q, want %q", index, got, feet)
}
if index < len(want)-1 {
if command := model.update(diffletTickMsg{generation: model.generation}); command == nil {
t.Fatalf("loading frame %d stopped looping", index)
}
}
}
}
func TestDiffletOneShotStopsAndSettles(t *testing.T) {
model := newDifflet(true, true, true)
model.setState(diffletSuccess)
for index := 0; index < 3; index++ {
if command := model.update(diffletTickMsg{generation: model.generation}); command == nil {
t.Fatalf("success animation stopped at frame %d", index)
}
}
if command := model.update(diffletTickMsg{generation: model.generation}); command != nil {
t.Fatal("completed success animation scheduled another tick")
}
if len(model.animation.frames) != 0 || model.expression != DiffletHappy {
t.Fatalf("success settled with animation=%#v expression=%s",
model.animation, model.expression)
}
}
func TestDiffletLoopStopsOnStateChangeAndRejectsStaleTick(t *testing.T) {
model := newDifflet(true, true, true)
model.setState(diffletLoading)
oldGeneration := model.generation
if command := model.update(diffletTickMsg{generation: oldGeneration}); command == nil {
t.Fatal("loading animation did not continue")
}
model.setState(diffletConcerned)
if command := model.update(diffletTickMsg{generation: oldGeneration}); command != nil {
t.Fatal("stale tick scheduled another tick")
}
if model.expression != DiffletConcerned || model.frame != 0 {
t.Fatalf("stale tick changed newer state: expression=%s frame=%d",
model.expression, model.frame)
}
}

View File

@@ -191,6 +191,9 @@ func main() {
KeyBindings: config.KeyBindings, KeyBindings: config.KeyBindings,
AI: aiController, AI: aiController,
AIStore: aiStore, AIStore: aiStore,
Mascot: config.Mascot,
MascotExpressive: config.MascotExpressive,
MascotAnimated: config.MascotAnimated,
}, },
) )
cursorOutput := newTerminalCursorOutput(os.Stdout) cursorOutput := newTerminalCursorOutput(os.Stdout)

View File

@@ -292,7 +292,7 @@ func (m App) positionPREditHardwareCursor(scroll, viewportHeight int) {
_, column := editorCursorVisualPosition(editor, m.prEditEditorWidth()) _, column := editorCursorVisualPosition(editor, m.prEditEditorWidth())
// Rows and columns are one-based. Each editor row has a two-cell "│ " // Rows and columns are one-based. Each editor row has a two-cell "│ "
// context rail before its text. // context rail before its text.
m.cursorOutput.SetCursor(true, column+3, screenRow+1) m.cursorOutput.SetCursor(true, column+3, m.contentTop+screenRow+1)
} }
func (m App) submitPREdit() tea.Cmd { func (m App) submitPREdit() tea.Cmd {

294
tui.go
View File

@@ -137,6 +137,8 @@ type App struct {
listHidden bool listHidden bool
scroll int scroll int
width, height int width, height int
contentTop int
headerWidth int
loading bool loading bool
secondaryLoading bool secondaryLoading bool
err error err error
@@ -201,6 +203,7 @@ type App struct {
aiProgress AIRunProgress aiProgress AIRunProgress
aiSpinner int aiSpinner int
aiEvents <-chan tea.Msg aiEvents <-chan tea.Msg
difflet diffletModel
} }
type AppSettings struct { type AppSettings struct {
@@ -219,6 +222,9 @@ type AppSettings struct {
Drafts *draftStore Drafts *draftStore
AI *AIController AI *AIController
AIStore *AIStore AIStore *AIStore
Mascot bool
MascotExpressive bool
MascotAnimated bool
} }
func defaultAppSettings() AppSettings { func defaultAppSettings() AppSettings {
@@ -252,6 +258,8 @@ func NewAppWithSettings(
if state == nil { if state == nil {
state = &readStateStore{Data: make(map[string]readPRState)} state = &readStateStore{Data: make(map[string]readPRState)}
} }
difflet := newDifflet(settings.Mascot, settings.MascotExpressive, settings.MascotAnimated)
difflet.visible = false
return App{ return App{
service: service, owner: owner, repo: repo, showAll: showAll, limit: limit, poll: poll, service: service, owner: owner, repo: repo, showAll: showAll, limit: limit, poll: poll,
folded: make(map[string]bool), loading: true, folded: make(map[string]bool), loading: true,
@@ -273,6 +281,7 @@ func NewAppWithSettings(
unreadComments: make(map[string]bool), newThreads: make(map[string]bool), unreadComments: make(map[string]bool), newThreads: make(map[string]bool),
updatedThreads: make(map[string]bool), updatedThreads: make(map[string]bool),
ai: settings.AI, aiStore: settings.AIStore, ai: settings.AI, aiStore: settings.AIStore,
difflet: difflet,
} }
} }
@@ -281,6 +290,9 @@ func (m App) Init() tea.Cmd {
if m.pathScroll { if m.pathScroll {
commands = append(commands, m.nextPathTick()) commands = append(commands, m.nextPathTick())
} }
if command := m.difflet.start(); command != nil {
commands = append(commands, command)
}
return tea.Batch(commands...) return tea.Batch(commands...)
} }
@@ -599,7 +611,7 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch k { switch k {
case "y": case "y":
m.writeMode = writeReplyBusy m.writeMode = writeReplyBusy
return m, m.submitReply() return m, tea.Batch(m.submitReply(), m.difflet.setState(diffletLoading))
case "n", "esc": case "n", "esc":
m.writeMode = writeReply m.writeMode = writeReply
m.scroll = m.detailMaxScroll() m.scroll = m.detailMaxScroll()
@@ -608,7 +620,7 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch k { switch k {
case "y": case "y":
m.writeMode = writeResolveBusy m.writeMode = writeResolveBusy
return m, m.submitResolution() return m, tea.Batch(m.submitResolution(), m.difflet.setState(diffletLoading))
case "n", "esc": case "n", "esc":
m.writeMode, m.writeThreadID = writeNone, "" m.writeMode, m.writeThreadID = writeNone, ""
} }
@@ -616,7 +628,7 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch k { switch k {
case "y": case "y":
m.writeMode = writeAutoMergeBusy m.writeMode = writeAutoMergeBusy
return m, m.submitAutoMerge() return m, tea.Batch(m.submitAutoMerge(), m.difflet.setState(diffletLoading))
case "n", "esc": case "n", "esc":
m.writeMode = writeNone m.writeMode = writeNone
} }
@@ -624,7 +636,7 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch k { switch k {
case "y": case "y":
m.writeMode = writeMergeNowBusy m.writeMode = writeMergeNowBusy
return m, m.submitMergeNow() return m, tea.Batch(m.submitMergeNow(), m.difflet.setState(diffletLoading))
case "n", "esc": case "n", "esc":
m.writeMode = writeNone m.writeMode = writeNone
} }
@@ -688,6 +700,9 @@ func (m App) submitMergeNow() tea.Cmd {
} }
func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if tick, ok := msg.(diffletTickMsg); ok {
return m, m.difflet.update(tick)
}
if m.aiMode != aiNone { if m.aiMode != aiNone {
if updated, command, handled := m.updateAI(msg); handled { if updated, command, handled := m.updateAI(msg); handled {
return updated, command return updated, command
@@ -696,17 +711,22 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height m.width, m.height = msg.Width, msg.Height
return m, m.difflet.setVisible(
m.width >= diffletWidth &&
m.height >= diffletHeight+4,
)
case tickMsg: case tickMsg:
if !m.loading { if !m.loading {
m.loading = true m.loading = true
diffletCommand := m.difflet.setState(diffletLoading)
targetScreen := m.screen targetScreen := m.screen
if targetScreen == healthScreen { if targetScreen == healthScreen {
targetScreen = m.healthReturn targetScreen = m.healthReturn
} }
if (targetScreen == dashboardScreen || targetScreen == threadScreen) && m.details.Number != 0 { if (targetScreen == dashboardScreen || targetScreen == threadScreen) && m.details.Number != 0 {
return m, tea.Batch(m.loadDetails(m.details.PullRequest, false), m.nextTick()) return m, tea.Batch(m.loadDetails(m.details.PullRequest, false), m.nextTick(), diffletCommand)
} }
return m, tea.Batch(m.loadPRs(false), m.nextTick()) return m, tea.Batch(m.loadPRs(false), m.nextTick(), diffletCommand)
} }
return m, m.nextTick() return m, m.nextTick()
case pathTickMsg: case pathTickMsg:
@@ -734,8 +754,9 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
m.err = msg.err m.err = msg.err
command := m.difflet.setState(diffletSad)
m.recordHealth("pull request list", healthError, msg.err.Error()) m.recordHealth("pull request list", healthError, msg.err.Error())
return m, nil return m, command
} }
selected := "" selected := ""
if len(m.prs) > 0 && m.prIndex < len(m.prs) { if len(m.prs) > 0 && m.prIndex < len(m.prs) {
@@ -756,6 +777,10 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else { } else {
m.lastRefresh = time.Now() m.lastRefresh = time.Now()
} }
if len(m.prs) == 0 {
return m, m.difflet.setState(diffletSleeping)
}
return m, m.difflet.setState(diffletIdle)
case detailsLoadedMsg: case detailsLoadedMsg:
if m.requests != nil && !m.requests.current(msg.requestID) { if m.requests != nil && !m.requests.current(msg.requestID) {
return m, nil return m, nil
@@ -781,8 +806,9 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
m.err = msg.err m.err = msg.err
command := m.difflet.setState(diffletSad)
m.recordHealth("PR refresh", healthError, msg.err.Error()) m.recordHealth("PR refresh", healthError, msg.err.Error())
return m, nil return m, command
} }
selected := "" selected := ""
anchor := "" anchor := ""
@@ -801,6 +827,11 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.trackThreadUpdates(msg.details) m.trackThreadUpdates(msg.details)
sortReviewThreads(msg.details.Threads, m.threadStatusOrder, m.threadWithinStatus) sortReviewThreads(msg.details.Threads, m.threadStatusOrder, m.threadWithinStatus)
m.details = msg.details m.details = msg.details
m.err = nil
diffletCommand := m.difflet.setState(m.restingDiffletState())
if len(m.updatedThreads) > 0 {
diffletCommand = m.difflet.setState(diffletNewComment)
}
for _, issue := range msg.details.DataIssues { for _, issue := range msg.details.DataIssues {
m.recordHealth(issue.Component, healthWarning, issue.Message) m.recordHealth(issue.Component, healthWarning, issue.Message)
} }
@@ -832,9 +863,11 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.lastRefresh = time.Now() m.lastRefresh = time.Now()
if _, ok := m.service.(GitHubEnrichmentService); ok { if _, ok := m.service.(GitHubEnrichmentService); ok {
m.secondaryLoading = true m.secondaryLoading = true
return m, m.loadDetailsEnrichment(msg.details) diffletCommand = m.difflet.setState(diffletLoading)
return m, tea.Batch(m.loadDetailsEnrichment(msg.details), diffletCommand)
} }
} }
return m, diffletCommand
case detailsEnrichedMsg: case detailsEnrichedMsg:
if m.requests != nil && !m.requests.current(msg.requestID) { if m.requests != nil && !m.requests.current(msg.requestID) {
return m, nil return m, nil
@@ -866,6 +899,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
m.recordHealth(issue.Component, healthWarning, issue.Message) m.recordHealth(issue.Component, healthWarning, issue.Message)
} }
return m, m.difflet.setState(m.restingDiffletState())
case draftFlushMsg: case draftFlushMsg:
if msg.err != nil { if msg.err != nil {
m.recordHealth("draft persistence", healthWarning, msg.err.Error()) m.recordHealth("draft persistence", healthWarning, msg.err.Error())
@@ -907,7 +941,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.err != nil { if msg.err != nil {
m.err = fmt.Errorf("change thread resolution: %w", msg.err) m.err = fmt.Errorf("change thread resolution: %w", msg.err)
m.recordHealth("thread resolution", healthError, msg.err.Error()) m.recordHealth("thread resolution", healthError, msg.err.Error())
return m, nil return m, m.difflet.setState(diffletRecoverableError)
} }
selected := "" selected := ""
if m.threadIndex >= 0 && m.threadIndex < len(m.details.Threads) { if m.threadIndex >= 0 && m.threadIndex < len(m.details.Threads) {
@@ -931,13 +965,14 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.writeThreadID = "" m.writeThreadID = ""
m.err = nil m.err = nil
m.lastRefresh = time.Now() m.lastRefresh = time.Now()
return m, m.difflet.setState(diffletSuccess)
case threadRepliedMsg: case threadRepliedMsg:
if msg.err != nil { if msg.err != nil {
m.writeMode = writeReply m.writeMode = writeReply
m.err = fmt.Errorf("reply to review thread: %w", msg.err) m.err = fmt.Errorf("reply to review thread: %w", msg.err)
m.recordHealth("thread reply", healthError, msg.err.Error()) m.recordHealth("thread reply", healthError, msg.err.Error())
m.scroll = m.detailMaxScroll() m.scroll = m.detailMaxScroll()
return m, nil return m, m.difflet.setState(diffletRecoverableError)
} }
m.writeMode = writeNone m.writeMode = writeNone
for index := range m.details.Threads { for index := range m.details.Threads {
@@ -957,12 +992,13 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.replyDraft, m.writeThreadID = "", "" m.replyDraft, m.writeThreadID = "", ""
m.err = nil m.err = nil
m.lastRefresh = time.Now() m.lastRefresh = time.Now()
return m, m.difflet.setState(diffletSuccess)
case autoMergeUpdatedMsg: case autoMergeUpdatedMsg:
m.writeMode = writeNone m.writeMode = writeNone
if msg.err != nil { if msg.err != nil {
m.err = fmt.Errorf("change auto-merge: %w", msg.err) m.err = fmt.Errorf("change auto-merge: %w", msg.err)
m.recordHealth("auto-merge", healthError, msg.err.Error()) m.recordHealth("auto-merge", healthError, msg.err.Error())
return m, nil return m, m.difflet.setState(diffletRecoverableError)
} }
m.err = nil m.err = nil
if msg.enabled { if msg.enabled {
@@ -975,12 +1011,13 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.details.Permissions.CanDisableMerge = false m.details.Permissions.CanDisableMerge = false
} }
m.lastRefresh = time.Now() m.lastRefresh = time.Now()
return m, m.difflet.setState(diffletSuccess)
case pullRequestMergedMsg: case pullRequestMergedMsg:
m.writeMode = writeNone m.writeMode = writeNone
if msg.err != nil { if msg.err != nil {
m.err = fmt.Errorf("merge pull request: %w", msg.err) m.err = fmt.Errorf("merge pull request: %w", msg.err)
m.recordHealth("merge pull request", healthError, msg.err.Error()) m.recordHealth("merge pull request", healthError, msg.err.Error())
return m, nil return m, m.difflet.setState(diffletRecoverableError)
} }
m.err = nil m.err = nil
m.details.Merged = msg.result.Merged m.details.Merged = msg.result.Merged
@@ -988,6 +1025,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.details.State = "MERGED" m.details.State = "MERGED"
m.details.AutoMerge = nil m.details.AutoMerge = nil
m.lastRefresh = time.Now() m.lastRefresh = time.Now()
return m, m.difflet.setState(diffletSuccess)
case pullRequestUpdatedMsg: case pullRequestUpdatedMsg:
if msg.err != nil { if msg.err != nil {
m.writeMode = writePREdit m.writeMode = writePREdit
@@ -999,7 +1037,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = fmt.Errorf("update pull request: %w", msg.err) m.err = fmt.Errorf("update pull request: %w", msg.err)
m.recordHealth("PR metadata update", healthError, msg.err.Error()) m.recordHealth("PR metadata update", healthError, msg.err.Error())
m.scroll = 0 m.scroll = 0
return m, nil return m, m.difflet.setState(diffletRecoverableError)
} }
m.writeMode = writeNone m.writeMode = writeNone
m.details.Title = msg.metadata.Title m.details.Title = msg.metadata.Title
@@ -1031,7 +1069,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = nil m.err = nil
m.lastRefresh = time.Now() m.lastRefresh = time.Now()
m.loading = true m.loading = true
return m, m.loadDetails(m.details.PullRequest, false) return m, tea.Batch(m.loadDetails(m.details.PullRequest, false), m.difflet.setState(diffletLoading))
} }
key, ok := msg.(tea.KeyMsg) key, ok := msg.(tea.KeyMsg)
@@ -1172,14 +1210,15 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
m.loading = true m.loading = true
diffletCommand := m.difflet.setState(diffletLoading)
targetScreen := m.screen targetScreen := m.screen
if targetScreen == healthScreen { if targetScreen == healthScreen {
targetScreen = m.healthReturn targetScreen = m.healthReturn
} }
if targetScreen == dashboardScreen || targetScreen == threadScreen { if targetScreen == dashboardScreen || targetScreen == threadScreen {
return m, m.loadDetails(m.details.PullRequest, false) return m, tea.Batch(m.loadDetails(m.details.PullRequest, false), diffletCommand)
} }
return m, m.loadPRs(false) return m, tea.Batch(m.loadPRs(false), diffletCommand)
case "j", "down": case "j", "down":
if m.screen == dashboardScreen { if m.screen == dashboardScreen {
m.scrollDashboard(1) m.scrollDashboard(1)
@@ -1282,14 +1321,14 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
m.screen, m.err, m.loading = prScreen, nil, true m.screen, m.err, m.loading = prScreen, nil, true
return m, m.loadPRs(false) return m, tea.Batch(m.loadPRs(false), m.difflet.setState(diffletLoading))
} }
if m.screen == dashboardScreen { if m.screen == dashboardScreen {
m.screen, m.scroll, m.err = m.dashboardReturn, 0, nil m.screen, m.scroll, m.err = m.dashboardReturn, 0, nil
if m.screen == prScreen { if m.screen == prScreen {
m.loading = true m.loading = true
m.searching, m.searchQuery = false, "" m.searching, m.searchQuery = false, ""
return m, m.loadPRs(false) return m, tea.Batch(m.loadPRs(false), m.difflet.setState(diffletLoading))
} }
return m, nil return m, nil
} }
@@ -1382,7 +1421,7 @@ func (m *App) openSelectedPR(target screen) tea.Cmd {
m.threadIndex, m.scroll, m.focus, m.listHidden, m.loading, m.err = 0, 0, threadListPane, false, true, nil m.threadIndex, m.scroll, m.focus, m.listHidden, m.loading, m.err = 0, 0, threadListPane, false, true, nil
m.searching, m.searchQuery = false, "" m.searching, m.searchQuery = false, ""
m.pathScrollStep = 0 m.pathScrollStep = 0
return m.loadDetails(m.details.PullRequest, true) return tea.Batch(m.loadDetails(m.details.PullRequest, true), m.difflet.setState(diffletLoading))
} }
func (m *App) trackThreadUpdates(details PRDetails) { func (m *App) trackThreadUpdates(details PRDetails) {
@@ -1849,11 +1888,8 @@ func (m App) detailPaneSize() (int, int) {
} }
func (m App) threadTopLineCount() int { func (m App) threadTopLineCount() int {
topLines := 3 topLines := len(m.threadTopLines())
if m.details.FromCache { if m.headerWidth > 0 {
topLines++
}
if m.details.ThreadsTruncated {
topLines++ topLines++
} }
return topLines return topLines
@@ -1884,6 +1920,117 @@ func (m App) View() string {
if m.width == 0 { if m.width == 0 {
return "Loading…" return "Loading…"
} }
mascot := m.difflet.frameLines()
if len(mascot) != diffletHeight {
return m.viewContent()
}
gap := diffletGap
headerWidth := diffletHeaderWidth(m.width)
if headerWidth < 1 {
headerWidth = m.width
}
content := m
content.headerWidth = headerWidth
headerLineCount, hasHeader := content.diffletHeaderLineCount()
if !hasHeader {
content.height = max(1, m.height-diffletHeight-1)
content.contentTop = diffletHeight + 1
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(
strings.Join(mascot, "\n") + "\n\n" + content.viewContent(),
)
}
bandHeight := max(diffletHeight, headerLineCount)
addedRows := bandHeight - headerLineCount
if m.width <= diffletWidth {
addedRows = diffletHeight
}
content.height = max(1, m.height-addedRows)
content.contentTop = addedRows
rendered := content.viewContent()
header, body, ok := splitHeader(rendered)
if !ok {
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(
strings.Join(mascot, "\n") + "\n\n" + rendered,
)
}
headerBand := renderHeaderWithDifflet(header, mascot, m.width, headerWidth, gap)
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(
headerBand + "\n\n" + body,
)
}
func (m App) diffletHeaderLineCount() (int, bool) {
if m.helpVisible ||
(m.aiMode != aiNone && m.aiMode != aiDiscussion) ||
(m.writeMode != writeNone && m.writeMode != writeReply && m.writeMode != writePREdit) ||
m.screen == healthScreen {
return 0, false
}
switch m.screen {
case prScreen:
return len(m.prHeaderLines()), true
case dashboardScreen:
if m.scroll > 0 {
return 0, false
}
if m.writeMode == writePREdit {
return 1, true
}
return len(m.dashboardHeaderLines()), true
case threadScreen:
return len(m.threadTopLines()), true
default:
return 0, false
}
}
func splitHeader(view string) (header []string, body string, ok bool) {
lines := strings.Split(view, "\n")
for index, line := range lines {
if strings.TrimSpace(ansi.Strip(line)) == "" {
if index == 0 {
return nil, view, false
}
return lines[:index], strings.Join(lines[index+1:], "\n"), true
}
}
return nil, view, false
}
func renderHeaderWithDifflet(
header, mascot []string,
width, headerWidth, gap int,
) string {
if width <= diffletWidth {
lines := append([]string(nil), mascot...)
lines = append(lines, header...)
return strings.Join(lines, "\n")
}
height := max(len(header), len(mascot))
headerText := strings.Join(header, "\n")
left := lipgloss.NewStyle().Width(headerWidth).Height(height).Render(headerText)
right := lipgloss.NewStyle().Width(diffletWidth).Height(height).Render(strings.Join(mascot, "\n"))
return lipgloss.JoinHorizontal(
lipgloss.Top,
left,
strings.Repeat(" ", gap),
right,
)
}
func (m App) wrapHeaderLines(lines []string) []string {
if m.headerWidth < 1 {
return lines
}
var wrapped []string
for _, line := range lines {
value := ansi.Hardwrap(ansi.Wordwrap(line, m.headerWidth, ""), m.headerWidth, false)
wrapped = append(wrapped, strings.Split(value, "\n")...)
}
return wrapped
}
func (m App) viewContent() string {
if m.helpVisible { if m.helpVisible {
return m.viewHelp() return m.viewHelp()
} }
@@ -2326,16 +2473,7 @@ func paneStyle(active bool) lipgloss.Style {
} }
func (m App) viewPRs() string { func (m App) viewPRs() string {
header := titleStyle.Render("diple") lines := append(m.prHeaderLines(), "")
if m.owner != "" {
header += " " + m.owner + "/" + m.repo
}
if m.showAll {
header += dimStyle.Render(" all open pull requests")
} else {
header += dimStyle.Render(" assigned to you")
}
lines := []string{header, ""}
if m.loading && len(m.prs) == 0 { if m.loading && len(m.prs) == 0 {
lines = append(lines, "Loading open pull requests…") lines = append(lines, "Loading open pull requests…")
} else if len(m.prs) == 0 && m.err == nil { } else if len(m.prs) == 0 && m.err == nil {
@@ -2384,6 +2522,19 @@ func (m App) viewPRs() string {
return m.frame(lines, footer) return m.frame(lines, footer)
} }
func (m App) prHeaderLines() []string {
header := titleStyle.Render("diple")
if m.owner != "" {
header += " " + m.owner + "/" + m.repo
}
if m.showAll {
header += dimStyle.Render(" all open pull requests")
} else {
header += dimStyle.Render(" assigned to you")
}
return m.wrapHeaderLines([]string{header})
}
type prListRow struct { type prListRow struct {
repository string repository string
prIndex int prIndex int
@@ -2669,10 +2820,7 @@ func (m App) healthLines() []string {
return lines return lines
} }
func (m App) dashboardLines() []string { func (m App) dashboardHeaderLines() []string {
if m.writeMode == writePREdit {
return m.dashboardEditLines()
}
pr := m.details pr := m.details
width := max(10, m.width-2) width := max(10, m.width-2)
draft := "" draft := ""
@@ -2684,7 +2832,9 @@ func (m App) dashboardLines() []string {
titleStyle.Render(truncate(pr.Title, width)), titleStyle.Render(truncate(pr.Title, width)),
} }
if pr.FromCache { if pr.FromCache {
lines = append(lines, warnStyle.Render("OFFLINE CACHE • saved "+pr.CachedAt.Local().Format("2006-01-02 15:04"))) lines = append(lines, warnStyle.Render(
"OFFLINE CACHE • saved "+pr.CachedAt.Local().Format("2006-01-02 15:04"),
))
} }
if len(pr.DataIssues) > 0 { if len(pr.DataIssues) > 0 {
lines = append(lines, warnStyle.Render(fmt.Sprintf( lines = append(lines, warnStyle.Render(fmt.Sprintf(
@@ -2692,6 +2842,16 @@ func (m App) dashboardLines() []string {
len(pr.DataIssues), primaryKeyLabel(m.keybindings.Views.Health), len(pr.DataIssues), primaryKeyLabel(m.keybindings.Views.Health),
))) )))
} }
return m.wrapHeaderLines(lines)
}
func (m App) dashboardLines() []string {
if m.writeMode == writePREdit {
return m.dashboardEditLines()
}
pr := m.details
width := max(10, m.width-2)
lines := m.dashboardHeaderLines()
if m.loading && pr.BaseRef == "" { if m.loading && pr.BaseRef == "" {
return append(lines, "", "Loading pull request details…") return append(lines, "", "Loading pull request details…")
} }
@@ -3100,9 +3260,13 @@ func viewerPermissionsText(permissions ViewerPermissions) string {
return strings.Join(items, ", ") return strings.Join(items, ", ")
} }
func (m App) viewThreads() string { func (m App) threadTopLines() []string {
pr := m.details pr := m.details
header := titleStyle.Render(fmt.Sprintf("%s #%d %s", pr.RepoWithOwner, pr.Number, truncate(pr.Title, max(10, m.width-len(pr.RepoWithOwner)-12)))) width := m.width
if m.headerWidth > 0 {
width = m.headerWidth
}
header := titleStyle.Render(fmt.Sprintf("%s #%d %s", pr.RepoWithOwner, pr.Number, truncate(pr.Title, max(10, width-len(pr.RepoWithOwner)-12))))
meta := fmt.Sprintf("%s → %s checks: %s %s", pr.HeadRef, pr.BaseRef, coloredState(pr.CheckState), reviewAndMergeState(pr)) meta := fmt.Sprintf("%s → %s checks: %s %s", pr.HeadRef, pr.BaseRef, coloredState(pr.CheckState), reviewAndMergeState(pr))
people := "assignees: " + m.handlesText(pr.Assignees) + " reviewers: " + m.reviewersText(pr.Reviewers) people := "assignees: " + m.handlesText(pr.Assignees) + " reviewers: " + m.reviewersText(pr.Reviewers)
top := []string{header, meta, people} top := []string{header, meta, people}
@@ -3115,8 +3279,16 @@ func (m App) viewThreads() string {
if pr.ThreadsTruncated { if pr.ThreadsTruncated {
top = append(top, warnStyle.Render("Showing the first 100 review threads.")) top = append(top, warnStyle.Render("Showing the first 100 review threads."))
} }
return m.wrapHeaderLines(top)
}
contentHeight := max(3, m.height-len(top)-1) func (m App) viewThreads() string {
top := m.threadTopLines()
topLineCount := len(top)
if m.headerWidth > 0 {
topLineCount++
}
contentHeight := max(3, m.height-topLineCount-1)
var body string var body string
if m.width < 70 { if m.width < 70 {
if m.focus == threadListPane { if m.focus == threadListPane {
@@ -3167,6 +3339,9 @@ func (m App) viewThreads() string {
) )
} }
m.positionThreadInputHardwareCursor() m.positionThreadInputHardwareCursor()
if m.headerWidth > 0 {
top = append(top, "")
}
view := m.frame(append(top, body), help) view := m.frame(append(top, body), help)
if m.cursorOutput != nil { if m.cursorOutput != nil {
view += m.cursorOutput.FrameMarker() view += m.cursorOutput.FrameMarker()
@@ -3571,7 +3746,7 @@ func (m App) positionThreadInputHardwareCursor() {
m.cursorOutput.SetCursor( m.cursorOutput.SetCursor(
true, true,
2+ansi.StringWidth("Filter: ")+ansi.StringWidth(query), 2+ansi.StringWidth("Filter: ")+ansi.StringWidth(query),
topLines+3, m.contentTop+topLines+3,
) )
return return
} }
@@ -3607,7 +3782,7 @@ func (m App) positionThreadInputHardwareCursor() {
m.cursorOutput.SetCursor( m.cursorOutput.SetCursor(
true, true,
paneStart+2+ansi.StringWidth(line.rail)+ansi.StringWidth(line.fixed+line.text), paneStart+2+ansi.StringWidth(line.rail)+ansi.StringWidth(line.fixed+line.text),
topLines+2+screenLine, m.contentTop+topLines+2+screenLine,
) )
} }
@@ -3902,6 +4077,35 @@ func (m App) frame(lines []string, help string) string {
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(strings.Join(bodyLines, "\n")) return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(strings.Join(bodyLines, "\n"))
} }
func (m App) restingDiffletState() diffletState {
if m.err != nil {
return diffletSad
}
if m.loading || m.secondaryLoading {
return diffletLoading
}
if m.details.Number == 0 {
if !m.lastRefresh.IsZero() && len(m.prs) == 0 {
return diffletSleeping
}
return diffletIdle
}
if m.details.ReviewDecision == "APPROVED" {
return diffletApproved
}
open, outdated, resolved := threadStatusCounts(m.details.Threads)
if open+outdated > 0 {
return diffletConcerned
}
if resolved > 0 {
return diffletHappy
}
if m.screen == threadScreen {
return diffletFocused
}
return diffletIdle
}
func renderPane(lines []string, width, height int, active bool) string { func renderPane(lines []string, width, height int, active bool) string {
innerWidth := max(1, width-2) innerWidth := max(1, width-2)
innerHeight := max(1, height-2) innerHeight := max(1, height-2)