Skip to content
Draft
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
136 changes: 136 additions & 0 deletions internal/ui/sidebar/manual_scroll_helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package sidebar

import (
"fmt"
"os"
"path/filepath"
"testing"

"github.com/andyrewlee/amux/internal/data"
"github.com/andyrewlee/amux/internal/git"
)

func setupChangesScrollModel() *Model {
m := New()
m.SetSize(80, 10)
m.Focus()

unstaged := make([]git.Change, 0, 20)
for i := 0; i < 20; i++ {
unstaged = append(unstaged, git.Change{
Path: fmt.Sprintf("file-%02d.txt", i),
Kind: git.ChangeModified,
})
}
m.SetGitStatus(&git.StatusResult{
Clean: false,
Unstaged: unstaged,
})
_ = m.View()
return m
}

func setupProjectTreeScrollModel(t *testing.T) *ProjectTree {
t.Helper()

root := t.TempDir()
for i := 0; i < 20; i++ {
path := filepath.Join(root, fmt.Sprintf("file-%02d.txt", i))
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
t.Fatalf("write %q: %v", path, err)
}
}

tree := NewProjectTree()
tree.SetSize(80, 10)
tree.Focus()
tree.SetWorkspace(data.NewWorkspace("feature", "feature", "main", root, root))
_ = tree.View()
return tree
}

func setupNestedProjectTreeScrollModel(t *testing.T) *ProjectTree {
t.Helper()

root := t.TempDir()
dir := filepath.Join(root, "dir")
if err := os.Mkdir(dir, 0o755); err != nil {
t.Fatalf("mkdir %q: %v", dir, err)
}
for i := 0; i < 20; i++ {
path := filepath.Join(dir, fmt.Sprintf("file-%02d.txt", i))
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
t.Fatalf("write %q: %v", path, err)
}
}

tree := NewProjectTree()
tree.SetSize(80, 10)
tree.Focus()
tree.SetWorkspace(data.NewWorkspace("feature", "feature", "main", root, root))

if len(tree.flatNodes) == 0 {
t.Fatal("expected project tree to have visible nodes")
}
parent := tree.flatNodes[0]
if !parent.IsDir {
t.Fatalf("expected first node to be a directory, got %+v", parent)
}

tree.expandNode(parent)
tree.rebuildFlatList()
_ = tree.View()
return tree
}

func setupTabbedChangesScrollModel() *TabbedSidebar {
sidebar := NewTabbedSidebar()
sidebar.SetSize(80, 10)
sidebar.Focus()
sidebar.SetActiveTab(TabChanges)

unstaged := make([]git.Change, 0, 20)
for i := 0; i < 20; i++ {
unstaged = append(unstaged, git.Change{
Path: fmt.Sprintf("file-%02d.txt", i),
Kind: git.ChangeModified,
})
}
sidebar.SetGitStatus(&git.StatusResult{
Clean: false,
Unstaged: unstaged,
})
_ = sidebar.View()
return sidebar
}

func setupTabbedProjectScrollModel(t *testing.T) *TabbedSidebar {
t.Helper()

sidebar := NewTabbedSidebar()
sidebar.SetSize(80, 10)
sidebar.Focus()
sidebar.SetActiveTab(TabProject)

root := t.TempDir()
for i := 0; i < 20; i++ {
path := filepath.Join(root, fmt.Sprintf("file-%02d.txt", i))
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
t.Fatalf("write %q: %v", path, err)
}
}

sidebar.SetWorkspace(data.NewWorkspace("feature", "feature", "main", root, root))
_ = sidebar.View()
return sidebar
}

func changesCursorVisible(m *Model) bool {
visibleHeight := m.visibleHeight()
return m.cursor >= m.scrollOffset && m.cursor < m.scrollOffset+visibleHeight
}

func treeCursorVisible(m *ProjectTree) bool {
visibleHeight := m.visibleHeight()
return m.cursor >= m.scrollOffset && m.cursor < m.scrollOffset+visibleHeight
}
223 changes: 223 additions & 0 deletions internal/ui/sidebar/manual_scroll_keyboard_anchor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
package sidebar

import (
"path/filepath"
"testing"

tea "charm.land/bubbletea/v2"

"github.com/andyrewlee/amux/internal/git"
"github.com/andyrewlee/amux/internal/messages"
)

func TestChangesKeyboardActionsReanchorAfterWheelScroll(t *testing.T) {
m := setupChangesScrollModel()
m.cursor = 1
m.scrollOffset = 10

cmd := m.openCurrentItem()
if cmd == nil {
t.Fatal("expected openCurrentItem command after wheel scroll")
}
msg := cmd()
diff, ok := msg.(messages.OpenDiff)
if !ok {
t.Fatalf("expected OpenDiff, got %T", msg)
}
if diff.Change == nil || diff.Change.Path != "file-09.txt" {
got := "<nil>"
if diff.Change != nil {
got = diff.Change.Path
}
t.Fatalf("opened change path = %q, want %q", got, "file-09.txt")
}

m.cursor = 1
m.scrollOffset = 10
_, _ = m.Update(tea.KeyPressMsg{Text: "j", Code: 'j'})
if m.cursor != 11 || m.scrollOffset != 10 {
t.Fatalf("after j cursor=%d scrollOffset=%d, want cursor=11 scrollOffset=10", m.cursor, m.scrollOffset)
}
if !changesCursorVisible(m) {
t.Fatalf("expected cursor to be visible after j, cursor=%d scrollOffset=%d visibleHeight=%d",
m.cursor, m.scrollOffset, m.visibleHeight())
}
}

func TestChangesKeyboardActionsReanchorInsideShortViewport(t *testing.T) {
m := New()
m.SetSize(80, 2)
m.SetGitStatus(&git.StatusResult{
Clean: false,
Staged: []git.Change{
{Path: "staged.txt", Kind: git.ChangeModified, Staged: true},
},
Unstaged: []git.Change{
{Path: "unstaged.txt", Kind: git.ChangeModified},
},
})
m.cursor = 1
m.scrollOffset = 2

if m.cursorVisible() {
t.Fatalf("expected cursor to start hidden, cursor=%d scrollOffset=%d visibleHeight=%d",
m.cursor, m.scrollOffset, m.visibleHeight())
}

cmd := m.openCurrentItem()
if cmd == nil {
t.Fatal("expected openCurrentItem command after short-viewport reanchor")
}
if !changesCursorVisible(m) {
t.Fatalf("expected cursor to be visible after reanchor, cursor=%d scrollOffset=%d visibleHeight=%d",
m.cursor, m.scrollOffset, m.visibleHeight())
}
msg := cmd()
diff, ok := msg.(messages.OpenDiff)
if !ok {
t.Fatalf("expected OpenDiff, got %T", msg)
}
if diff.Change == nil || diff.Change.Path != "unstaged.txt" {
got := "<nil>"
if diff.Change != nil {
got = diff.Change.Path
}
t.Fatalf("opened change path = %q, want %q", got, "unstaged.txt")
}
}

func TestChangesKeyboardActionsReanchorBelowHeaderOnlyViewport(t *testing.T) {
m := New()
m.SetSize(80, 2)
m.SetGitStatus(&git.StatusResult{
Clean: false,
Staged: []git.Change{
{Path: "staged.txt", Kind: git.ChangeModified, Staged: true},
},
Unstaged: []git.Change{
{Path: "unstaged.txt", Kind: git.ChangeModified},
},
})
m.cursor = 3
m.scrollOffset = 2

if m.cursorVisible() {
t.Fatalf("expected cursor to start hidden, cursor=%d scrollOffset=%d visibleHeight=%d",
m.cursor, m.scrollOffset, m.visibleHeight())
}

cmd := m.openCurrentItem()
if cmd == nil {
t.Fatal("expected openCurrentItem command after short-viewport downward reanchor")
}
if !changesCursorVisible(m) {
t.Fatalf("expected cursor to be visible after reanchor, cursor=%d scrollOffset=%d visibleHeight=%d",
m.cursor, m.scrollOffset, m.visibleHeight())
}
msg := cmd()
diff, ok := msg.(messages.OpenDiff)
if !ok {
t.Fatalf("expected OpenDiff, got %T", msg)
}
if diff.Change == nil || diff.Change.Path != "unstaged.txt" {
got := "<nil>"
if diff.Change != nil {
got = diff.Change.Path
}
t.Fatalf("opened change path = %q, want %q", got, "unstaged.txt")
}
}

func TestChangesMoveCursorDoesNotSkipFirstFileInHeaderOnlyViewport(t *testing.T) {
m := New()
m.SetSize(80, 2)
m.Focus()
m.SetGitStatus(&git.StatusResult{
Clean: false,
Staged: []git.Change{
{Path: "staged.txt", Kind: git.ChangeModified, Staged: true},
},
Unstaged: []git.Change{
{Path: "unstaged.txt", Kind: git.ChangeModified},
},
})
m.cursor = 3
m.scrollOffset = 0

if m.cursorVisible() {
t.Fatalf("expected cursor to start hidden, cursor=%d scrollOffset=%d visibleHeight=%d",
m.cursor, m.scrollOffset, m.visibleHeight())
}

_, _ = m.Update(tea.KeyPressMsg{Text: "j", Code: 'j'})

if m.cursor != 1 {
t.Fatalf("cursor = %d, want 1 after j", m.cursor)
}
if !changesCursorVisible(m) {
t.Fatalf("expected cursor to be visible after j, cursor=%d scrollOffset=%d visibleHeight=%d",
m.cursor, m.scrollOffset, m.visibleHeight())
}
}

func TestChangesPageDownDoesNotSkipFirstFileInHeaderOnlyViewport(t *testing.T) {
m := New()
m.SetSize(80, 2)
m.Focus()
m.SetGitStatus(&git.StatusResult{
Clean: false,
Staged: []git.Change{
{Path: "staged.txt", Kind: git.ChangeModified, Staged: true},
},
Unstaged: []git.Change{
{Path: "unstaged.txt", Kind: git.ChangeModified},
},
})
m.cursor = 3
m.scrollOffset = 0

if m.cursorVisible() {
t.Fatalf("expected cursor to start hidden, cursor=%d scrollOffset=%d visibleHeight=%d",
m.cursor, m.scrollOffset, m.visibleHeight())
}

_, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyPgDown})

if m.cursor != 1 {
t.Fatalf("cursor = %d, want 1 after PgDown", m.cursor)
}
if !changesCursorVisible(m) {
t.Fatalf("expected cursor to be visible after PgDown, cursor=%d scrollOffset=%d visibleHeight=%d",
m.cursor, m.scrollOffset, m.visibleHeight())
}
}

func TestProjectTreeKeyboardActionsReanchorAfterWheelScroll(t *testing.T) {
tree := setupProjectTreeScrollModel(t)
tree.cursor = 1
tree.scrollOffset = 10

cmd := tree.handleEnter()
if cmd == nil {
t.Fatal("expected handleEnter command after wheel scroll")
}
msg := cmd()
opened, ok := msg.(OpenFileInEditor)
if !ok {
t.Fatalf("expected OpenFileInEditor, got %T", msg)
}
if got := filepath.Base(opened.Path); got != "file-10.txt" {
t.Fatalf("opened path = %q, want %q", got, "file-10.txt")
}

tree.cursor = 1
tree.scrollOffset = 10
_, _ = tree.Update(tea.KeyPressMsg{Text: "j", Code: 'j'})
if tree.cursor != 11 || tree.scrollOffset != 10 {
t.Fatalf("after j cursor=%d scrollOffset=%d, want cursor=11 scrollOffset=10", tree.cursor, tree.scrollOffset)
}
if !treeCursorVisible(tree) {
t.Fatalf("expected cursor to be visible after j, cursor=%d scrollOffset=%d visibleHeight=%d",
tree.cursor, tree.scrollOffset, tree.visibleHeight())
}
}
Loading
Loading