diff --git a/internal/ui/model.go b/internal/ui/model.go index 0c18bd6..363dc17 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -98,10 +98,12 @@ type Model struct { lastDiffContent string // Branch picker state - branches []string - branchCursor int - branchOffset int - currentBranch string + branches []string + filteredBranches []string // nil = show all + branchCursor int + branchOffset int + currentBranch string + branchFilter textinput.Model // Push/pull state upstream git.UpstreamInfo @@ -130,17 +132,23 @@ func NewModel( ti.Placeholder = "commit message..." ti.CharLimit = 200 + bf := textinput.New() + bf.Placeholder = "filter..." + bf.CharLimit = 100 + bf.Width = fileListWidth - 8 + return Model{ - repo: repo, - cfg: cfg, - files: files, - styles: styles, - theme: t, - stagedOnly: stagedOnly, - ref: ref, - splitDiff: cfg.SplitDiff, - prevCurs: -1, - commitInput: ti, + repo: repo, + cfg: cfg, + files: files, + styles: styles, + theme: t, + stagedOnly: stagedOnly, + ref: ref, + splitDiff: cfg.SplitDiff, + prevCurs: -1, + commitInput: ti, + branchFilter: bf, } } @@ -311,6 +319,27 @@ func (m Model) handleCommitMsgGenerated(msg commitMsgGeneratedMsg) (tea.Model, t return m, nil } +func (m Model) activeBranches() []string { + if m.filteredBranches != nil { + return m.filteredBranches + } + return m.branches +} + +func filterBranches(branches []string, query string) []string { + if query == "" { + return nil + } + q := strings.ToLower(query) + out := []string{} + for _, b := range branches { + if strings.Contains(strings.ToLower(b), q) { + out = append(out, b) + } + } + return out +} + func (m Model) enterBranchMode() (tea.Model, tea.Cmd) { repo := m.repo return m, func() tea.Msg { @@ -343,11 +372,17 @@ func (m Model) handleBranchesLoaded(msg branchesLoadedMsg) (tea.Model, tea.Cmd) break } } - return m, nil + m.filteredBranches = nil + m.branchFilter.Reset() + m.branchFilter.Focus() + return m, textinput.Blink } func (m Model) handleBranchSwitched(msg branchSwitchedMsg) (tea.Model, tea.Cmd) { m.mode = modeFileList + m.filteredBranches = nil + m.branchFilter.Reset() + m.branchFilter.Blur() if msg.err != nil { m.statusMsg = "switch failed: " + msg.err.Error() return m, nil @@ -517,29 +552,39 @@ func (m Model) updateDiffMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Branch picker mode func (m Model) updateBranchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { - case "esc", "b": + case "esc": + if m.branchFilter.Value() != "" { + m.branchFilter.Reset() + m.filteredBranches = nil + m.branchCursor = 0 + m.branchOffset = 0 + return m, nil + } m.mode = modeFileList + m.branchFilter.Blur() return m, nil - case "q", "ctrl+c": + case "ctrl+c": return m, tea.Quit - case "j", "down": - if m.branchCursor < len(m.branches)-1 { - m.branchCursor++ - } - case "k", "up": + case "up", "ctrl+k": if m.branchCursor > 0 { m.branchCursor-- } - case "g": - m.branchCursor = 0 - m.branchOffset = 0 - case "G": - m.branchCursor = max(0, len(m.branches)-1) + m = m.clampBranchScroll() + return m, nil + case "down", "ctrl+j": + list := m.activeBranches() + if m.branchCursor < len(list)-1 { + m.branchCursor++ + } + m = m.clampBranchScroll() + return m, nil case "enter": - if m.branchCursor >= len(m.branches) { + list := m.activeBranches() + if m.branchCursor >= len(list) || len(list) == 0 { return m, nil } - selected := m.branches[m.branchCursor] + selected := list[m.branchCursor] + m.branchFilter.Blur() if selected == m.currentBranch { m.mode = modeFileList return m, nil @@ -549,16 +594,30 @@ func (m Model) updateBranchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return branchSwitchedMsg{err: repo.CheckoutBranch(selected)} } } - // Keep cursor in visible window - h := m.contentHeight() - if h > 0 { - if m.branchCursor < m.branchOffset { - m.branchOffset = m.branchCursor - } else if m.branchCursor >= m.branchOffset+h { - m.branchOffset = m.branchCursor - h + 1 - } + + // All other keys go to the filter input + prevVal := m.branchFilter.Value() + var cmd tea.Cmd + m.branchFilter, cmd = m.branchFilter.Update(msg) + if m.branchFilter.Value() != prevVal { + m.filteredBranches = filterBranches(m.branches, m.branchFilter.Value()) + m.branchCursor = 0 + m.branchOffset = 0 } - return m, nil + return m, cmd +} + +func (m Model) clampBranchScroll() Model { + h := m.contentHeight() - 1 // -1 for filter bar + if h <= 0 { + return m + } + if m.branchCursor < m.branchOffset { + m.branchOffset = m.branchCursor + } else if m.branchCursor >= m.branchOffset+h { + m.branchOffset = m.branchCursor - h + 1 + } + return m } // Commit mode @@ -910,12 +969,26 @@ func (m Model) renderFileItem(f fileItem, selected bool) string { func (m Model) renderBranchList(height int) string { var b strings.Builder - end := m.branchOffset + height - if end > len(m.branches) { - end = len(m.branches) + + // Filter bar (row 0) + b.WriteString(m.renderBranchFilterBar()) + b.WriteByte('\n') + + list := m.activeBranches() + itemH := height - 1 // reserve 1 row for filter bar + + if len(list) == 0 { + noMatch := m.styles.HelpDesc.Render(" no matches") + b.WriteString(m.styles.FileItem.Width(fileListWidth).Render(noMatch)) + return b.String() + } + + end := m.branchOffset + itemH + if end > len(list) { + end = len(list) } for i := m.branchOffset; i < end; i++ { - branch := m.branches[i] + branch := list[i] b.WriteString(m.renderBranchItem(branch, i == m.branchCursor, branch == m.currentBranch)) if i < end-1 { b.WriteByte('\n') @@ -924,6 +997,23 @@ func (m Model) renderBranchList(height int) string { return b.String() } +func (m Model) renderBranchFilterBar() string { + list := m.activeBranches() + count := fmt.Sprintf("%d/%d", len(list), len(m.branches)) + countStyled := m.styles.HelpDesc.Render(count) + countW := lipgloss.Width(countStyled) + + input := m.branchFilter.View() + inputW := lipgloss.Width(input) + + gap := fileListWidth - inputW - countW - 1 + if gap < 0 { + gap = 0 + } + line := input + strings.Repeat(" ", gap) + countStyled + return lipgloss.NewStyle().Width(fileListWidth).Render(line) +} + func (m Model) renderBranchItem(name string, selected, current bool) string { prefix := " " if current { @@ -1003,10 +1093,10 @@ func (m Model) renderHelpBar() string { } case modeBranchPicker: pairs = []struct{ key, desc string }{ - {"j/k", "navigate"}, + {"type", "filter"}, + {"↑/↓/^j/^k", "navigate"}, {"enter", "switch"}, - {"esc", "cancel"}, - {"q", "quit"}, + {"esc", "clear/close"}, } default: pairs = []struct{ key, desc string }{ diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 421d219..24f17bb 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/jansmrcka/differ/internal/config" "github.com/jansmrcka/differ/internal/git" @@ -134,13 +135,19 @@ func TestDiffWidth(t *testing.T) { func newTestModel(t *testing.T, files []fileItem) Model { t.Helper() th := theme.Themes["dark"] + bf := textinput.New() + bf.Placeholder = "filter..." + bf.CharLimit = 100 + bf.Width = fileListWidth - 8 return Model{ - files: files, - styles: NewStyles(th), - theme: th, - cfg: config.Default(), - width: 120, - height: 30, + files: files, + styles: NewStyles(th), + theme: th, + cfg: config.Default(), + width: 120, + height: 30, + commitInput: textinput.New(), + branchFilter: bf, } } @@ -206,7 +213,7 @@ func TestRenderHelpBar_BranchMode(t *testing.T) { m := newTestModel(t, nil) m.mode = modeBranchPicker bar := m.renderHelpBar() - for _, key := range []string{"j/k", "enter", "esc", "q"} { + for _, key := range []string{"↑/↓/^j/^k", "enter", "esc", "filter"} { if !strings.Contains(bar, key) { t.Errorf("branch help should contain %q", key) } @@ -276,16 +283,16 @@ func TestUpdateBranchMode_Navigation(t *testing.T) { m.branches = []string{"main", "dev", "feature"} m.branchCursor = 0 - result, _ := m.updateBranchMode(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + result, _ := m.updateBranchMode(tea.KeyMsg{Type: tea.KeyDown}) rm := result.(Model) if rm.branchCursor != 1 { - t.Errorf("cursor=%d after j, want 1", rm.branchCursor) + t.Errorf("cursor=%d after down, want 1", rm.branchCursor) } - result, _ = rm.updateBranchMode(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + result, _ = rm.updateBranchMode(tea.KeyMsg{Type: tea.KeyUp}) rm = result.(Model) if rm.branchCursor != 0 { - t.Errorf("cursor=%d after k, want 0", rm.branchCursor) + t.Errorf("cursor=%d after up, want 0", rm.branchCursor) } } @@ -367,14 +374,14 @@ func TestBranchListScroll(t *testing.T) { t.Parallel() m := newTestModel(t, nil) m.mode = modeBranchPicker - // height=30, contentHeight=27. Put cursor beyond visible area. + // height=30, contentHeight=27, itemH=26 (minus filter bar). branches := make([]string, 40) for i := range branches { branches[i] = fmt.Sprintf("branch-%02d", i) } m.branches = branches m.branchCursor = 35 - m.branchOffset = 35 - 27 + 1 // 9 + m.branchOffset = 35 - 26 + 1 // 10 out := m.renderBranchList(m.contentHeight()) if !strings.Contains(out, "branch-35") { @@ -384,3 +391,166 @@ func TestBranchListScroll(t *testing.T) { t.Error("branch list should not show first branch when scrolled down") } } + +func TestFilterBranches(t *testing.T) { + t.Parallel() + branches := []string{"main", "feature-auth", "feature-ui", "bugfix-login", "dev"} + + t.Run("empty query returns nil", func(t *testing.T) { + t.Parallel() + if got := filterBranches(branches, ""); got != nil { + t.Errorf("expected nil, got %v", got) + } + }) + t.Run("substring match", func(t *testing.T) { + t.Parallel() + got := filterBranches(branches, "feature") + if len(got) != 2 { + t.Fatalf("expected 2 matches, got %d: %v", len(got), got) + } + }) + t.Run("case insensitive", func(t *testing.T) { + t.Parallel() + got := filterBranches(branches, "FEATURE") + if len(got) != 2 { + t.Fatalf("expected 2 matches, got %d: %v", len(got), got) + } + }) + t.Run("no match", func(t *testing.T) { + t.Parallel() + got := filterBranches(branches, "zzz") + if len(got) != 0 { + t.Fatalf("expected 0 matches, got %d: %v", len(got), got) + } + }) +} + +func TestUpdateBranchMode_TypeFilters(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + m.mode = modeBranchPicker + m.branches = []string{"main", "feature-auth", "feature-ui", "dev"} + m.branchFilter.Focus() + + // Type 'f' — should filter to feature branches + result, _ := m.updateBranchMode(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'f'}}) + rm := result.(Model) + if rm.filteredBranches == nil { + t.Fatal("filteredBranches should not be nil after typing") + } + if len(rm.filteredBranches) != 2 { + t.Errorf("expected 2 filtered branches, got %d", len(rm.filteredBranches)) + } + if rm.branchCursor != 0 { + t.Errorf("cursor should reset to 0, got %d", rm.branchCursor) + } +} + +func TestUpdateBranchMode_EscClearsFilter(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + m.mode = modeBranchPicker + m.branches = []string{"main", "feature-auth", "dev"} + m.branchFilter.Focus() + m.branchFilter.SetValue("feat") + m.filteredBranches = filterBranches(m.branches, "feat") + + result, _ := m.updateBranchMode(tea.KeyMsg{Type: tea.KeyEscape}) + rm := result.(Model) + // First esc clears filter, stays in branch picker + if rm.mode != modeBranchPicker { + t.Errorf("mode=%d, want modeBranchPicker", rm.mode) + } + if rm.branchFilter.Value() != "" { + t.Errorf("filter should be cleared, got %q", rm.branchFilter.Value()) + } + if rm.filteredBranches != nil { + t.Error("filteredBranches should be nil after clearing") + } +} + +func TestUpdateBranchMode_EscClosesWhenEmpty(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + m.mode = modeBranchPicker + m.branches = []string{"main"} + m.branchFilter.Focus() + // Filter is empty — esc should close + result, _ := m.updateBranchMode(tea.KeyMsg{Type: tea.KeyEscape}) + rm := result.(Model) + if rm.mode != modeFileList { + t.Errorf("mode=%d, want modeFileList", rm.mode) + } +} + +func TestUpdateBranchMode_ArrowsInFilteredList(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + m.mode = modeBranchPicker + m.branches = []string{"main", "feature-auth", "feature-ui", "dev"} + m.filteredBranches = []string{"feature-auth", "feature-ui"} + m.branchCursor = 0 + + result, _ := m.updateBranchMode(tea.KeyMsg{Type: tea.KeyDown}) + rm := result.(Model) + if rm.branchCursor != 1 { + t.Errorf("cursor=%d after down, want 1", rm.branchCursor) + } + // Should not go past end of filtered list + result, _ = rm.updateBranchMode(tea.KeyMsg{Type: tea.KeyDown}) + rm = result.(Model) + if rm.branchCursor != 1 { + t.Errorf("cursor=%d, should not exceed filtered list", rm.branchCursor) + } +} + +func TestUpdateBranchMode_CtrlJK(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + m.mode = modeBranchPicker + m.branches = []string{"main", "dev", "feature"} + m.branchCursor = 0 + + // ctrl+j moves down + result, _ := m.updateBranchMode(tea.KeyMsg{Type: tea.KeyCtrlJ}) + rm := result.(Model) + if rm.branchCursor != 1 { + t.Errorf("cursor=%d after ctrl+j, want 1", rm.branchCursor) + } + + // ctrl+k moves up + result, _ = rm.updateBranchMode(tea.KeyMsg{Type: tea.KeyCtrlK}) + rm = result.(Model) + if rm.branchCursor != 0 { + t.Errorf("cursor=%d after ctrl+k, want 0", rm.branchCursor) + } +} + +func TestRenderBranchList_ShowsFilterBar(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + m.mode = modeBranchPicker + m.branches = []string{"main", "dev"} + m.branchCursor = 0 + out := m.renderBranchList(10) + // Should contain the match count + if !strings.Contains(out, "2/2") { + t.Error("branch list should show match count") + } +} + +func TestRenderBranchList_NoMatches(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + m.mode = modeBranchPicker + m.branches = []string{"main", "dev"} + m.filteredBranches = []string{} // empty filter result + m.branchFilter.SetValue("zzz") + out := m.renderBranchList(10) + if !strings.Contains(out, "no matches") { + t.Error("should show 'no matches' placeholder") + } + if !strings.Contains(out, "0/2") { + t.Error("should show 0/2 count") + } +}