add mascot
This commit is contained in:
15
README.md
15
README.md
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
328
difflet.go
Normal 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
370
difflet_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
3
main.go
3
main.go
@@ -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)
|
||||||
|
|||||||
@@ -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
294
tui.go
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user