Compare commits
2 Commits
0d2af19c6d
...
21d44ea3a1
| Author | SHA1 | Date | |
|---|---|---|---|
| 21d44ea3a1 | |||
| 590a863e26 |
@@ -72,6 +72,52 @@ func TestDiffletStylesOnlyDiffSigns(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDiffletAnimationDoesNotChangeThreadCommentRows(t *testing.T) {
|
||||||
|
if err := applyTheme("dark"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = applyTheme("dark") })
|
||||||
|
|
||||||
|
app := NewAppWithSettings(
|
||||||
|
&recordingService{}, "owner", "repository", false, 10, 10,
|
||||||
|
AppSettings{Mascot: true, MascotAnimated: true},
|
||||||
|
)
|
||||||
|
app.screen = threadScreen
|
||||||
|
app.loading = false
|
||||||
|
app.details = PRDetails{
|
||||||
|
PullRequest: PullRequest{
|
||||||
|
Owner: "owner", Repository: "repository",
|
||||||
|
RepoWithOwner: "owner/repository", Number: 42,
|
||||||
|
Title: "Stable highlighted comments",
|
||||||
|
},
|
||||||
|
Threads: []ReviewThread{{
|
||||||
|
ID: "thread", Path: "main.go",
|
||||||
|
Comments: []ReviewComment{{
|
||||||
|
ID: "comment", Author: "reviewer",
|
||||||
|
Body: "```go\nfunc main() {\n\tprintln(\"stable\")\n}\n```",
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
updated, _ := app.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||||
|
app = updated.(App)
|
||||||
|
before := strings.Split(app.View(), "\n")
|
||||||
|
|
||||||
|
generation := app.difflet.generation
|
||||||
|
updated, _ = app.Update(diffletTickMsg{generation: generation})
|
||||||
|
app = updated.(App)
|
||||||
|
after := strings.Split(app.View(), "\n")
|
||||||
|
|
||||||
|
if len(before) != len(after) {
|
||||||
|
t.Fatalf("animation changed frame height from %d to %d", len(before), len(after))
|
||||||
|
}
|
||||||
|
for row := diffletHeight; row < len(before); row++ {
|
||||||
|
if before[row] != after[row] {
|
||||||
|
t.Fatalf("animation changed non-mascot row %d:\nbefore %q\nafter %q",
|
||||||
|
row, before[row], after[row])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDiffletEnabledSitsRightOfWrappingHeader(t *testing.T) {
|
func TestDiffletEnabledSitsRightOfWrappingHeader(t *testing.T) {
|
||||||
app := NewAppWithSettings(
|
app := NewAppWithSettings(
|
||||||
&recordingService{}, "owner", "repository", false, 10, 10,
|
&recordingService{}, "owner", "repository", false, 10, 10,
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ func commentMarkdownRenderer(width int) (*glamour.TermRenderer, error) {
|
|||||||
style.Code.Suffix = ""
|
style.Code.Suffix = ""
|
||||||
renderer, err := glamour.NewTermRenderer(
|
renderer, err := glamour.NewTermRenderer(
|
||||||
glamour.WithStyles(style),
|
glamour.WithStyles(style),
|
||||||
|
glamour.WithChromaFormatter("terminal16m"),
|
||||||
glamour.WithWordWrap(width),
|
glamour.WithWordWrap(width),
|
||||||
glamour.WithTableWrap(true),
|
glamour.WithTableWrap(true),
|
||||||
glamour.WithPreservedNewLines(),
|
glamour.WithPreservedNewLines(),
|
||||||
|
|||||||
@@ -49,6 +49,21 @@ func (o *terminalCursorOutput) Write(value []byte) (int, error) {
|
|||||||
o.mu.Lock()
|
o.mu.Lock()
|
||||||
defer o.mu.Unlock()
|
defer o.mu.Unlock()
|
||||||
|
|
||||||
|
// Bubble Tea v1 can expose intermediate rows from an animated partial
|
||||||
|
// repaint. This is especially visible when unchanged Markdown code blocks
|
||||||
|
// below the changed rows contain dense ANSI styling. Terminals that support
|
||||||
|
// synchronized output hold the completed frame until the reset sequence;
|
||||||
|
// terminals that do not support it safely ignore both sequences.
|
||||||
|
if !bytes.Equal(value, []byte(ansi.ShowCursor)) &&
|
||||||
|
!bytes.Equal(value, []byte(ansi.HideCursor)) {
|
||||||
|
if _, err := io.WriteString(o.file, ansi.SetSynchronizedOutputMode); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_, _ = io.WriteString(o.file, ansi.ResetSynchronizedOutputMode)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
written, err := o.file.Write(value)
|
written, err := o.file.Write(value)
|
||||||
if err != nil || written != len(value) {
|
if err != nil || written != len(value) {
|
||||||
return written, err
|
return written, err
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func TestTerminalCursorOutputPositionsHardwareBarAfterFrame(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
wantSuffix := ansi.SetCursorStyle(5) + ansi.CursorPosition(7, 4) + ansi.ShowCursor
|
wantSuffix := ansi.SetCursorStyle(5) + ansi.CursorPosition(7, 4) + ansi.ShowCursor
|
||||||
if !strings.HasSuffix(string(content), wantSuffix) {
|
if !strings.HasSuffix(string(content), wantSuffix+ansi.ResetSynchronizedOutputMode) {
|
||||||
t.Fatalf("cursor output = %q, want suffix %q", content, wantSuffix)
|
t.Fatalf("cursor output = %q, want suffix %q", content, wantSuffix)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,33 @@ func TestTerminalCursorOutputHidesCursorOutsideInsertMode(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(string(content), ansi.HideCursor) {
|
if !strings.HasSuffix(
|
||||||
|
string(content),
|
||||||
|
ansi.HideCursor+ansi.ResetSynchronizedOutputMode,
|
||||||
|
) {
|
||||||
t.Fatalf("cursor output did not hide cursor: %q", content)
|
t.Fatalf("cursor output did not hide cursor: %q", content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTerminalCursorOutputSynchronizesCompletedFrames(t *testing.T) {
|
||||||
|
file, err := os.CreateTemp(t.TempDir(), "cursor-output")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
output := newTerminalCursorOutput(file)
|
||||||
|
output.SetCursor(false, 0, 0)
|
||||||
|
if _, err := output.Write([]byte("animated frame")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(file.Name())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := ansi.SetSynchronizedOutputMode + "animated frame" +
|
||||||
|
ansi.HideCursor + ansi.ResetSynchronizedOutputMode
|
||||||
|
if string(content) != want {
|
||||||
|
t.Fatalf("synchronized frame output = %q, want %q", content, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
47
tui.go
47
tui.go
@@ -1906,6 +1906,12 @@ func (m App) dashboardMaxScroll() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m App) detailPaneSize() (int, int) {
|
func (m App) detailPaneSize() (int, int) {
|
||||||
|
if m.contentTop == 0 &&
|
||||||
|
len(m.difflet.frameLines()) == diffletHeight &&
|
||||||
|
!m.diffletHiddenForCurrentView() &&
|
||||||
|
m.screen != dashboardScreen {
|
||||||
|
m, _ = m.diffletContentModel()
|
||||||
|
}
|
||||||
topLines := m.threadTopLineCount()
|
topLines := m.threadTopLineCount()
|
||||||
height := max(3, m.height-topLines-1)
|
height := max(3, m.height-topLines-1)
|
||||||
if m.width < 70 || m.listHidden {
|
if m.width < 70 || m.listHidden {
|
||||||
@@ -1958,20 +1964,39 @@ func (m App) View() string {
|
|||||||
if m.screen == dashboardScreen {
|
if m.screen == dashboardScreen {
|
||||||
return m.viewDashboardWithLines(m.dashboardLinesWithDifflet(mascot))
|
return m.viewDashboardWithLines(m.dashboardLinesWithDifflet(mascot))
|
||||||
}
|
}
|
||||||
gap := diffletGap
|
content, hasHeader := m.diffletContentModel()
|
||||||
|
if !hasHeader {
|
||||||
|
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(
|
||||||
|
strings.Join(mascot, "\n") + "\n\n" + content.viewContent(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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, content.headerWidth, diffletGap,
|
||||||
|
)
|
||||||
|
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(
|
||||||
|
headerBand + "\n\n" + body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m App) diffletContentModel() (content App, hasHeader bool) {
|
||||||
headerWidth := diffletHeaderWidth(m.width)
|
headerWidth := diffletHeaderWidth(m.width)
|
||||||
if headerWidth < 1 {
|
if headerWidth < 1 {
|
||||||
headerWidth = m.width
|
headerWidth = m.width
|
||||||
}
|
}
|
||||||
content := m
|
content = m
|
||||||
content.headerWidth = headerWidth
|
content.headerWidth = headerWidth
|
||||||
headerLineCount, hasHeader := content.diffletHeaderLineCount()
|
headerLineCount, hasHeader := content.diffletHeaderLineCount()
|
||||||
if !hasHeader {
|
if !hasHeader {
|
||||||
content.height = max(1, m.height-diffletHeight-1)
|
content.height = max(1, m.height-diffletHeight-1)
|
||||||
content.contentTop = diffletHeight + 1
|
content.contentTop = diffletHeight + 1
|
||||||
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(
|
return content, hasHeader
|
||||||
strings.Join(mascot, "\n") + "\n\n" + content.viewContent(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
bandHeight := max(diffletHeight, headerLineCount)
|
bandHeight := max(diffletHeight, headerLineCount)
|
||||||
addedRows := bandHeight - headerLineCount
|
addedRows := bandHeight - headerLineCount
|
||||||
@@ -1980,17 +2005,7 @@ func (m App) View() string {
|
|||||||
}
|
}
|
||||||
content.height = max(1, m.height-addedRows)
|
content.height = max(1, m.height-addedRows)
|
||||||
content.contentTop = addedRows
|
content.contentTop = addedRows
|
||||||
rendered := content.viewContent()
|
return content, hasHeader
|
||||||
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) diffletHiddenForCurrentView() bool {
|
func (m App) diffletHiddenForCurrentView() bool {
|
||||||
|
|||||||
85
tui_test.go
85
tui_test.go
@@ -1189,35 +1189,82 @@ func TestReplyComposerRendersInlineWithCurrentThread(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLongThreadCommentRemainsReachableByScrolling(t *testing.T) {
|
func TestLongThreadCommentRemainsReachableByScrolling(t *testing.T) {
|
||||||
m := NewApp(nil, "o", "r", false, 50, time.Second)
|
for _, mascot := range []bool{false, true} {
|
||||||
m.screen, m.loading, m.width, m.height = threadScreen, false, 60, 12
|
t.Run(fmt.Sprintf("mascot=%t", mascot), func(t *testing.T) {
|
||||||
|
settings := defaultAppSettings()
|
||||||
|
settings.Mascot = mascot
|
||||||
|
m := NewAppWithSettings(
|
||||||
|
nil, "o", "r", false, 50, time.Second,
|
||||||
|
settings,
|
||||||
|
)
|
||||||
|
m.screen, m.loading = threadScreen, false
|
||||||
|
m.listHidden, m.focus = true, threadDetailPane
|
||||||
|
updated, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 12})
|
||||||
|
m = updated.(App)
|
||||||
|
body := strings.Repeat("abcdefghij", 40) + "FINALMARKER"
|
||||||
|
m.details = PRDetails{
|
||||||
|
PullRequest: PullRequest{RepoWithOwner: "o/r", Number: 1, Title: "Title"},
|
||||||
|
Threads: []ReviewThread{{
|
||||||
|
ID: "thread", Path: "main.go", Line: 12,
|
||||||
|
Comments: []ReviewComment{{
|
||||||
|
ID: "comment", Author: "reviewer", Body: body,
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
width, _ := m.detailPaneSize()
|
||||||
|
lines := m.renderedDetailLines(width)
|
||||||
|
for _, line := range lines {
|
||||||
|
available := max(1, width-2-ansi.StringWidth(line.rail))
|
||||||
|
if got := ansi.StringWidth(line.fixed + line.text); got > available {
|
||||||
|
t.Fatalf("detail line width=%d available=%d text=%q",
|
||||||
|
got, available, ansi.Strip(line.text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.detailMaxScroll() == 0 {
|
||||||
|
t.Fatal("long comment did not produce scrollable detail rows")
|
||||||
|
}
|
||||||
|
m.scroll = m.detailMaxScroll()
|
||||||
|
if view := ansi.Strip(m.View()); !strings.Contains(view, "FINALMARKER") {
|
||||||
|
t.Fatalf("final comment content is not reachable at maximum scroll:\n%s", view)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLongThreadReplyComposerIsVisibleWithDifflet(t *testing.T) {
|
||||||
|
settings := defaultAppSettings()
|
||||||
|
settings.Mascot = true
|
||||||
|
m := NewAppWithSettings(
|
||||||
|
&recordingService{}, "o", "r", false, 50, time.Second,
|
||||||
|
settings,
|
||||||
|
)
|
||||||
|
m.screen, m.loading = threadScreen, false
|
||||||
m.listHidden, m.focus = true, threadDetailPane
|
m.listHidden, m.focus = true, threadDetailPane
|
||||||
body := strings.Repeat("abcdefghij", 40) + "FINALMARKER"
|
updated, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 12})
|
||||||
|
m = updated.(App)
|
||||||
m.details = PRDetails{
|
m.details = PRDetails{
|
||||||
PullRequest: PullRequest{RepoWithOwner: "o/r", Number: 1, Title: "Title"},
|
PullRequest: PullRequest{
|
||||||
|
ID: "pr", Owner: "o", Repository: "r",
|
||||||
|
RepoWithOwner: "o/r", Number: 1, Title: "Title",
|
||||||
|
},
|
||||||
Threads: []ReviewThread{{
|
Threads: []ReviewThread{{
|
||||||
ID: "thread", Path: "main.go", Line: 12,
|
ID: "thread", Path: "main.go", Line: 12, ViewerCanReply: true,
|
||||||
Comments: []ReviewComment{{
|
Comments: []ReviewComment{{
|
||||||
ID: "comment", Author: "reviewer", Body: body,
|
ID: "comment", Author: "reviewer",
|
||||||
|
Body: strings.Repeat("A long review comment. ", 40),
|
||||||
}},
|
}},
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
width, _ := m.detailPaneSize()
|
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")})
|
||||||
lines := m.renderedDetailLines(width)
|
m = updated.(App)
|
||||||
for _, line := range lines {
|
view := ansi.Strip(m.View())
|
||||||
available := max(1, width-2-ansi.StringWidth(line.rail))
|
for _, wanted := range []string{"Reply draft", "ctrl+s review"} {
|
||||||
if got := ansi.StringWidth(line.fixed + line.text); got > available {
|
if !strings.Contains(view, wanted) {
|
||||||
t.Fatalf("detail line width=%d available=%d text=%q", got, available, ansi.Strip(line.text))
|
t.Fatalf("long-thread reply view is missing %q:\n%s", wanted, view)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if m.detailMaxScroll() == 0 {
|
|
||||||
t.Fatal("long comment did not produce scrollable detail rows")
|
|
||||||
}
|
|
||||||
m.scroll = m.detailMaxScroll()
|
|
||||||
if view := ansi.Strip(m.viewThreads()); !strings.Contains(view, "FINALMARKER") {
|
|
||||||
t.Fatalf("final comment content is not reachable at maximum scroll:\n%s", view)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveToggleConfirmsAndUsesCurrentThreadState(t *testing.T) {
|
func TestResolveToggleConfirmsAndUsesCurrentThreadState(t *testing.T) {
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
const dipleVersion = "0.1.1"
|
const dipleVersion = "0.1.3"
|
||||||
|
|||||||
Reference in New Issue
Block a user