diff --git a/internal/ui/sidebar/manual_scroll_helpers_test.go b/internal/ui/sidebar/manual_scroll_helpers_test.go new file mode 100644 index 00000000..3ad5725e --- /dev/null +++ b/internal/ui/sidebar/manual_scroll_helpers_test.go @@ -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 +} diff --git a/internal/ui/sidebar/manual_scroll_keyboard_anchor_test.go b/internal/ui/sidebar/manual_scroll_keyboard_anchor_test.go new file mode 100644 index 00000000..65febf88 --- /dev/null +++ b/internal/ui/sidebar/manual_scroll_keyboard_anchor_test.go @@ -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 := "" + 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 := "" + 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 := "" + 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()) + } +} diff --git a/internal/ui/sidebar/manual_scroll_rebuild_anchor_test.go b/internal/ui/sidebar/manual_scroll_rebuild_anchor_test.go new file mode 100644 index 00000000..c075f558 --- /dev/null +++ b/internal/ui/sidebar/manual_scroll_rebuild_anchor_test.go @@ -0,0 +1,494 @@ +package sidebar + +import ( + "os" + "path/filepath" + "strconv" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/andyrewlee/amux/internal/data" + "github.com/andyrewlee/amux/internal/git" + "github.com/andyrewlee/amux/internal/messages" +) + +func topVisibleChangePath(m *Model) string { + start := m.scrollOffset + if start < 0 { + start = 0 + } + end := start + m.visibleHeight() + if end > len(m.displayItems) { + end = len(m.displayItems) + } + for i := start; i < end; i++ { + if m.displayItems[i].change != nil { + return m.displayItems[i].change.Path + } + } + return "" +} + +func topVisibleItem(m *Model) displayItem { + if len(m.displayItems) == 0 { + return displayItem{} + } + if m.scrollOffset < 0 || m.scrollOffset >= len(m.displayItems) { + return displayItem{} + } + return m.displayItems[m.scrollOffset] +} + +func TestChangesSetGitStatusPreservesManualScrollAnchor(t *testing.T) { + m := New() + m.SetSize(80, 10) + unstaged := make([]git.Change, 0, 20) + for i := 0; i < 20; i++ { + unstaged = append(unstaged, git.Change{ + Path: "u" + strconv.Itoa(i/10) + strconv.Itoa(i%10) + ".go", + Kind: git.ChangeModified, + }) + } + m.SetGitStatus(&git.StatusResult{ + Clean: false, + Unstaged: unstaged, + }) + m.cursor = 1 + m.scrollOffset = 8 + + if got := topVisibleChangePath(m); got != "u07.go" { + t.Fatalf("top visible change = %q, want %q", got, "u07.go") + } + + m.SetGitStatus(&git.StatusResult{ + Clean: false, + Staged: []git.Change{ + {Path: "s00.go", Kind: git.ChangeModified, Staged: true}, + }, + Unstaged: unstaged, + }) + + if m.scrollOffset != 10 { + t.Fatalf("scrollOffset = %d, want 10", m.scrollOffset) + } + if got := topVisibleChangePath(m); got != "u07.go" { + t.Fatalf("top visible change after rebuild = %q, want %q", got, "u07.go") + } + cmd := m.openCurrentItem() + if cmd == nil { + t.Fatal("expected openCurrentItem command after rebuild") + } + msg := cmd() + diff, ok := msg.(messages.OpenDiff) + if !ok { + t.Fatalf("expected OpenDiff, got %T", msg) + } + if diff.Change == nil || diff.Change.Path != "u07.go" { + got := "" + if diff.Change != nil { + got = diff.Change.Path + } + t.Fatalf("opened change path = %q, want %q", got, "u07.go") + } +} + +func TestChangesSetGitStatusPreservesVisibleCursorManualScrollAnchor(t *testing.T) { + m := New() + m.SetSize(80, 10) + unstaged := make([]git.Change, 0, 20) + for i := 0; i < 20; i++ { + unstaged = append(unstaged, git.Change{ + Path: "u" + strconv.Itoa(i/10) + strconv.Itoa(i%10) + ".go", + Kind: git.ChangeModified, + }) + } + m.SetGitStatus(&git.StatusResult{ + Clean: false, + Unstaged: unstaged, + }) + m.cursor = 3 + m.scrollOffset = 1 + + if !m.cursorVisible() { + t.Fatalf("expected cursor to start visible, cursor=%d scrollOffset=%d visibleHeight=%d", + m.cursor, m.scrollOffset, m.visibleHeight()) + } + if got := topVisibleChangePath(m); got != "u00.go" { + t.Fatalf("top visible change = %q, want %q", got, "u00.go") + } + + m.SetGitStatus(&git.StatusResult{ + Clean: false, + Staged: []git.Change{ + {Path: "s00.go", Kind: git.ChangeModified, Staged: true}, + }, + Unstaged: unstaged, + }) + + if m.scrollOffset != 3 { + t.Fatalf("scrollOffset = %d, want 3", m.scrollOffset) + } + if got := topVisibleChangePath(m); got != "u00.go" { + t.Fatalf("top visible change after rebuild = %q, want %q", got, "u00.go") + } + if !m.cursorVisible() { + t.Fatalf("expected cursor to remain visible after rebuild, cursor=%d scrollOffset=%d visibleHeight=%d", + m.cursor, m.scrollOffset, m.visibleHeight()) + } +} + +func TestChangesSetGitStatusPreservesVisibleCursorAfterAnchorRestore(t *testing.T) { + m := New() + m.SetSize(80, 10) + staged := make([]git.Change, 0, 8) + for i := 0; i < 8; i++ { + staged = append(staged, git.Change{ + Path: "s" + strconv.Itoa(i/10) + strconv.Itoa(i%10) + ".go", + Kind: git.ChangeModified, + Staged: true, + }) + } + unstaged := []git.Change{ + {Path: "u00.go", Kind: git.ChangeModified}, + {Path: "u01.go", Kind: git.ChangeModified}, + {Path: "u02.go", Kind: git.ChangeModified}, + } + m.SetGitStatus(&git.StatusResult{ + Clean: false, + Staged: staged, + Unstaged: unstaged, + }) + m.cursor = 11 + m.scrollOffset = 6 + + if !m.cursorVisible() { + t.Fatalf("expected cursor to start visible, cursor=%d scrollOffset=%d visibleHeight=%d", + m.cursor, m.scrollOffset, m.visibleHeight()) + } + if got := topVisibleChangePath(m); got != "s05.go" { + t.Fatalf("top visible change = %q, want %q", got, "s05.go") + } + + m.SetGitStatus(&git.StatusResult{ + Clean: false, + Staged: []git.Change{ + staged[5], staged[6], staged[7], + }, + Unstaged: unstaged, + }) + + if got := topVisibleChangePath(m); got != "s05.go" { + t.Fatalf("top visible change after rebuild = %q, want %q", got, "s05.go") + } + if !m.cursorVisible() { + t.Fatalf("expected cursor to remain visible after anchor restore, cursor=%d scrollOffset=%d visibleHeight=%d", + m.cursor, m.scrollOffset, m.visibleHeight()) + } +} + +func TestChangesSetGitStatusDoesNotFallbackAcrossSections(t *testing.T) { + m := New() + m.SetSize(80, 10) + unstaged := []git.Change{ + {Path: "u00.go", Kind: git.ChangeModified}, + {Path: "shared.go", Kind: git.ChangeModified}, + {Path: "u01.go", Kind: git.ChangeModified}, + {Path: "u02.go", Kind: git.ChangeModified}, + {Path: "u03.go", Kind: git.ChangeModified}, + {Path: "u04.go", Kind: git.ChangeModified}, + {Path: "u05.go", Kind: git.ChangeModified}, + {Path: "u06.go", Kind: git.ChangeModified}, + {Path: "u07.go", Kind: git.ChangeModified}, + {Path: "u08.go", Kind: git.ChangeModified}, + } + m.SetGitStatus(&git.StatusResult{ + Clean: false, + Unstaged: unstaged, + }) + m.cursor = 3 + m.scrollOffset = 2 + + if got := topVisibleChangePath(m); got != "shared.go" { + t.Fatalf("top visible change = %q, want %q", got, "shared.go") + } + + m.SetGitStatus(&git.StatusResult{ + Clean: false, + Staged: []git.Change{ + {Path: "shared.go", Kind: git.ChangeModified, Staged: true}, + }, + Unstaged: []git.Change{ + {Path: "u00.go", Kind: git.ChangeModified}, + {Path: "u01.go", Kind: git.ChangeModified}, + {Path: "u02.go", Kind: git.ChangeModified}, + {Path: "u03.go", Kind: git.ChangeModified}, + {Path: "u04.go", Kind: git.ChangeModified}, + {Path: "u05.go", Kind: git.ChangeModified}, + {Path: "u06.go", Kind: git.ChangeModified}, + {Path: "u07.go", Kind: git.ChangeModified}, + {Path: "u08.go", Kind: git.ChangeModified}, + }, + }) + + if got := topVisibleChangePath(m); got != "u00.go" { + t.Fatalf("top visible change after rebuild = %q, want %q", got, "u00.go") + } +} + +func TestChangesSetGitStatusDoesNotExactMatchAcrossUntrackedAndUnstaged(t *testing.T) { + m := New() + m.SetSize(80, 10) + m.SetGitStatus(&git.StatusResult{ + Clean: false, + Untracked: []git.Change{ + {Path: "shared.go", Kind: git.ChangeUntracked}, + {Path: "u01.go", Kind: git.ChangeUntracked}, + {Path: "u02.go", Kind: git.ChangeUntracked}, + {Path: "u03.go", Kind: git.ChangeUntracked}, + {Path: "u04.go", Kind: git.ChangeUntracked}, + {Path: "u05.go", Kind: git.ChangeUntracked}, + {Path: "u06.go", Kind: git.ChangeUntracked}, + {Path: "u07.go", Kind: git.ChangeUntracked}, + {Path: "u08.go", Kind: git.ChangeUntracked}, + }, + }) + m.cursor = 2 + m.scrollOffset = 1 + + if got := topVisibleChangePath(m); got != "shared.go" { + t.Fatalf("top visible change = %q, want %q", got, "shared.go") + } + + m.SetGitStatus(&git.StatusResult{ + Clean: false, + Staged: []git.Change{ + {Path: "s00.go", Kind: git.ChangeModified, Staged: true}, + }, + Unstaged: []git.Change{ + {Path: "shared.go", Kind: git.ChangeModified}, + {Path: "u01.go", Kind: git.ChangeModified}, + {Path: "u02.go", Kind: git.ChangeModified}, + {Path: "u03.go", Kind: git.ChangeModified}, + {Path: "u04.go", Kind: git.ChangeModified}, + {Path: "u05.go", Kind: git.ChangeModified}, + {Path: "u06.go", Kind: git.ChangeModified}, + {Path: "u07.go", Kind: git.ChangeModified}, + {Path: "u08.go", Kind: git.ChangeModified}, + }, + }) + + if got := topVisibleChangePath(m); got == "shared.go" { + t.Fatalf("top visible change after rebuild = %q, want a non-cross-section match", got) + } +} + +func TestChangesSetGitStatusPreservesHeaderAlignedManualScrollAnchor(t *testing.T) { + m := New() + m.SetSize(80, 10) + unstaged := make([]git.Change, 0, 20) + for i := 0; i < 20; i++ { + unstaged = append(unstaged, git.Change{ + Path: "u" + strconv.Itoa(i/10) + strconv.Itoa(i%10) + ".go", + Kind: git.ChangeModified, + }) + } + m.SetGitStatus(&git.StatusResult{ + Clean: false, + Unstaged: unstaged, + }) + m.cursor = 10 + m.scrollOffset = 0 + + item := topVisibleItem(m) + if !item.isHeader || item.header != "Unstaged (20)" { + t.Fatalf("top visible item = %+v, want Unstaged header", item) + } + + m.SetGitStatus(&git.StatusResult{ + Clean: false, + Staged: []git.Change{ + {Path: "s00.go", Kind: git.ChangeModified, Staged: true}, + }, + Unstaged: unstaged, + }) + + if m.scrollOffset != 2 { + t.Fatalf("scrollOffset = %d, want 2", m.scrollOffset) + } + item = topVisibleItem(m) + if !item.isHeader || item.header != "Unstaged (20)" { + t.Fatalf("top visible item after rebuild = %+v, want Unstaged header", item) + } +} + +func TestChangesSetGitStatusPreservesHeaderOnlyManualScrollAnchor(t *testing.T) { + m := New() + m.SetSize(80, 2) + unstaged := []git.Change{ + {Path: "u00.go", Kind: git.ChangeModified}, + {Path: "u01.go", Kind: git.ChangeModified}, + } + m.SetGitStatus(&git.StatusResult{ + Clean: false, + Unstaged: unstaged, + }) + m.cursor = 1 + m.scrollOffset = 0 + + if m.visibleHeight() != 1 { + t.Fatalf("visibleHeight = %d, want 1", m.visibleHeight()) + } + item := topVisibleItem(m) + if !item.isHeader || item.header != "Unstaged (2)" { + t.Fatalf("top visible item = %+v, want Unstaged header", item) + } + + m.SetGitStatus(&git.StatusResult{ + Clean: false, + Staged: []git.Change{ + {Path: "s00.go", Kind: git.ChangeModified, Staged: true}, + }, + Unstaged: unstaged, + }) + + if m.scrollOffset != 2 { + t.Fatalf("scrollOffset = %d, want 2", m.scrollOffset) + } + item = topVisibleItem(m) + if !item.isHeader || item.header != "Unstaged (2)" { + t.Fatalf("top visible item after rebuild = %+v, want Unstaged header", item) + } +} + +func TestProjectTreeReloadPreservesManualScrollAnchor(t *testing.T) { + root := t.TempDir() + for _, name := range []string{ + "b.txt", "c.txt", "d.txt", "e.txt", "f.txt", "g.txt", "h.txt", "i.txt", + "j.txt", "k.txt", "l.txt", "m.txt", "n.txt", "o.txt", "p.txt", "q.txt", + } { + if err := os.WriteFile(filepath.Join(root, name), []byte(name), 0o644); err != nil { + t.Fatalf("write %q: %v", name, err) + } + } + + tree := NewProjectTree() + tree.SetSize(80, 10) + tree.Focus() + tree.SetWorkspace(data.NewWorkspace("feature", "feature", "main", root, root)) + tree.cursor = 1 + tree.scrollOffset = 5 + + if got := filepath.Base(tree.flatNodes[tree.scrollOffset].Path); got != "g.txt" { + t.Fatalf("top visible node = %q, want %q", got, "g.txt") + } + + if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("a"), 0o644); err != nil { + t.Fatalf("write a.txt: %v", err) + } + _, _ = tree.Update(tea.KeyPressMsg{Text: "r", Code: 'r'}) + + if tree.scrollOffset != 6 { + t.Fatalf("scrollOffset = %d, want 6", tree.scrollOffset) + } + if got := filepath.Base(tree.flatNodes[tree.scrollOffset].Path); got != "g.txt" { + t.Fatalf("top visible node after reload = %q, want %q", got, "g.txt") + } + cmd := tree.handleEnter() + if cmd == nil { + t.Fatal("expected handleEnter command after reload") + } + msg := cmd() + opened, ok := msg.(OpenFileInEditor) + if !ok { + t.Fatalf("expected OpenFileInEditor, got %T", msg) + } + if got := filepath.Base(opened.Path); got != "g.txt" { + t.Fatalf("opened path = %q, want %q", got, "g.txt") + } +} + +func TestProjectTreeReloadPreservesVisibleCursorManualScrollAnchor(t *testing.T) { + root := t.TempDir() + for _, name := range []string{ + "b.txt", "c.txt", "d.txt", "e.txt", "f.txt", "g.txt", "h.txt", "i.txt", + "j.txt", "k.txt", "l.txt", "m.txt", "n.txt", "o.txt", "p.txt", "q.txt", + } { + if err := os.WriteFile(filepath.Join(root, name), []byte(name), 0o644); err != nil { + t.Fatalf("write %q: %v", name, err) + } + } + + tree := NewProjectTree() + tree.SetSize(80, 10) + tree.Focus() + tree.SetWorkspace(data.NewWorkspace("feature", "feature", "main", root, root)) + tree.cursor = 3 + tree.scrollOffset = 1 + + if !treeCursorVisible(tree) { + t.Fatalf("expected cursor to start visible, cursor=%d scrollOffset=%d visibleHeight=%d", + tree.cursor, tree.scrollOffset, tree.visibleHeight()) + } + if got := filepath.Base(tree.flatNodes[tree.scrollOffset].Path); got != "c.txt" { + t.Fatalf("top visible node = %q, want %q", got, "c.txt") + } + + if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("a"), 0o644); err != nil { + t.Fatalf("write a.txt: %v", err) + } + _, _ = tree.Update(tea.KeyPressMsg{Text: "r", Code: 'r'}) + + if tree.scrollOffset != 2 { + t.Fatalf("scrollOffset = %d, want 2", tree.scrollOffset) + } + if got := filepath.Base(tree.flatNodes[tree.scrollOffset].Path); got != "c.txt" { + t.Fatalf("top visible node after reload = %q, want %q", got, "c.txt") + } + if !treeCursorVisible(tree) { + t.Fatalf("expected cursor to remain visible after reload, cursor=%d scrollOffset=%d visibleHeight=%d", + tree.cursor, tree.scrollOffset, tree.visibleHeight()) + } +} + +func TestProjectTreeReloadPreservesVisibleCursorAfterAnchorRestore(t *testing.T) { + root := t.TempDir() + for _, name := range []string{ + "a.txt", "b.txt", "c.txt", "d.txt", "e.txt", "f.txt", "g.txt", "h.txt", + "i.txt", "j.txt", "k.txt", "l.txt", "m.txt", + } { + if err := os.WriteFile(filepath.Join(root, name), []byte(name), 0o644); err != nil { + t.Fatalf("write %q: %v", name, err) + } + } + + tree := NewProjectTree() + tree.SetSize(80, 10) + tree.Focus() + tree.SetWorkspace(data.NewWorkspace("feature", "feature", "main", root, root)) + tree.cursor = 10 + tree.scrollOffset = 5 + + if !treeCursorVisible(tree) { + t.Fatalf("expected cursor to start visible, cursor=%d scrollOffset=%d visibleHeight=%d", + tree.cursor, tree.scrollOffset, tree.visibleHeight()) + } + if got := filepath.Base(tree.flatNodes[tree.scrollOffset].Path); got != "f.txt" { + t.Fatalf("top visible node = %q, want %q", got, "f.txt") + } + + for _, name := range []string{"a.txt", "b.txt", "c.txt", "d.txt", "e.txt"} { + if err := os.Remove(filepath.Join(root, name)); err != nil { + t.Fatalf("remove %q: %v", name, err) + } + } + _, _ = tree.Update(tea.KeyPressMsg{Text: "r", Code: 'r'}) + + if got := filepath.Base(tree.flatNodes[tree.scrollOffset].Path); got != "f.txt" { + t.Fatalf("top visible node after reload = %q, want %q", got, "f.txt") + } + if !treeCursorVisible(tree) { + t.Fatalf("expected cursor to remain visible after anchor restore, cursor=%d scrollOffset=%d visibleHeight=%d", + tree.cursor, tree.scrollOffset, tree.visibleHeight()) + } +} diff --git a/internal/ui/sidebar/manual_scroll_refocus_test.go b/internal/ui/sidebar/manual_scroll_refocus_test.go new file mode 100644 index 00000000..ffdae390 --- /dev/null +++ b/internal/ui/sidebar/manual_scroll_refocus_test.go @@ -0,0 +1,241 @@ +package sidebar + +import ( + "fmt" + "testing" + + "github.com/andyrewlee/amux/internal/data" + "github.com/andyrewlee/amux/internal/git" +) + +func TestChangesFocusIsIdempotentWhileAlreadyFocused(t *testing.T) { + m := setupChangesScrollModel() + m.cursor = 1 + m.scrollOffset = 6 + + if changesCursorVisible(m) { + t.Fatalf("expected cursor to start hidden, cursor=%d scrollOffset=%d visibleHeight=%d", + m.cursor, m.scrollOffset, m.visibleHeight()) + } + + m.Focus() + + if m.scrollOffset != 6 { + t.Fatalf("scrollOffset = %d, want 6", m.scrollOffset) + } + if changesCursorVisible(m) { + t.Fatalf("expected redundant Focus to preserve hidden cursor, cursor=%d scrollOffset=%d visibleHeight=%d", + m.cursor, m.scrollOffset, m.visibleHeight()) + } + + m.Blur() + m.Focus() + + if !changesCursorVisible(m) { + t.Fatalf("expected Focus after blur to reanchor cursor, cursor=%d scrollOffset=%d visibleHeight=%d", + m.cursor, m.scrollOffset, m.visibleHeight()) + } +} + +func TestTabbedInactiveChangesPreserveManualScrollOffsetUntilRefocus(t *testing.T) { + tests := []struct { + name string + apply func(*TabbedSidebar) + }{ + { + name: "resize", + apply: func(sidebar *TabbedSidebar) { + sidebar.SetSize(80, 8) + }, + }, + { + name: "show keymap hints", + apply: func(sidebar *TabbedSidebar) { + sidebar.SetShowKeymapHints(true) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sidebar := setupTabbedChangesScrollModel() + changes := sidebar.Changes() + changes.cursor = 1 + changes.scrollOffset = 6 + + sidebar.SetActiveTab(TabProject) + tt.apply(sidebar) + + if changes.scrollOffset != 6 { + t.Fatalf("scrollOffset = %d, want 6", changes.scrollOffset) + } + if changesCursorVisible(changes) { + t.Fatalf("expected cursor to remain hidden while inactive, cursor=%d scrollOffset=%d visibleHeight=%d", + changes.cursor, changes.scrollOffset, changes.visibleHeight()) + } + + sidebar.SetActiveTab(TabChanges) + + if !changesCursorVisible(changes) { + t.Fatalf("expected cursor to be visible after refocus, cursor=%d scrollOffset=%d visibleHeight=%d", + changes.cursor, changes.scrollOffset, changes.visibleHeight()) + } + }) + } +} + +func TestTabbedInactiveChangesSkipCursorRepairOnGitStatusRefresh(t *testing.T) { + sidebar := setupTabbedChangesScrollModel() + changes := sidebar.Changes() + changes.cursor = 7 + changes.scrollOffset = 6 + + if !changesCursorVisible(changes) { + t.Fatalf("expected cursor to start visible, cursor=%d scrollOffset=%d visibleHeight=%d", + changes.cursor, changes.scrollOffset, changes.visibleHeight()) + } + if got := topVisibleChangePath(changes); got != "file-05.txt" { + t.Fatalf("top visible change = %q, want %q", got, "file-05.txt") + } + + 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.SetActiveTab(TabProject) + sidebar.SetGitStatus(&git.StatusResult{ + Clean: false, + Staged: []git.Change{ + {Path: "staged.txt", Kind: git.ChangeModified, Staged: true}, + }, + Unstaged: unstaged, + }) + + if got := topVisibleChangePath(changes); got != "file-05.txt" { + t.Fatalf("top visible change after background refresh = %q, want %q", got, "file-05.txt") + } + if changesCursorVisible(changes) { + t.Fatalf("expected cursor to remain hidden while inactive after refresh, cursor=%d scrollOffset=%d visibleHeight=%d", + changes.cursor, changes.scrollOffset, changes.visibleHeight()) + } + + sidebar.SetActiveTab(TabChanges) + + if !changesCursorVisible(changes) { + t.Fatalf("expected cursor to be visible after refocus, cursor=%d scrollOffset=%d visibleHeight=%d", + changes.cursor, changes.scrollOffset, changes.visibleHeight()) + } +} + +func TestTabbedInactiveChangesSkipCursorRepairOnWorkspaceRebind(t *testing.T) { + sidebar := setupTabbedChangesScrollModel() + changes := sidebar.Changes() + ws1 := data.NewWorkspace("feature", "", "main", "/tmp/repo", "/tmp/workspaces/repo/feature") + ws2 := data.NewWorkspace("feature", "updated-branch", "main", "/tmp/repo", "/tmp/workspaces/repo/feature") + sidebar.SetWorkspace(ws1) + changes.cursor = 13 + changes.scrollOffset = 6 + + if !changesCursorVisible(changes) { + t.Fatalf("expected cursor to start visible, cursor=%d scrollOffset=%d visibleHeight=%d", + changes.cursor, changes.scrollOffset, changes.visibleHeight()) + } + + sidebar.SetActiveTab(TabProject) + sidebar.SetWorkspace(ws2) + + if changes.scrollOffset != 6 { + t.Fatalf("scrollOffset = %d, want 6 while inactive", changes.scrollOffset) + } + if changesCursorVisible(changes) { + t.Fatalf("expected cursor to remain hidden while inactive after workspace rebind, cursor=%d scrollOffset=%d visibleHeight=%d", + changes.cursor, changes.scrollOffset, changes.visibleHeight()) + } + + sidebar.SetActiveTab(TabChanges) + + if !changesCursorVisible(changes) { + t.Fatalf("expected cursor to be visible after refocus, cursor=%d scrollOffset=%d visibleHeight=%d", + changes.cursor, changes.scrollOffset, changes.visibleHeight()) + } +} + +func TestProjectTreeFocusIsIdempotentWhileAlreadyFocused(t *testing.T) { + tree := setupProjectTreeScrollModel(t) + tree.cursor = 1 + tree.scrollOffset = 6 + + if treeCursorVisible(tree) { + t.Fatalf("expected cursor to start hidden, cursor=%d scrollOffset=%d visibleHeight=%d", + tree.cursor, tree.scrollOffset, tree.visibleHeight()) + } + + tree.Focus() + + if tree.scrollOffset != 6 { + t.Fatalf("scrollOffset = %d, want 6", tree.scrollOffset) + } + if treeCursorVisible(tree) { + t.Fatalf("expected redundant Focus to preserve hidden cursor, cursor=%d scrollOffset=%d visibleHeight=%d", + tree.cursor, tree.scrollOffset, tree.visibleHeight()) + } + + tree.Blur() + tree.Focus() + + if !treeCursorVisible(tree) { + t.Fatalf("expected Focus after blur to reanchor cursor, cursor=%d scrollOffset=%d visibleHeight=%d", + tree.cursor, tree.scrollOffset, tree.visibleHeight()) + } +} + +func TestTabbedInactiveProjectPreserveManualScrollOffsetUntilRefocus(t *testing.T) { + tests := []struct { + name string + apply func(*TabbedSidebar) + }{ + { + name: "resize", + apply: func(sidebar *TabbedSidebar) { + sidebar.SetSize(80, 8) + }, + }, + { + name: "show keymap hints", + apply: func(sidebar *TabbedSidebar) { + sidebar.SetShowKeymapHints(true) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sidebar := setupTabbedProjectScrollModel(t) + tree := sidebar.ProjectTree() + tree.cursor = 1 + tree.scrollOffset = 6 + + sidebar.SetActiveTab(TabChanges) + tt.apply(sidebar) + + if tree.scrollOffset != 6 { + t.Fatalf("scrollOffset = %d, want 6", tree.scrollOffset) + } + if treeCursorVisible(tree) { + t.Fatalf("expected cursor to remain hidden while inactive, cursor=%d scrollOffset=%d visibleHeight=%d", + tree.cursor, tree.scrollOffset, tree.visibleHeight()) + } + + sidebar.SetActiveTab(TabProject) + + if !treeCursorVisible(tree) { + t.Fatalf("expected cursor to be visible after refocus, cursor=%d scrollOffset=%d visibleHeight=%d", + tree.cursor, tree.scrollOffset, tree.visibleHeight()) + } + }) + } +} diff --git a/internal/ui/sidebar/manual_scroll_test.go b/internal/ui/sidebar/manual_scroll_test.go new file mode 100644 index 00000000..fe7a5346 --- /dev/null +++ b/internal/ui/sidebar/manual_scroll_test.go @@ -0,0 +1,410 @@ +package sidebar + +import ( + "path/filepath" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/andyrewlee/amux/internal/messages" +) + +func TestChangesViewPreservesManualScrollOffset(t *testing.T) { + m := setupChangesScrollModel() + m.cursor = 1 + m.scrollOffset = 6 + + _ = m.View() + + if m.scrollOffset != 6 { + t.Fatalf("scrollOffset = %d, want 6", m.scrollOffset) + } +} + +func TestTabbedChangesViewPreservesManualScrollOffset(t *testing.T) { + sidebar := setupTabbedChangesScrollModel() + changes := sidebar.Changes() + changes.cursor = 1 + changes.scrollOffset = 6 + + _ = sidebar.View() + + if changes.scrollOffset != 6 { + t.Fatalf("scrollOffset = %d, want 6", changes.scrollOffset) + } +} + +func TestTabbedChangesContentViewPreservesManualScrollOffset(t *testing.T) { + sidebar := setupTabbedChangesScrollModel() + changes := sidebar.Changes() + changes.cursor = 1 + changes.scrollOffset = 6 + + _ = sidebar.ContentView() + + if changes.scrollOffset != 6 { + t.Fatalf("scrollOffset = %d, want 6", changes.scrollOffset) + } +} + +func TestChangesMouseWheelScrollMovesViewportNotCursor(t *testing.T) { + m := setupChangesScrollModel() + m.cursor = 1 + + _, _ = m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + + if m.cursor != 1 { + t.Fatalf("cursor = %d, want 1", m.cursor) + } + if m.scrollOffset == 0 { + t.Fatal("expected scrollOffset to increase after wheel scroll") + } +} + +func TestChangesPageScrollUsesViewportOffset(t *testing.T) { + m := setupChangesScrollModel() + m.cursor = 1 + + _, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyPgDown}) + if m.cursor != 5 { + t.Fatalf("cursor = %d, want 5 after PgDown", m.cursor) + } + if m.scrollOffset != 4 { + t.Fatalf("scrollOffset = %d, want 4 after PgDown", m.scrollOffset) + } + if !changesCursorVisible(m) { + t.Fatalf("expected cursor to stay visible after PgDown, cursor=%d scrollOffset=%d visibleHeight=%d", + m.cursor, m.scrollOffset, m.visibleHeight()) + } + cmd := m.openCurrentItem() + if cmd == nil { + t.Fatal("expected openCurrentItem command after PgDown") + } + msg := cmd() + diff, ok := msg.(messages.OpenDiff) + if !ok { + t.Fatalf("expected OpenDiff, got %T", msg) + } + if diff.Change == nil || diff.Change.Path != "file-04.txt" { + got := "" + if diff.Change != nil { + got = diff.Change.Path + } + t.Fatalf("opened change path = %q, want %q", got, "file-04.txt") + } + + _, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyPgUp}) + if m.cursor != 1 { + t.Fatalf("cursor = %d, want 1 after PgUp", m.cursor) + } + if m.scrollOffset != 0 { + t.Fatalf("scrollOffset = %d, want 0 after PgUp", m.scrollOffset) + } + + m.cursor = 1 + m.scrollOffset = 10 + _, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyPgDown}) + if m.cursor != 14 || m.scrollOffset != 12 { + t.Fatalf("after hidden-cursor PgDown cursor=%d scrollOffset=%d, want cursor=14 scrollOffset=12", m.cursor, m.scrollOffset) + } + cmd = m.openCurrentItem() + if cmd == nil { + t.Fatal("expected openCurrentItem command after hidden-cursor PgDown") + } + msg = cmd() + diff, ok = msg.(messages.OpenDiff) + if !ok { + t.Fatalf("expected OpenDiff after hidden-cursor PgDown, got %T", msg) + } + if diff.Change == nil || diff.Change.Path != "file-13.txt" { + got := "" + if diff.Change != nil { + got = diff.Change.Path + } + t.Fatalf("hidden-cursor PgDown opened change path = %q, want %q", got, "file-13.txt") + } +} + +func TestChangesFilterModeKeepsCursorVisible(t *testing.T) { + m := setupChangesScrollModel() + m.cursor = 1 + m.scrollOffset = 6 + + _, _ = m.Update(tea.KeyPressMsg{Text: "/", Code: '/'}) + _ = m.View() + + if !changesCursorVisible(m) { + t.Fatalf("expected cursor to be visible after entering filter mode, cursor=%d scrollOffset=%d visibleHeight=%d", + m.cursor, m.scrollOffset, m.visibleHeight()) + } +} + +func TestChangesSetShowKeymapHintsKeepsCursorVisible(t *testing.T) { + m := setupChangesScrollModel() + m.cursor = 1 + m.scrollOffset = 6 + + m.SetShowKeymapHints(true) + _ = m.View() + + if !changesCursorVisible(m) { + t.Fatalf("expected cursor to be visible after enabling keymap hints, cursor=%d scrollOffset=%d visibleHeight=%d", + m.cursor, m.scrollOffset, m.visibleHeight()) + } +} + +func TestChangesHideKeymapHintsPreservesManualScrollOffset(t *testing.T) { + m := setupChangesScrollModel() + m.showKeymapHints = true + m.cursor = 1 + m.scrollOffset = 6 + oldVisibleHeight := m.visibleHeight() + + m.SetShowKeymapHints(false) + _ = m.View() + + if m.visibleHeight() <= oldVisibleHeight { + t.Fatalf("visibleHeight = %d, want > %d", m.visibleHeight(), oldVisibleHeight) + } + if m.scrollOffset != 6 { + t.Fatalf("scrollOffset = %d, want 6", m.scrollOffset) + } +} + +func TestChangesHeightIncreasePreservesManualScrollOffset(t *testing.T) { + m := setupChangesScrollModel() + m.cursor = 1 + m.scrollOffset = 6 + oldVisibleHeight := m.visibleHeight() + + m.SetSize(80, 12) + _ = m.View() + + if m.visibleHeight() <= oldVisibleHeight { + t.Fatalf("visibleHeight = %d, want > %d", m.visibleHeight(), oldVisibleHeight) + } + if m.scrollOffset != 6 { + t.Fatalf("scrollOffset = %d, want 6", m.scrollOffset) + } +} + +func TestChangesWidthOnlyResizePreservesManualScrollOffset(t *testing.T) { + m := setupChangesScrollModel() + m.cursor = 1 + m.scrollOffset = 6 + oldVisibleHeight := m.visibleHeight() + + m.SetSize(60, 10) + _ = m.View() + + if m.visibleHeight() != oldVisibleHeight { + t.Fatalf("visibleHeight = %d, want %d", m.visibleHeight(), oldVisibleHeight) + } + if m.scrollOffset != 6 { + t.Fatalf("scrollOffset = %d, want 6", m.scrollOffset) + } +} + +func TestProjectTreeViewPreservesManualScrollOffset(t *testing.T) { + tree := setupProjectTreeScrollModel(t) + tree.cursor = 1 + tree.scrollOffset = 6 + + _ = tree.View() + + if tree.scrollOffset != 6 { + t.Fatalf("scrollOffset = %d, want 6", tree.scrollOffset) + } +} + +func TestTabbedProjectViewPreservesManualScrollOffset(t *testing.T) { + sidebar := setupTabbedProjectScrollModel(t) + tree := sidebar.ProjectTree() + tree.cursor = 1 + tree.scrollOffset = 6 + + _ = sidebar.View() + + if tree.scrollOffset != 6 { + t.Fatalf("scrollOffset = %d, want 6", tree.scrollOffset) + } +} + +func TestTabbedProjectContentViewPreservesManualScrollOffset(t *testing.T) { + sidebar := setupTabbedProjectScrollModel(t) + tree := sidebar.ProjectTree() + tree.cursor = 1 + tree.scrollOffset = 6 + + _ = sidebar.ContentView() + + if tree.scrollOffset != 6 { + t.Fatalf("scrollOffset = %d, want 6", tree.scrollOffset) + } +} + +func TestProjectTreeMouseWheelScrollMovesViewportNotCursor(t *testing.T) { + tree := setupProjectTreeScrollModel(t) + tree.cursor = 1 + + _, _ = tree.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + + if tree.cursor != 1 { + t.Fatalf("cursor = %d, want 1", tree.cursor) + } + if tree.scrollOffset == 0 { + t.Fatal("expected scrollOffset to increase after wheel scroll") + } +} + +func TestProjectTreePageScrollUsesViewportOffset(t *testing.T) { + tree := setupProjectTreeScrollModel(t) + tree.cursor = 1 + + _, _ = tree.Update(tea.KeyPressMsg{Code: tea.KeyPgDown}) + if tree.cursor != 6 { + t.Fatalf("cursor = %d, want 6 after PgDown", tree.cursor) + } + if tree.scrollOffset != 5 { + t.Fatalf("scrollOffset = %d, want 5 after PgDown", tree.scrollOffset) + } + if !treeCursorVisible(tree) { + t.Fatalf("expected cursor to stay visible after PgDown, cursor=%d scrollOffset=%d visibleHeight=%d", + tree.cursor, tree.scrollOffset, tree.visibleHeight()) + } + cmd := tree.handleEnter() + if cmd == nil { + t.Fatal("expected handleEnter command after PgDown") + } + msg := cmd() + opened, ok := msg.(OpenFileInEditor) + if !ok { + t.Fatalf("expected OpenFileInEditor, got %T", msg) + } + if got := filepath.Base(opened.Path); got != "file-06.txt" { + t.Fatalf("opened path = %q, want %q", got, "file-06.txt") + } + + _, _ = tree.Update(tea.KeyPressMsg{Code: tea.KeyPgUp}) + if tree.cursor != 1 { + t.Fatalf("cursor = %d, want 1 after PgUp", tree.cursor) + } + if tree.scrollOffset != 0 { + t.Fatalf("scrollOffset = %d, want 0 after PgUp", tree.scrollOffset) + } + + tree.cursor = 1 + tree.scrollOffset = 10 + _, _ = tree.Update(tea.KeyPressMsg{Code: tea.KeyPgDown}) + if tree.cursor != 15 || tree.scrollOffset != 10 { + t.Fatalf("after hidden-cursor PgDown cursor=%d scrollOffset=%d, want cursor=15 scrollOffset=10", tree.cursor, tree.scrollOffset) + } + cmd = tree.handleEnter() + if cmd == nil { + t.Fatal("expected handleEnter command after hidden-cursor PgDown") + } + msg = cmd() + opened, ok = msg.(OpenFileInEditor) + if !ok { + t.Fatalf("expected OpenFileInEditor after hidden-cursor PgDown, got %T", msg) + } + if got := filepath.Base(opened.Path); got != "file-15.txt" { + t.Fatalf("hidden-cursor PgDown opened path = %q, want %q", got, "file-15.txt") + } +} + +func TestProjectTreeParentJumpKeepsCursorVisible(t *testing.T) { + tree := setupNestedProjectTreeScrollModel(t) + tree.cursor = 9 + tree.scrollOffset = 6 + + _, _ = tree.Update(tea.KeyPressMsg{Text: "h", Code: 'h'}) + + if tree.cursor != 0 { + t.Fatalf("cursor = %d, want 0", tree.cursor) + } + if !treeCursorVisible(tree) { + t.Fatalf("expected cursor to be visible after parent jump, cursor=%d scrollOffset=%d visibleHeight=%d", + tree.cursor, tree.scrollOffset, tree.visibleHeight()) + } +} + +func TestProjectTreeResizeKeepsCursorVisible(t *testing.T) { + tree := setupProjectTreeScrollModel(t) + tree.cursor = 1 + tree.scrollOffset = 6 + + tree.SetSize(80, 8) + _ = tree.View() + + if !treeCursorVisible(tree) { + t.Fatalf("expected cursor to be visible after resize, cursor=%d scrollOffset=%d visibleHeight=%d", + tree.cursor, tree.scrollOffset, tree.visibleHeight()) + } +} + +func TestProjectTreeWidthOnlyResizePreservesManualScrollOffset(t *testing.T) { + tree := setupProjectTreeScrollModel(t) + tree.cursor = 1 + tree.scrollOffset = 6 + oldVisibleHeight := tree.visibleHeight() + + tree.SetSize(60, 10) + _ = tree.View() + + if tree.visibleHeight() != oldVisibleHeight { + t.Fatalf("visibleHeight = %d, want %d", tree.visibleHeight(), oldVisibleHeight) + } + if tree.scrollOffset != 6 { + t.Fatalf("scrollOffset = %d, want 6", tree.scrollOffset) + } +} + +func TestProjectTreeSetShowKeymapHintsKeepsCursorVisible(t *testing.T) { + tree := setupProjectTreeScrollModel(t) + tree.cursor = 1 + tree.scrollOffset = 6 + + tree.SetShowKeymapHints(true) + _ = tree.View() + + if !treeCursorVisible(tree) { + t.Fatalf("expected cursor to be visible after enabling keymap hints, cursor=%d scrollOffset=%d visibleHeight=%d", + tree.cursor, tree.scrollOffset, tree.visibleHeight()) + } +} + +func TestProjectTreeHideKeymapHintsPreservesManualScrollOffset(t *testing.T) { + tree := setupProjectTreeScrollModel(t) + tree.showKeymapHints = true + tree.cursor = 1 + tree.scrollOffset = 6 + oldVisibleHeight := tree.visibleHeight() + + tree.SetShowKeymapHints(false) + _ = tree.View() + + if tree.visibleHeight() <= oldVisibleHeight { + t.Fatalf("visibleHeight = %d, want > %d", tree.visibleHeight(), oldVisibleHeight) + } + if tree.scrollOffset != 6 { + t.Fatalf("scrollOffset = %d, want 6", tree.scrollOffset) + } +} + +func TestProjectTreeHeightIncreasePreservesManualScrollOffset(t *testing.T) { + tree := setupProjectTreeScrollModel(t) + tree.cursor = 1 + tree.scrollOffset = 6 + oldVisibleHeight := tree.visibleHeight() + + tree.SetSize(80, 12) + _ = tree.View() + + if tree.visibleHeight() <= oldVisibleHeight { + t.Fatalf("visibleHeight = %d, want > %d", tree.visibleHeight(), oldVisibleHeight) + } + if tree.scrollOffset != 6 { + t.Fatalf("scrollOffset = %d, want 6", tree.scrollOffset) + } +} diff --git a/internal/ui/sidebar/model.go b/internal/ui/sidebar/model.go index f853e5d0..9691eab0 100644 --- a/internal/ui/sidebar/model.go +++ b/internal/ui/sidebar/model.go @@ -61,9 +61,16 @@ func New() *Model { // rebuildDisplayList rebuilds the flat display list from grouped status. func (m *Model) rebuildDisplayList() { + wasCursorVisible := m.cursorVisible() + preserveViewport := m.scrollOffset > 0 || !wasCursorVisible + anchor := changeViewportAnchor{} + if preserveViewport { + anchor = m.viewportAnchor() + } m.displayItems = nil if m.gitStatus == nil || m.gitStatus.Clean { + m.scrollOffset = 0 return } @@ -158,6 +165,22 @@ func (m *Model) rebuildDisplayList() { if m.cursor >= len(m.displayItems) && len(m.displayItems) > 0 { m.cursor = len(m.displayItems) - 1 } + + if preserveViewport && m.restoreViewportAnchor(anchor) { + if m.focused && wasCursorVisible && !m.cursorVisible() { + m.ensureCursorVisible() + } + return + } + if wasCursorVisible && !m.cursorVisible() { + if preserveViewport && !m.focused { + m.clampScrollOffset() + return + } + m.ensureCursorVisible() + return + } + m.clampScrollOffset() } func (m *Model) listHeaderLines() int { diff --git a/internal/ui/sidebar/model_input.go b/internal/ui/sidebar/model_input.go index 97d4d775..877afaa9 100644 --- a/internal/ui/sidebar/model_input.go +++ b/internal/ui/sidebar/model_input.go @@ -24,10 +24,12 @@ func (m *Model) Update(msg tea.Msg) (*Model, tea.Cmd) { m.filterInput.SetValue("") m.filterInput.Blur() m.rebuildDisplayList() + m.ensureCursorVisible() return m, nil case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): m.filterMode = false m.filterInput.Blur() + m.ensureCursorVisible() return m, nil default: newInput, cmd := m.filterInput.Update(msg) @@ -44,13 +46,13 @@ func (m *Model) Update(msg tea.Msg) (*Model, tea.Cmd) { if !m.focused { return m, nil } - delta := common.ScrollDeltaForHeight(m.visibleHeight(), 10) // ~10% of visible + delta := common.ScrollDeltaForHeight(m.visibleHeight(), 8) if msg.Button == tea.MouseWheelUp { - m.moveCursor(-delta) + m.scrollBy(-delta) return m, nil } if msg.Button == tea.MouseWheelDown { - m.moveCursor(delta) + m.scrollBy(delta) return m, nil } @@ -77,6 +79,10 @@ func (m *Model) Update(msg tea.Msg) (*Model, tea.Cmd) { m.moveCursor(1) case key.Matches(msg, key.NewBinding(key.WithKeys("k", "up"))): m.moveCursor(-1) + case msg.Key().Code == tea.KeyPgUp: + m.scrollPage(-1) + case msg.Key().Code == tea.KeyPgDown: + m.scrollPage(1) case key.Matches(msg, key.NewBinding(key.WithKeys("enter", "space", "o"))): cmds = append(cmds, m.openCurrentItem()) case key.Matches(msg, key.NewBinding(key.WithKeys("g"))): @@ -85,6 +91,7 @@ func (m *Model) Update(msg tea.Msg) (*Model, tea.Cmd) { // Enter filter mode m.filterMode = true m.filterInput.Focus() + m.ensureCursorVisible() return m, m.filterInput.Focus() } } @@ -94,6 +101,9 @@ func (m *Model) Update(msg tea.Msg) (*Model, tea.Cmd) { // openCurrentItem opens the diff for the currently selected item. func (m *Model) openCurrentItem() tea.Cmd { + if !m.cursorVisible() { + m.reanchorCursorToViewport() + } if m.cursor < 0 || m.cursor >= len(m.displayItems) { return nil } @@ -145,6 +155,13 @@ func (m *Model) moveCursor(delta int) { if len(m.displayItems) == 0 { return } + viewportHasSelectableItem := m.viewportHasSelectableItem() + if !m.cursorVisible() { + m.reanchorCursorToViewport() + if !viewportHasSelectableItem { + return + } + } newCursor := m.cursor + delta @@ -176,6 +193,7 @@ func (m *Model) moveCursor(delta int) { if newCursor >= 0 && newCursor < len(m.displayItems) { m.cursor = newCursor } + m.ensureCursorVisible() } // refreshStatus refreshes the git status. diff --git a/internal/ui/sidebar/model_lifecycle.go b/internal/ui/sidebar/model_lifecycle.go index 1c50b614..976608b8 100644 --- a/internal/ui/sidebar/model_lifecycle.go +++ b/internal/ui/sidebar/model_lifecycle.go @@ -10,7 +10,20 @@ import ( // SetShowKeymapHints controls whether helper text is rendered. func (m *Model) SetShowKeymapHints(show bool) { + if m.showKeymapHints == show { + return + } + oldVisibleHeight := m.visibleHeight() m.showKeymapHints = show + newVisibleHeight := m.visibleHeight() + switch { + case newVisibleHeight < oldVisibleHeight: + if m.focused { + m.ensureCursorVisible() + } + case newVisibleHeight > oldVisibleHeight: + m.clampScrollOffset() + } } // SetStyles updates the component's styles (for theme changes). @@ -25,13 +38,32 @@ func (m *Model) Init() tea.Cmd { // SetSize sets the sidebar size. func (m *Model) SetSize(width, height int) { + if m.width == width && m.height == height { + return + } + oldVisibleHeight := m.visibleHeight() m.width = width m.height = height + newVisibleHeight := m.visibleHeight() + switch { + case newVisibleHeight < oldVisibleHeight: + if m.focused { + m.ensureCursorVisible() + } + case newVisibleHeight > oldVisibleHeight: + m.clampScrollOffset() + } } // Focus sets the focus state. func (m *Model) Focus() { + if m.focused { + return + } m.focused = true + if !m.cursorVisible() { + m.ensureCursorVisible() + } } // Blur removes focus. @@ -52,8 +84,12 @@ func (m *Model) Focused() bool { // SetWorkspace sets the active workspace. func (m *Model) SetWorkspace(ws *data.Workspace) { if sameWorkspaceByCanonicalPaths(m.workspace, ws) { + wasCursorVisible := m.cursorVisible() // Rebind pointer for metadata freshness without resetting UI state. m.workspace = ws + if m.focused && wasCursorVisible && !m.cursorVisible() { + m.ensureCursorVisible() + } return } m.workspace = ws @@ -68,4 +104,5 @@ func (m *Model) SetWorkspace(ws *data.Workspace) { func (m *Model) SetGitStatus(status *git.StatusResult) { m.gitStatus = status m.rebuildDisplayList() + m.clampScrollOffset() } diff --git a/internal/ui/sidebar/model_scroll.go b/internal/ui/sidebar/model_scroll.go new file mode 100644 index 00000000..4f3c5f90 --- /dev/null +++ b/internal/ui/sidebar/model_scroll.go @@ -0,0 +1,335 @@ +package sidebar + +import ( + "strings" + + "github.com/andyrewlee/amux/internal/git" +) + +func (m *Model) maxScrollOffset() int { + maxOffset := len(m.displayItems) - m.visibleHeight() + if maxOffset < 0 { + return 0 + } + return maxOffset +} + +func (m *Model) clampScrollOffset() { + if len(m.displayItems) == 0 { + m.scrollOffset = 0 + return + } + if m.scrollOffset < 0 { + m.scrollOffset = 0 + } + maxOffset := m.maxScrollOffset() + if m.scrollOffset > maxOffset { + m.scrollOffset = maxOffset + } +} + +func (m *Model) cursorVisible() bool { + if len(m.displayItems) == 0 { + return true + } + if m.cursor < 0 || m.cursor >= len(m.displayItems) { + return false + } + visibleHeight := m.visibleHeight() + return m.cursor >= m.scrollOffset && m.cursor < m.scrollOffset+visibleHeight +} + +func (m *Model) ensureCursorVisible() { + if len(m.displayItems) == 0 { + m.cursor = 0 + m.scrollOffset = 0 + return + } + if m.cursor < 0 { + m.cursor = 0 + } + if m.cursor >= len(m.displayItems) { + m.cursor = len(m.displayItems) - 1 + } + if m.cursor < m.scrollOffset { + m.scrollOffset = m.cursor + } + visibleHeight := m.visibleHeight() + if m.cursor >= m.scrollOffset+visibleHeight { + m.scrollOffset = m.cursor - visibleHeight + 1 + } + m.clampScrollOffset() +} + +func (m *Model) scrollBy(delta int) { + if delta == 0 { + return + } + m.scrollOffset += delta + m.clampScrollOffset() +} + +func (m *Model) reanchorCursorToViewport() { + if len(m.displayItems) == 0 || m.cursorVisible() { + return + } + if m.cursor < m.scrollOffset { + m.cursor = m.scrollOffset + for m.cursor < len(m.displayItems) && m.displayItems[m.cursor].isHeader { + m.cursor++ + } + if m.cursor >= len(m.displayItems) { + m.cursor = m.lastSelectableIndex() + } + } else { + m.cursor = m.scrollOffset + m.visibleHeight() - 1 + if m.cursor >= len(m.displayItems) { + m.cursor = len(m.displayItems) - 1 + } + if m.displayItems[m.cursor].isHeader { + candidate := m.cursor + 1 + for candidate < len(m.displayItems) && m.displayItems[candidate].isHeader { + candidate++ + } + if candidate < len(m.displayItems) { + m.cursor = candidate + } else { + for m.cursor >= 0 && m.displayItems[m.cursor].isHeader { + m.cursor-- + } + } + } + if m.cursor >= 0 && m.cursor < len(m.displayItems) && m.displayItems[m.cursor].isHeader { + for m.cursor >= 0 && m.displayItems[m.cursor].isHeader { + m.cursor-- + } + } + if m.cursor < 0 { + m.cursor = m.firstSelectableIndex() + } + } + m.ensureCursorVisible() +} + +type changeViewportAnchor struct { + path string + mode git.DiffMode + section string + isHeader bool +} + +func (m *Model) viewportAnchor() changeViewportAnchor { + if len(m.displayItems) == 0 { + return changeViewportAnchor{} + } + start := m.scrollOffset + if start < 0 { + start = 0 + } + end := start + m.visibleHeight() + if end > len(m.displayItems) { + end = len(m.displayItems) + } + if start < len(m.displayItems) && m.displayItems[start].isHeader { + return changeViewportAnchor{ + section: headerSection(m.displayItems[start].header), + isHeader: true, + } + } + for i := start; i < end; i++ { + item := m.displayItems[i] + if item.change != nil { + return changeViewportAnchor{ + path: item.change.Path, + mode: item.mode, + section: itemSection(item), + } + } + } + return changeViewportAnchor{} +} + +func (m *Model) restoreViewportAnchor(anchor changeViewportAnchor) bool { + if anchor.isHeader { + for i, item := range m.displayItems { + if !item.isHeader || headerSection(item.header) != anchor.section { + continue + } + m.scrollOffset = i + m.clampScrollOffset() + return true + } + return false + } + if anchor.path == "" { + return false + } + fallback := -1 + for i, item := range m.displayItems { + if item.change == nil || item.change.Path != anchor.path { + continue + } + if itemSection(item) == anchor.section && fallback == -1 { + fallback = i + } + if item.mode == anchor.mode && itemSection(item) == anchor.section { + m.scrollOffset = i + m.clampScrollOffset() + return true + } + } + if fallback == -1 { + return false + } + m.scrollOffset = fallback + m.clampScrollOffset() + return true +} + +func itemSection(item displayItem) string { + if item.isHeader { + return headerSection(item.header) + } + if item.change != nil && item.change.Kind == git.ChangeUntracked { + return "Untracked" + } + if item.mode == git.DiffModeStaged { + return "Staged" + } + return "Unstaged" +} + +func headerSection(header string) string { + section, _, found := strings.Cut(header, " (") + if found { + return section + } + return header +} + +func (m *Model) firstSelectableIndex() int { + for i := 0; i < len(m.displayItems); i++ { + if !m.displayItems[i].isHeader { + return i + } + } + return 0 +} + +func (m *Model) lastSelectableIndex() int { + for i := len(m.displayItems) - 1; i >= 0; i-- { + if !m.displayItems[i].isHeader { + return i + } + } + return 0 +} + +func (m *Model) advanceCursorBy(delta int) int { + if len(m.displayItems) == 0 { + return 0 + } + if delta == 0 { + if m.cursor < 0 { + return m.firstSelectableIndex() + } + if m.cursor >= len(m.displayItems) { + return m.lastSelectableIndex() + } + return m.cursor + } + + newCursor := m.cursor + direction := 1 + if delta < 0 { + direction = -1 + delta = -delta + } + + for range delta { + candidate := newCursor + direction + for candidate >= 0 && candidate < len(m.displayItems) && m.displayItems[candidate].isHeader { + candidate += direction + } + if candidate < 0 { + return m.firstSelectableIndex() + } + if candidate >= len(m.displayItems) { + return m.lastSelectableIndex() + } + newCursor = candidate + } + + return newCursor +} + +func (m *Model) viewportHasSelectableItem() bool { + if len(m.displayItems) == 0 { + return false + } + start := m.scrollOffset + if start < 0 { + start = 0 + } + end := start + m.visibleHeight() + if end > len(m.displayItems) { + end = len(m.displayItems) + } + for i := start; i < end; i++ { + if !m.displayItems[i].isHeader { + return true + } + } + return false +} + +func (m *Model) scrollPage(delta int) { + if delta == 0 || len(m.displayItems) == 0 { + return + } + visibleHeight := m.visibleHeight() + step := max(1, visibleHeight/2) + anchor := m.cursor + viewportHasSelectableItem := m.viewportHasSelectableItem() + if !m.cursorVisible() { + if delta > 0 { + anchor = m.scrollOffset + for anchor < len(m.displayItems) && m.displayItems[anchor].isHeader { + anchor++ + } + if anchor >= len(m.displayItems) { + anchor = m.lastSelectableIndex() + } + } else { + anchor = m.scrollOffset + visibleHeight - 1 + if anchor >= len(m.displayItems) { + anchor = len(m.displayItems) - 1 + } + for anchor >= 0 && m.displayItems[anchor].isHeader { + anchor-- + } + if anchor < 0 { + anchor = m.firstSelectableIndex() + } + } + } + desiredRow := anchor - m.scrollOffset + if desiredRow < 0 { + desiredRow = 0 + } + if desiredRow >= visibleHeight { + desiredRow = visibleHeight - 1 + } + if !m.cursorVisible() && !viewportHasSelectableItem { + m.cursor = anchor + m.scrollOffset = m.cursor - desiredRow + m.clampScrollOffset() + m.ensureCursorVisible() + return + } + m.cursor = anchor + m.cursor = m.advanceCursorBy(delta * step) + m.scrollOffset = m.cursor - desiredRow + m.clampScrollOffset() + m.ensureCursorVisible() +} diff --git a/internal/ui/sidebar/model_view.go b/internal/ui/sidebar/model_view.go index 870eda2d..d4b7f1ba 100644 --- a/internal/ui/sidebar/model_view.go +++ b/internal/ui/sidebar/model_view.go @@ -101,14 +101,7 @@ func (m *Model) renderChanges() string { b.WriteString("\n") visibleHeight := m.visibleHeight() - - // Adjust scroll - if m.cursor < m.scrollOffset { - m.scrollOffset = m.cursor - } - if m.cursor >= m.scrollOffset+visibleHeight { - m.scrollOffset = m.cursor - visibleHeight + 1 - } + m.clampScrollOffset() for i, item := range m.displayItems { if i < m.scrollOffset { @@ -188,6 +181,8 @@ func (m *Model) helpLines(contentWidth int) []string { items := []string{ m.helpItem("k/↑", "up"), m.helpItem("j/↓", "down"), + m.helpItem("PgUp", "half up"), + m.helpItem("PgDn", "half down"), m.helpItem("/", "filter"), } return common.WrapHelpItems(items, contentWidth) diff --git a/internal/ui/sidebar/project_tree.go b/internal/ui/sidebar/project_tree.go index 27c19b2f..5eedbd23 100644 --- a/internal/ui/sidebar/project_tree.go +++ b/internal/ui/sidebar/project_tree.go @@ -51,7 +51,20 @@ func NewProjectTree() *ProjectTree { // SetShowKeymapHints controls whether helper text is rendered. func (m *ProjectTree) SetShowKeymapHints(show bool) { + if m.showKeymapHints == show { + return + } + oldVisibleHeight := m.visibleHeight() m.showKeymapHints = show + newVisibleHeight := m.visibleHeight() + switch { + case newVisibleHeight < oldVisibleHeight: + if m.focused { + m.ensureCursorVisible() + } + case newVisibleHeight > oldVisibleHeight: + m.clampScrollOffset() + } } // SetStyles updates the component's styles (for theme changes). @@ -72,13 +85,13 @@ func (m *ProjectTree) Update(msg tea.Msg) (*ProjectTree, tea.Cmd) { switch msg := msg.(type) { case tea.MouseWheelMsg: - delta := common.ScrollDeltaForHeight(m.visibleHeight(), 10) + delta := common.ScrollDeltaForHeight(m.visibleHeight(), 8) if msg.Button == tea.MouseWheelUp { - m.moveCursor(-delta) + m.scrollBy(-delta) return m, nil } if msg.Button == tea.MouseWheelDown { - m.moveCursor(delta) + m.scrollBy(delta) return m, nil } @@ -98,9 +111,19 @@ func (m *ProjectTree) Update(msg tea.Msg) (*ProjectTree, tea.Cmd) { m.moveCursor(1) case key.Matches(msg, key.NewBinding(key.WithKeys("k", "up"))): m.moveCursor(-1) + case msg.Key().Code == tea.KeyPgUp: + m.scrollPage(-1) + case msg.Key().Code == tea.KeyPgDown: + m.scrollPage(1) case key.Matches(msg, key.NewBinding(key.WithKeys("enter", "o"))): + if !m.cursorVisible() { + m.reanchorCursorToViewport() + } return m, m.handleEnter() case key.Matches(msg, key.NewBinding(key.WithKeys("l", "right"))): + if !m.cursorVisible() { + m.reanchorCursorToViewport() + } // Expand directory if m.cursor >= 0 && m.cursor < len(m.flatNodes) { node := m.flatNodes[m.cursor] @@ -110,6 +133,9 @@ func (m *ProjectTree) Update(msg tea.Msg) (*ProjectTree, tea.Cmd) { } } case key.Matches(msg, key.NewBinding(key.WithKeys("h", "left"))): + if !m.cursorVisible() { + m.reanchorCursorToViewport() + } // Collapse directory or go to parent if m.cursor >= 0 && m.cursor < len(m.flatNodes) { node := m.flatNodes[m.cursor] @@ -121,6 +147,7 @@ func (m *ProjectTree) Update(msg tea.Msg) (*ProjectTree, tea.Cmd) { for i, n := range m.flatNodes { if n == node.Parent { m.cursor = i + m.ensureCursorVisible() break } } @@ -141,6 +168,9 @@ func (m *ProjectTree) Update(msg tea.Msg) (*ProjectTree, tea.Cmd) { // handleEnter handles enter/click on a node func (m *ProjectTree) handleEnter() tea.Cmd { + if !m.cursorVisible() { + m.reanchorCursorToViewport() + } if m.cursor < 0 || m.cursor >= len(m.flatNodes) { return nil } @@ -257,6 +287,11 @@ func (m *ProjectTree) rebuildFlatList() { // reloadTree reloads the entire tree from disk func (m *ProjectTree) reloadTree() { + wasCursorVisible := m.cursorVisible() + anchorPath := "" + if m.scrollOffset > 0 || !wasCursorVisible { + anchorPath = m.viewportAnchorPath() + } if m.workspace == nil { m.root = nil m.flatNodes = nil @@ -273,6 +308,13 @@ func (m *ProjectTree) reloadTree() { m.expandNode(m.root) m.rebuildFlatList() + if m.restoreViewportAnchor(anchorPath) { + if wasCursorVisible && !m.cursorVisible() { + m.ensureCursorVisible() + } + return + } + m.clampScrollOffset() } func (m *ProjectTree) visibleHeight() int { @@ -304,6 +346,9 @@ func (m *ProjectTree) moveCursor(delta int) { if len(m.flatNodes) == 0 { return } + if !m.cursorVisible() { + m.reanchorCursorToViewport() + } newCursor := m.cursor + delta if newCursor < 0 { @@ -313,17 +358,37 @@ func (m *ProjectTree) moveCursor(delta int) { newCursor = len(m.flatNodes) - 1 } m.cursor = newCursor + m.ensureCursorVisible() } // SetSize sets the project tree size func (m *ProjectTree) SetSize(width, height int) { + if m.width == width && m.height == height { + return + } + oldVisibleHeight := m.visibleHeight() m.width = width m.height = height + newVisibleHeight := m.visibleHeight() + switch { + case newVisibleHeight < oldVisibleHeight: + if m.focused { + m.ensureCursorVisible() + } + case newVisibleHeight > oldVisibleHeight: + m.clampScrollOffset() + } } // Focus sets the focus state func (m *ProjectTree) Focus() { + if m.focused { + return + } m.focused = true + if !m.cursorVisible() { + m.ensureCursorVisible() + } } // Blur removes focus diff --git a/internal/ui/sidebar/project_tree_scroll.go b/internal/ui/sidebar/project_tree_scroll.go new file mode 100644 index 00000000..9afbc967 --- /dev/null +++ b/internal/ui/sidebar/project_tree_scroll.go @@ -0,0 +1,133 @@ +package sidebar + +func (m *ProjectTree) maxScrollOffset() int { + maxOffset := len(m.flatNodes) - m.visibleHeight() + if maxOffset < 0 { + return 0 + } + return maxOffset +} + +func (m *ProjectTree) clampScrollOffset() { + if len(m.flatNodes) == 0 { + m.scrollOffset = 0 + return + } + if m.scrollOffset < 0 { + m.scrollOffset = 0 + } + maxOffset := m.maxScrollOffset() + if m.scrollOffset > maxOffset { + m.scrollOffset = maxOffset + } +} + +func (m *ProjectTree) ensureCursorVisible() { + if len(m.flatNodes) == 0 { + m.cursor = 0 + m.scrollOffset = 0 + return + } + if m.cursor < 0 { + m.cursor = 0 + } + if m.cursor >= len(m.flatNodes) { + m.cursor = len(m.flatNodes) - 1 + } + if m.cursor < m.scrollOffset { + m.scrollOffset = m.cursor + } + visibleHeight := m.visibleHeight() + if m.cursor >= m.scrollOffset+visibleHeight { + m.scrollOffset = m.cursor - visibleHeight + 1 + } + m.clampScrollOffset() +} + +func (m *ProjectTree) cursorVisible() bool { + if len(m.flatNodes) == 0 { + return true + } + if m.cursor < 0 || m.cursor >= len(m.flatNodes) { + return false + } + visibleHeight := m.visibleHeight() + return m.cursor >= m.scrollOffset && m.cursor < m.scrollOffset+visibleHeight +} + +func (m *ProjectTree) scrollBy(delta int) { + if delta == 0 { + return + } + m.scrollOffset += delta + m.clampScrollOffset() +} + +func (m *ProjectTree) reanchorCursorToViewport() { + if len(m.flatNodes) == 0 || m.cursorVisible() { + return + } + if m.cursor < m.scrollOffset { + m.cursor = m.scrollOffset + return + } + m.cursor = m.scrollOffset + m.visibleHeight() - 1 + if m.cursor >= len(m.flatNodes) { + m.cursor = len(m.flatNodes) - 1 + } +} + +func (m *ProjectTree) viewportAnchorPath() string { + if len(m.flatNodes) == 0 { + return "" + } + if m.scrollOffset < 0 || m.scrollOffset >= len(m.flatNodes) { + return "" + } + return m.flatNodes[m.scrollOffset].Path +} + +func (m *ProjectTree) restoreViewportAnchor(path string) bool { + if path == "" { + return false + } + for i, node := range m.flatNodes { + if node.Path != path { + continue + } + m.scrollOffset = i + m.clampScrollOffset() + return true + } + return false +} + +func (m *ProjectTree) scrollPage(delta int) { + if delta == 0 || len(m.flatNodes) == 0 { + return + } + visibleHeight := m.visibleHeight() + step := max(1, visibleHeight/2) + anchor := m.cursor + if !m.cursorVisible() { + if delta > 0 { + anchor = m.scrollOffset + } else { + anchor = m.scrollOffset + visibleHeight - 1 + if anchor >= len(m.flatNodes) { + anchor = len(m.flatNodes) - 1 + } + } + } + desiredRow := anchor - m.scrollOffset + if desiredRow < 0 { + desiredRow = 0 + } + if desiredRow >= visibleHeight { + desiredRow = visibleHeight - 1 + } + m.cursor = anchor + delta*step + m.scrollOffset = m.cursor - desiredRow + m.clampScrollOffset() + m.ensureCursorVisible() +} diff --git a/internal/ui/sidebar/project_tree_view.go b/internal/ui/sidebar/project_tree_view.go index d138654a..1430db4d 100644 --- a/internal/ui/sidebar/project_tree_view.go +++ b/internal/ui/sidebar/project_tree_view.go @@ -20,14 +20,7 @@ func (m *ProjectTree) View() string { var b strings.Builder visibleHeight := m.visibleHeight() - - // Adjust scroll - if m.cursor < m.scrollOffset { - m.scrollOffset = m.cursor - } - if m.cursor >= m.scrollOffset+visibleHeight { - m.scrollOffset = m.cursor - visibleHeight + 1 - } + m.clampScrollOffset() for i, node := range m.flatNodes { if i < m.scrollOffset { @@ -144,6 +137,8 @@ func (m *ProjectTree) helpLines(contentWidth int) []string { items := []string{ m.helpItem("k/↑", "up"), m.helpItem("j/↓", "down"), + m.helpItem("PgUp", "half up"), + m.helpItem("PgDn", "half down"), m.helpItem("h/←", "collapse"), m.helpItem("l/→", "expand"), m.helpItem(".", "hidden"), diff --git a/internal/ui/sidebar/rebuild_display_list_test.go b/internal/ui/sidebar/rebuild_display_list_test.go index 0fda829f..f66a0efe 100644 --- a/internal/ui/sidebar/rebuild_display_list_test.go +++ b/internal/ui/sidebar/rebuild_display_list_test.go @@ -90,3 +90,59 @@ func TestRebuildDisplayListUnstagedOnlyShowsUnstagedSection(t *testing.T) { t.Errorf("expected Unstaged (1) header, got %+v", m.displayItems[0]) } } + +func TestRebuildDisplayListKeepsVisibleSelectionOnScreenAfterHeaderInsert(t *testing.T) { + m := New() + m.SetSize(80, 10) + + m.SetGitStatus(&git.StatusResult{ + Clean: false, + Staged: []git.Change{ + {Path: "staged.go", Kind: git.ChangeModified, Staged: true}, + }, + Unstaged: []git.Change{ + {Path: "u1.go", Kind: git.ChangeModified}, + {Path: "u2.go", Kind: git.ChangeModified}, + {Path: "u3.go", Kind: git.ChangeModified}, + {Path: "u4.go", Kind: git.ChangeModified}, + {Path: "u5.go", Kind: git.ChangeModified}, + {Path: "u6.go", Kind: git.ChangeModified}, + {Path: "u7.go", Kind: git.ChangeModified}, + }, + }) + m.cursor = 8 + m.scrollOffset = 0 + + if !m.cursorVisible() { + t.Fatalf("expected cursor to start visible, cursor=%d scrollOffset=%d visibleHeight=%d", + m.cursor, m.scrollOffset, m.visibleHeight()) + } + + m.SetGitStatus(&git.StatusResult{ + Clean: false, + Staged: []git.Change{ + {Path: "staged.go", Kind: git.ChangeModified, Staged: true}, + }, + Unstaged: []git.Change{ + {Path: "u1.go", Kind: git.ChangeModified}, + {Path: "u2.go", Kind: git.ChangeModified}, + {Path: "u3.go", Kind: git.ChangeModified}, + {Path: "u4.go", Kind: git.ChangeModified}, + {Path: "u5.go", Kind: git.ChangeModified}, + }, + Untracked: []git.Change{ + {Path: "new.go", Kind: git.ChangeUntracked}, + }, + }) + + if m.cursor != 9 { + t.Fatalf("cursor = %d, want 9", m.cursor) + } + if !m.cursorVisible() { + t.Fatalf("expected cursor to remain visible after rebuild, cursor=%d scrollOffset=%d visibleHeight=%d", + m.cursor, m.scrollOffset, m.visibleHeight()) + } + if m.scrollOffset != 1 { + t.Fatalf("scrollOffset = %d, want 1", m.scrollOffset) + } +} diff --git a/internal/ui/sidebar/workspace_rebind_test.go b/internal/ui/sidebar/workspace_rebind_test.go index 546b626c..1b737f9f 100644 --- a/internal/ui/sidebar/workspace_rebind_test.go +++ b/internal/ui/sidebar/workspace_rebind_test.go @@ -1,12 +1,14 @@ package sidebar import ( + "fmt" "os" "path/filepath" "strings" "testing" "github.com/andyrewlee/amux/internal/data" + "github.com/andyrewlee/amux/internal/git" ) func TestChangesSetWorkspaceSameIDPreservesState(t *testing.T) { @@ -138,6 +140,49 @@ func TestChangesSetWorkspaceCanonicalMatchDifferentIDPreservesState(t *testing.T } } +func TestChangesSetWorkspaceSameIDKeepsCursorVisibleWhenHeaderGrows(t *testing.T) { + model := New() + model.SetSize(80, 10) + model.Focus() + + ws1 := data.NewWorkspace("feature", "", "main", "/tmp/repo", "/tmp/workspaces/repo/feature") + ws2 := data.NewWorkspace("feature", "updated-branch", "main", "/tmp/repo", "/tmp/workspaces/repo/feature") + + model.SetWorkspace(ws1) + + 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, + }) + } + model.SetGitStatus(&git.StatusResult{ + Clean: false, + Unstaged: unstaged, + }) + model.cursor = 8 + model.scrollOffset = 0 + + if !changesCursorVisible(model) { + t.Fatalf("expected cursor to start visible, cursor=%d scrollOffset=%d visibleHeight=%d", + model.cursor, model.scrollOffset, model.visibleHeight()) + } + + model.SetWorkspace(ws2) + + if model.workspace != ws2 { + t.Fatal("expected workspace pointer to be rebound") + } + if !changesCursorVisible(model) { + t.Fatalf("expected cursor to remain visible after header growth, cursor=%d scrollOffset=%d visibleHeight=%d", + model.cursor, model.scrollOffset, model.visibleHeight()) + } + if model.scrollOffset != 1 { + t.Fatalf("scrollOffset = %d, want 1", model.scrollOffset) + } +} + func TestProjectTreeSetWorkspaceCanonicalMatchDifferentIDPreservesState(t *testing.T) { wd, err := os.Getwd() if err != nil {