Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 36 additions & 20 deletions pkg/leantui/leantui.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

tea "charm.land/bubbletea/v2"
"github.com/mattn/go-isatty"
"github.com/muesli/cancelreader"

"github.com/docker/docker-agent/pkg/app"
"github.com/docker/docker-agent/pkg/gitbranch"
Expand Down Expand Up @@ -88,9 +89,7 @@ func Run(ctx context.Context, cfg Config) error {

keys := make(chan ui.Key, 64)
events := make(chan any, 256)
resizes := make(chan [2]int, 4)
done := make(chan struct{})
defer close(done)

fileCompletion := completions.NewFileCompletionAt(loopCtx, cfg.WorkingDir)
if loader, ok := fileCompletion.(completions.AsyncLoader); ok {
Expand All @@ -103,7 +102,15 @@ func Run(ctx context.Context, cfg Config) error {
}()
}

go readKeys(term.Reader(), keys, done)
keysDone := make(chan struct{})
go func() {
defer close(keysDone)
readKeys(term.Reader(), keys, done)
}()
defer func() {
close(done)
<-keysDone
}()
subscriptionReady := make(chan struct{})
go func() {
m.app.SubscribeReliable(loopCtx, func(msg tea.Msg) {
Expand All @@ -117,19 +124,6 @@ func Run(ctx context.Context, cfg Config) error {
}()
<-subscriptionReady
m.app.Start(loopCtx)
go func() {
for {
w, h, ok := term.Resized()
if !ok {
return
}
select {
case resizes <- [2]int{w, h}:
case <-done:
return
}
}
}()

if cfg.FirstMessage != nil || cfg.FirstMessageAttachment != "" {
first := ""
Expand All @@ -148,6 +142,8 @@ func Run(ctx context.Context, cfg Config) error {

animationTicker := time.NewTicker(100 * time.Millisecond)
defer animationTicker.Stop()
resizeTicker := time.NewTicker(250 * time.Millisecond)
defer resizeTicker.Stop()
branchChanges := branchWatcher.Changes()

probeTimer := time.NewTimer(200 * time.Millisecond)
Expand Down Expand Up @@ -178,10 +174,8 @@ func Run(ctx context.Context, cfg Config) error {
m.handleEvent(loopCtx, ev)
m.publishProgramStatus()
m.render()
case sz := <-resizes:
m.width, m.height = sz[0], sz[1]
m.r.SetSize(sz[0], sz[1])
m.render()
case <-resizeTicker.C:
m.resize(term.Size())
case <-animationTicker.C:
if m.busy || m.screen.Transcript.ToolCount() > 0 {
m.spinnerFrame++
Expand Down Expand Up @@ -212,7 +206,9 @@ func readKeys(r io.Reader, keys chan<- ui.Key, done <-chan struct{}) {
err error
}
reads := make(chan readResult)
readerDone := make(chan struct{})
go func() {
defer close(readerDone)
for {
buf := make([]byte, 8192)
n, err := r.Read(buf)
Expand All @@ -226,6 +222,17 @@ func readKeys(r io.Reader, keys chan<- ui.Key, done <-chan struct{}) {
}
}
}()
defer func() {
select {
case <-readerDone:
return
default:
}
// Some terminals cannot interrupt Read; do not hang shutdown in that case.
if reader, ok := r.(cancelreader.CancelReader); ok && reader.Cancel() {
<-readerDone
}
}()
p := &ui.InputParser{}
timer := time.NewTimer(time.Hour)
timer.Stop()
Expand Down Expand Up @@ -363,6 +370,15 @@ func newModel(term *ui.Terminal, cfg Config) *model {
}
}

func (m *model) resize(width, height int) {
if width == m.width && height == m.height {
return
}
m.width, m.height = width, height
m.r.SetSize(width, height)
m.render()
}

// render assembles the full frame and reconciles it with the terminal.
func (m *model) render() {
lines, cursorLine, cursorCol := m.buildLines()
Expand Down
91 changes: 91 additions & 0 deletions pkg/leantui/readkeys_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package leantui

import (
"io"
"sync"
"testing"
"time"

"github.com/muesli/cancelreader"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/docker/docker-agent/pkg/leantui/ui"
)

type blockedKeyReader struct {
entered chan struct{}
release chan struct{}
finished chan struct{}
once sync.Once
}

func (r *blockedKeyReader) Read([]byte) (int, error) {
close(r.entered)
<-r.release
close(r.finished)
return 0, cancelreader.ErrCanceled
}

func (r *blockedKeyReader) Cancel() bool {
r.once.Do(func() { close(r.release) })
return true
}

func (*blockedKeyReader) Close() error { return nil }

func TestReadKeysJoinsCanceledReader(t *testing.T) {
t.Parallel()

reader := &blockedKeyReader{
entered: make(chan struct{}), release: make(chan struct{}), finished: make(chan struct{}),
}
done, returned := make(chan struct{}), make(chan struct{})
go func() {
defer close(returned)
readKeys(reader, make(chan ui.Key), done)
}()
<-reader.entered
close(done)
select {
case <-returned:
select {
case <-reader.finished:
default:
t.Fatal("readKeys returned before the canceled read completed")
}
case <-time.After(5 * time.Second):
t.Fatal("readKeys did not cancel and join the reader")
}
}

func TestReadKeysCancellationWithBlockedOutput(t *testing.T) {
t.Parallel()

reader, writer := io.Pipe()
t.Cleanup(func() { _ = reader.Close(); _ = writer.Close() })
done, returned := make(chan struct{}), make(chan struct{})
go func() {
defer close(returned)
readKeys(reader, make(chan ui.Key), done)
}()
_, err := writer.Write([]byte("x"))
require.NoError(t, err)
close(done)
select {
case <-returned:
case <-time.After(5 * time.Second):
t.Fatal("readKeys remained blocked sending a key after cancellation")
}
}

func TestReadKeysClosedReader(t *testing.T) {
t.Parallel()

reader, writer := io.Pipe()
require.NoError(t, writer.Close())
t.Cleanup(func() { _ = reader.Close() })
keys := make(chan ui.Key)
readKeys(reader, keys, make(chan struct{}))
assert.Empty(t, keys)
}
64 changes: 64 additions & 0 deletions pkg/leantui/resize_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package leantui

import (
"bufio"
"bytes"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/docker/docker-agent/pkg/leantui/ui"
)

func TestResize(t *testing.T) {
t.Parallel()

for _, tc := range []struct {
name string
width, height int
}{
{name: "width only", width: 40, height: 24},
{name: "height only", width: 80, height: 8},
{name: "grow", width: 120, height: 40},
{name: "shrink", width: 30, height: 6},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

m := bareModel(24)
var output bytes.Buffer
m.r = ui.NewRenderer(bufio.NewWriter(&output), m.width, m.height)
text := strings.Repeat("resize this paragraph without losing content ", 12)
m.screen.Transcript.AppendAssistant(text)
m.screen.Editor.SetText("keep this draft")
m.render()
output.Reset()

m.resize(m.width, m.height)
assert.Empty(t, output.String(), "unchanged polling must not render")
m.render()
assert.NotContains(t, output.String(), "\x1b[2J", "unchanged size must not invalidate the next frame")
output.Reset()

m.resize(tc.width, tc.height)
assert.Equal(t, tc.width, m.width)
assert.Equal(t, tc.height, m.height)
assert.Contains(t, output.String(), "\x1b[2J", "changed dimensions must repaint")
lines, _, _ := m.buildLines()
assert.Equal(t, max(0, len(lines)-tc.height), m.r.ViewportTop())
require.NotEmpty(t, lines)
for _, line := range lines {
assert.LessOrEqual(t, ui.DisplayWidth(line), tc.width)
}

m.resize(80, 24)
assert.Equal(t, "keep this draft", m.screen.Editor.Text())
assert.Contains(t, strings.Join(m.screen.Transcript.Lines(80, 0, false, m.sessionState, nil), "\n"), "resize this paragraph")
output.Reset()
m.resize(80, 24)
assert.Empty(t, output.String(), "repeated size must not render")
})
}
}
Loading
Loading