From f6460419c333e7271847b9fac27a5b124233921f Mon Sep 17 00:00:00 2001 From: zaidejjo Date: Fri, 16 Oct 2026 17:13:15 +0300 Subject: [PATCH] feat(tui): P2 lists converge, toasts, fuzzy palette, tab counts - dash/actions/notes embed ZList (Match/Render kept local); handlers use TypeKey/Nav; dead filterKeys removed - components.Toasts: capped expiring stack above status bar, wired to repo switch + inbox clear - palette: scored fuzzy ranking over label/id/description - header: loaded counts on PRs/Issues/Repos/Actions tabs - tests: toasts cap/expiry, palette ranking --- pkg/tui/app.go | 192 ++++++++++++++----------------- pkg/tui/components/toasts.go | 82 +++++++++++++ pkg/tui/components/zlist_test.go | 27 +++++ pkg/tui/views/actions.go | 144 ++++++++--------------- pkg/tui/views/dashboard.go | 177 +++++++++++----------------- pkg/tui/views/newviews_test.go | 24 ++++ pkg/tui/views/notifications.go | 70 +++-------- pkg/tui/views/palette.go | 62 ++++++++-- 8 files changed, 398 insertions(+), 380 deletions(-) create mode 100644 pkg/tui/components/toasts.go diff --git a/pkg/tui/app.go b/pkg/tui/app.go index 7887d14..76ab4e0 100644 --- a/pkg/tui/app.go +++ b/pkg/tui/app.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "strings" + "time" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/viewport" @@ -86,6 +87,9 @@ type Model struct { help help.Model viewport viewport.Model + // Transient notifications + toasts components.Toasts + // Terminal dimensions width int height int @@ -130,6 +134,7 @@ func NewModel(gitExec *gitpkg.NativeExec) *Model { prCreateDlg: views.NewPRCreateModel(), prMergeDlg: views.NewPRMergeModel(), help: help.New(), + toasts: components.NewToasts(4 * time.Second), mode: modeNormal, focusedPanel: PanelStatus, fullScreenView: shell.Panels, @@ -368,6 +373,15 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Reinstall listener for next subscriber message return updated, tea.Batch(cmd, listenForMessages(m.msgs)) + case toastTickMsg: + if m.toasts.Expire() { + return m, func() tea.Msg { + time.Sleep(4 * time.Second) + return toastTickMsg{} + } + } + return m, nil + default: return m, nil } @@ -389,8 +403,11 @@ func (m *Model) View() string { content := m.renderHeader() + "\n" + m.renderContent() statusBar := m.renderStatusBar() fullLayout := content + if toastBlock := m.toasts.View(); toastBlock != "" { + fullLayout = fmt.Sprintf("%s\n%s", fullLayout, toastBlock) + } if statusBar != "" { - fullLayout = fmt.Sprintf("%s\n%s", content, statusBar) + fullLayout = fmt.Sprintf("%s\n%s", fullLayout, statusBar) } // Pad fullLayout to exactly m.height lines so overlay centering is pixel-perfect @@ -460,6 +477,26 @@ func (m *Model) renderHeader() string { if compact { label = t.Short() } + if !compact { + switch t.ID { + case shell.PRs: + if m.prs.Items != nil { + label = fmt.Sprintf("%s (%d)", label, len(m.prs.Items)) + } + case shell.Issues: + if m.issues.Items != nil { + label = fmt.Sprintf("%s (%d)", label, len(m.issues.Items)) + } + case shell.Dashboard: + if m.dash.Loaded { + label = fmt.Sprintf("%s (%d)", label, len(m.dash.Items)) + } + case shell.Actions: + if m.actions.Items != nil { + label = fmt.Sprintf("%s (%d)", label, len(m.actions.Items)) + } + } + } if t.ID == shell.Notes && m.notes.Loaded && m.notes.UnreadCount() > 0 { label = fmt.Sprintf("%s (%d)", label, m.notes.UnreadCount()) } @@ -782,7 +819,7 @@ func (m *Model) ensureGHDataLoaded() { go m.fetchIssues() } case shell.Actions: // Actions - if m.actions.Runs == nil { + if m.actions.Items == nil { go m.fetchActions() } case shell.Notes: // Notifications @@ -928,14 +965,12 @@ func quickRepoStatus(path string) (*models.Status, error) { } // switchRepo repoints the TUI at another local repo. -func (m *Model) switchRepo(path string) { +func (m *Model) switchRepo(path string) tea.Cmd { if m.git == nil { - m.dash.Error = "no git backend" - return + return m.pushToast("no git backend", components.ToneError) } if err := m.git.Open(path); err != nil { - m.dash.Error = fmt.Sprintf("open %s: %v", path, err) - return + return m.pushToast(fmt.Sprintf("open failed: %v", err), components.ToneError) } if cfg, err := config.New(); err == nil { cfg.AddRecentRepo(path) @@ -945,12 +980,14 @@ func (m *Model) switchRepo(path string) { m.issues = views.NewIssueListModel() m.actions = views.NewActionsModel() m.notes = views.NewNotesModel() + m.dash.Error = "" m.ghOwner, m.ghRepo, m.ghDetected = "", "", false m.tryDetectRepo() m.sub.Refresh() m.fullScreenView = shell.Panels m.focusedPanel = PanelStatus go m.fetchDashboard() + return m.pushToast(fmt.Sprintf("Switched to %s", baseName(path)), components.ToneSuccess) } // fetchActions loads workflow runs for the detected repo. @@ -1378,150 +1415,82 @@ func (m *Model) handleIssueKeys(key string) (tea.Model, tea.Cmd) { return m, nil } -// filterKeys routes typing to a filterable list model. Returns true when -// the key was consumed by filter input. -func filterKeys(key string, filtering *bool, filter *string, clamp func()) bool { - if *filtering { - switch key { - case "esc", "enter": - *filtering = false - if key == "esc" { - *filter = "" - } - clamp() - return true - case "backspace": - if len(*filter) > 0 { - *filter = (*filter)[:len(*filter)-1] - clamp() - } - return true - default: - if len(key) == 1 || key == " " { - *filter += key - clamp() - } - return true - } - } - return false -} - func (m *Model) handleDashKeys(key string) (tea.Model, tea.Cmd) { - if filterKeys(key, &m.dash.Filtering, &m.dash.Filter, m.dash.ClampCursor) { + if m.dash.TypeKey(key) { return m, nil } switch key { case "/": m.dash.Filtering = true return m, nil - case "j", "down": - if len(m.dash.Filtered()) > 0 && m.dash.Cursor < len(m.dash.Filtered())-1 { - m.dash.Cursor++ - } - case "k", "up": - if m.dash.Cursor > 0 { - m.dash.Cursor-- - } - case "g", "home": - m.dash.Cursor = 0 - case "G", "end": - m.dash.Cursor = len(m.dash.Filtered()) - 1 - if m.dash.Cursor < 0 { - m.dash.Cursor = 0 - } case "enter": if sel := m.dash.Selected(); sel != nil { - m.switchRepo(sel.Path) + return m, m.switchRepo(sel.Path) } case "r": m.dash.Loaded = false go m.fetchDashboard() + default: + m.dash.Nav(key) } return m, nil } func (m *Model) handleActionsKeys(key string) (tea.Model, tea.Cmd) { - // In job drill-in, backspace/esc returns to runs. - if m.actions.JobsFor != 0 && (key == "backspace" || key == "esc") { - m.actions.Jobs = nil - m.actions.JobsFor = 0 - m.actions.Cursor = 0 - m.actions.Offset = 0 + // Job drill-in has its own navigation. + if m.actions.JobsFor != 0 { + switch key { + case "backspace", "esc": + m.actions.Jobs = nil + m.actions.JobsFor = 0 + m.actions.Cursor = 0 + m.actions.Offset = 0 + case "j", "down": + if len(m.actions.Jobs) > 0 && m.actions.Cursor < len(m.actions.Jobs)-1 { + m.actions.Cursor++ + } + case "k", "up": + if m.actions.Cursor > 0 { + m.actions.Cursor-- + } + } return m, nil } - // Filter input captures all typing (runs list only). - if m.actions.JobsFor == 0 && m.actions.Filtering { - filterKeys(key, &m.actions.Filtering, &m.actions.Filter, m.actions.ClampCursor) + if m.actions.TypeKey(key) { return m, nil } - n := len(m.actions.Filtered()) - if m.actions.JobsFor != 0 { - n = len(m.actions.Jobs) - } switch key { case "/": - if m.actions.JobsFor == 0 { - m.actions.Filtering = true - } + m.actions.Filtering = true return m, nil - case "j", "down": - if n > 0 && m.actions.Cursor < n-1 { - m.actions.Cursor++ - } - case "k", "up": - if m.actions.Cursor > 0 { - m.actions.Cursor-- - } - case "g", "home": - m.actions.Cursor = 0 - case "G", "end": - m.actions.Cursor = n - 1 - if m.actions.Cursor < 0 { - m.actions.Cursor = 0 - } case "enter": - if m.actions.JobsFor == 0 { - if run := m.actions.SelectedRun(); run != nil { - go m.fetchActionJobs(run.ID) - } + if run := m.actions.SelectedRun(); run != nil { + go m.fetchActionJobs(run.ID) } case "r": - m.actions.Runs = nil + m.actions.Items = nil go m.fetchActions() + default: + m.actions.Nav(key) } return m, nil } func (m *Model) handleNotesKeys(key string) (tea.Model, tea.Cmd) { - if filterKeys(key, &m.notes.Filtering, &m.notes.Filter, m.notes.ClampCursor) { + if m.notes.TypeKey(key) { return m, nil } switch key { case "/": m.notes.Filtering = true return m, nil - case "j", "down": - if len(m.notes.Ordered()) > 0 && m.notes.Cursor < len(m.notes.Ordered())-1 { - m.notes.Cursor++ - } - case "k", "up": - if m.notes.Cursor > 0 { - m.notes.Cursor-- - } - case "g", "home": - m.notes.Cursor = 0 - case "G", "end": - m.notes.Cursor = len(m.notes.Ordered()) - 1 - if m.notes.Cursor < 0 { - m.notes.Cursor = 0 - } case "x": if sel := m.notes.Selected(); sel != nil && sel.Unread { m.markNoteRead(sel.ID) } case "X": m.markAllNotesRead() + return m, m.pushToast("Inbox cleared", components.ToneSuccess) case "u": // Toggle unread/all (server-side) and reload. m.notes.ShowAll = !m.notes.ShowAll @@ -1530,6 +1499,8 @@ func (m *Model) handleNotesKeys(key string) (tea.Model, tea.Cmd) { case "r": m.notes.Loaded = false go m.fetchNotes() + default: + m.notes.Nav(key) } return m, nil } @@ -1896,6 +1867,17 @@ type notesEvent struct { err error } +// toastTickMsg fires when the oldest toast may have expired. +type toastTickMsg struct{} + +// pushToast shows a transient notification for ~4s. +func (m *Model) pushToast(text string, tone components.Tone) tea.Cmd { + m.toasts.Push(text, tone) + return tea.Tick(4*time.Second, func(time.Time) tea.Msg { + return toastTickMsg{} + }) +} + type prCreateResultEvent struct { Result views.PRCreateResultInfo } diff --git a/pkg/tui/components/toasts.go b/pkg/tui/components/toasts.go new file mode 100644 index 0000000..a9c1ede --- /dev/null +++ b/pkg/tui/components/toasts.go @@ -0,0 +1,82 @@ +package components + +import ( + "strings" + "time" + + "github.com/zaidejjo/zgit/pkg/tui/styles" +) + +// Tone selects the toast accent. +type Tone int + +const ( + ToneInfo Tone = iota + ToneSuccess + ToneError +) + +// Toast is one transient notification. +type Toast struct { + Text string + Tone Tone + Expires time.Time +} + +// Toasts is a small expiring stack (cap 3, oldest dropped first). +type Toasts struct { + items []Toast + ttl time.Duration +} + +// NewToasts creates a stack with the given per-toast lifetime. +func NewToasts(ttl time.Duration) Toasts { + return Toasts{ttl: ttl} +} + +// Push adds a toast, dropping the oldest past the cap. +func (t *Toasts) Push(text string, tone Tone) { + t.items = append(t.items, Toast{Text: text, Tone: tone, Expires: time.Now().Add(t.ttl)}) + for len(t.items) > 3 { + t.items = t.items[1:] + } +} + +// Expire drops elapsed toasts. True when anything remains. +func (t *Toasts) Expire() bool { + now := time.Now() + kept := t.items[:0] + for _, it := range t.items { + if it.Expires.After(now) { + kept = append(kept, it) + } + } + t.items = kept + return len(t.items) > 0 +} + +// Empty reports no visible toasts. +func (t *Toasts) Empty() bool { + return len(t.items) == 0 +} + +// View renders the stack as plain lines. The shell prints them above +// the status bar (brief reflow while visible — safer than splicing +// into ANSI-styled frame lines). +func (t *Toasts) View() string { + var b strings.Builder + for _, it := range t.items { + mark := "◌" + style := styles.SubtitleStyle + switch it.Tone { + case ToneSuccess: + mark = "✓" + style = styles.StatusStagedStyle + case ToneError: + mark = "✗" + style = styles.ErrorStyle + } + b.WriteString(style.Render(mark+" "+it.Text) + "\n") + } + return strings.TrimRight(b.String(), "\n") +} diff --git a/pkg/tui/components/zlist_test.go b/pkg/tui/components/zlist_test.go index 24ddea5..59f30b0 100644 --- a/pkg/tui/components/zlist_test.go +++ b/pkg/tui/components/zlist_test.go @@ -3,6 +3,7 @@ package components import ( "strings" "testing" + "time" ) func testList() ZList[string] { @@ -104,3 +105,29 @@ func TestCursorLine(t *testing.T) { t.Fatalf("empty") } } + +func TestToastsPushExpire(t *testing.T) { + tt := NewToasts(time.Hour) + if !tt.Empty() { + t.Fatalf("fresh stack should be empty") + } + tt.Push("a", ToneInfo) + tt.Push("b", ToneSuccess) + tt.Push("c", ToneError) + tt.Push("d", ToneInfo) // cap 3 drops "a" + if got := tt.View(); strings.Contains(got, "\na\n") || strings.Contains(got, " a\n") { + t.Fatalf("oldest should drop:\n%s", got) + } + if !strings.Contains(tt.View(), "d") { + t.Fatalf("newest kept:\n%s", tt.View()) + } + fast := NewToasts(20 * time.Millisecond) + fast.Push("x", ToneInfo) + time.Sleep(40 * time.Millisecond) + if fast.Expire() { + t.Fatalf("expired toast should clear") + } + if !fast.Empty() { + t.Fatalf("should be empty") + } +} diff --git a/pkg/tui/views/actions.go b/pkg/tui/views/actions.go index 10df0b3..4b8279f 100644 --- a/pkg/tui/views/actions.go +++ b/pkg/tui/views/actions.go @@ -6,31 +6,42 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/zaidejjo/zgit/pkg/core/models" + "github.com/zaidejjo/zgit/pkg/tui/components" "github.com/zaidejjo/zgit/pkg/tui/styles" ) // ActionsModel holds state for the CI workflow runs view. Enter drills // into the selected run's jobs; backspace returns to the run list. type ActionsModel struct { - Runs []*models.WorkflowRun - Jobs []*models.Job - JobsFor int64 // run id whose jobs are shown, 0 = run list - Cursor int - Offset int - Error string - Height int - Filter string - Filtering bool + components.ZList[*models.WorkflowRun] + Jobs []*models.Job + JobsFor int64 // run id whose jobs are shown, 0 = run list + Error string +} + +// Runs aliases the underlying items. +func (m *ActionsModel) Runs() []*models.WorkflowRun { + return m.Items } // NewActionsModel creates the actions view model. func NewActionsModel() ActionsModel { - return ActionsModel{Height: 20} + m := ActionsModel{} + m.Height = 20 + m.Match = func(r *models.WorkflowRun, q string) bool { + hay := strings.ToLower(strings.Join([]string{ + r.WorkflowName, r.Branch, r.HeadSHA, r.Event, + fmt.Sprintf("%d", r.RunNumber), + }, " ")) + return strings.Contains(hay, q) + } + m.Render = actionsRow + return m } // UpdateRuns refreshes the run list (clears any job drill-in). func (m *ActionsModel) UpdateRuns(runs []*models.WorkflowRun) { - m.Runs = runs + m.Items = runs m.Jobs = nil m.JobsFor = 0 m.Error = "" @@ -46,49 +57,13 @@ func (m *ActionsModel) UpdateJobs(runID int64, jobs []*models.Job) { m.Error = "" } -// Filtered returns runs matching the filter (workflow/branch/sha/event). -func (m *ActionsModel) Filtered() []*models.WorkflowRun { - q := strings.ToLower(strings.TrimSpace(m.Filter)) - if q == "" { - return m.Runs - } - out := make([]*models.WorkflowRun, 0, len(m.Runs)) - for _, r := range m.Runs { - hay := strings.ToLower(strings.Join([]string{ - r.WorkflowName, r.Branch, r.HeadSHA, r.Event, - fmt.Sprintf("%d", r.RunNumber), - }, " ")) - if strings.Contains(hay, q) { - out = append(out, r) - } - } - return out -} - -// ClampCursor keeps the cursor inside the visible list. -func (m *ActionsModel) ClampCursor() { - n := len(m.Filtered()) - if m.JobsFor != 0 { - n = len(m.Jobs) - } - if m.Cursor >= n { - m.Cursor = 0 - } - if m.Cursor < 0 { - m.Cursor = 0 - } -} - // SelectedRun returns the cursor run (filter-aware), or nil in job view. func (m *ActionsModel) SelectedRun() *models.WorkflowRun { if m.JobsFor != 0 { return nil } - visible := m.Filtered() - if len(visible) == 0 || m.Cursor < 0 || m.Cursor >= len(visible) { - return nil - } - return visible[m.Cursor] + run, _ := m.Selected() + return run } func runMark(r *models.WorkflowRun) string { @@ -128,64 +103,45 @@ func (m *ActionsModel) View(width int) string { if m.Error != "" { return styles.ErrorStyle.Render("Error: " + m.Error) } - if m.Runs == nil { + if m.Items == nil { return styles.LoadingStyle.Render("Loading workflow runs...") } if m.JobsFor != 0 { return m.jobsView(width) } - if len(m.Runs) == 0 { + if len(m.Items) == 0 { return styles.SubtitleStyle.Render("No workflow runs found") } - m.ensureCursorVisible() - var b strings.Builder - title := fmt.Sprintf(" Actions (%d)", len(m.Runs)) + title := fmt.Sprintf(" Actions (%d)", len(m.Items)) if strings.TrimSpace(m.Filter) != "" { - title = fmt.Sprintf(" Actions (%d/%d) filter: %s", len(m.Filtered()), len(m.Runs), m.Filter) + title = fmt.Sprintf(" Actions (%d/%d) filter: %s", len(m.Filtered()), len(m.Items), m.Filter) } b.WriteString(styles.SectionTitleStyle.Render(title)) b.WriteString("\n") - if m.Filtering { - b.WriteString(styles.FilterBarStyle.Render(" /" + m.Filter + "█")) - b.WriteString("\n") - } - - visible := m.Filtered() - if len(visible) == 0 && strings.TrimSpace(m.Filter) != "" { + b.WriteString(m.FilterBar()) + if len(m.Filtered()) == 0 && strings.TrimSpace(m.Filter) != "" { return b.String() + styles.SubtitleStyle.Render("No runs match filter") } - if len(visible) > m.Height { - end := m.Offset + m.Height - if end > len(visible) { - end = len(visible) - } - visible = visible[m.Offset:end] - } - for i, r := range visible { - globalIdx := m.Offset + i - head := r.HeadSHA - if len(head) > 7 { - head = head[:7] - } - line := fmt.Sprintf(" %s %s #%d %s %s", - runMark(r), truncateStr(r.WorkflowName, 28), - r.RunNumber, truncateStr(r.Branch, 24), head) - if lipgloss.Width(line) > width { - line = fmt.Sprintf(" %s %s #%d", runMark(r), truncateStr(r.WorkflowName, width-12), r.RunNumber) - } - if globalIdx == m.Cursor { - b.WriteString(styles.ListItemActiveStyle.Render(cursorLine(line))) - } else { - b.WriteString(styles.ListItemStyle.Render(line)) - } - b.WriteString("\n") - } + return b.String() + m.ViewRows(width) +} - return b.String() +// actionsRow renders one run row (no cursor — ZList wraps it). +func actionsRow(r *models.WorkflowRun, width int) string { + head := r.HeadSHA + if len(head) > 7 { + head = head[:7] + } + line := fmt.Sprintf(" %s %s #%d %s %s", + runMark(r), truncateStr(r.WorkflowName, 28), + r.RunNumber, truncateStr(r.Branch, 24), head) + if lipgloss.Width(line) > width { + line = fmt.Sprintf(" %s %s #%d", runMark(r), truncateStr(r.WorkflowName, width-12), r.RunNumber) + } + return line } func (m *ActionsModel) jobsView(width int) string { @@ -215,13 +171,3 @@ func (m *ActionsModel) jobsView(width int) string { } return b.String() } - -func (m *ActionsModel) ensureCursorVisible() { - m.ClampCursor() - if m.Cursor < m.Offset { - m.Offset = m.Cursor - } - if m.Cursor >= m.Offset+m.Height { - m.Offset = m.Cursor - m.Height + 1 - } -} diff --git a/pkg/tui/views/dashboard.go b/pkg/tui/views/dashboard.go index 2448c44..fdaae1d 100644 --- a/pkg/tui/views/dashboard.go +++ b/pkg/tui/views/dashboard.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/charmbracelet/lipgloss" + "github.com/zaidejjo/zgit/pkg/tui/components" "github.com/zaidejjo/zgit/pkg/tui/styles" ) @@ -24,61 +25,43 @@ type DashEntry struct { // DashModel holds state for the repository dashboard view. type DashModel struct { - Entries []DashEntry - Loaded bool // false while the async scan runs - Cursor int - Offset int - Error string - Height int - Filter string - Filtering bool + components.ZList[DashEntry] + Loaded bool // false while the async scan runs + Error string +} + +// Entries aliases the underlying items (read-only snapshot). +func (m *DashModel) Entries() []DashEntry { + return m.Items } // NewDashModel creates a dashboard view model. func NewDashModel() DashModel { - return DashModel{Height: 20} + m := DashModel{} + m.Height = 20 + m.Match = func(e DashEntry, q string) bool { + return strings.Contains(strings.ToLower(e.Name+" "+e.Path), q) + } + m.Render = dashRow + return m } // UpdateEntries refreshes the dashboard data. func (m *DashModel) UpdateEntries(entries []DashEntry) { - m.Entries = entries + m.Items = entries m.Loaded = true m.Error = "" m.ClampCursor() } -// Filtered returns entries matching the filter (name or path substring). -func (m *DashModel) Filtered() []DashEntry { - q := strings.ToLower(strings.TrimSpace(m.Filter)) - if q == "" { - return m.Entries - } - out := make([]DashEntry, 0, len(m.Entries)) - for _, e := range m.Entries { - if strings.Contains(strings.ToLower(e.Name+" "+e.Path), q) { - out = append(out, e) - } - } - return out -} - -// ClampCursor keeps the cursor inside the filtered list. -func (m *DashModel) ClampCursor() { - if m.Cursor >= len(m.Filtered()) { - m.Cursor = 0 - } - if m.Cursor < 0 { - m.Cursor = 0 - } -} - // Selected returns the cursor entry (filter-aware). func (m *DashModel) Selected() *DashEntry { - visible := m.Filtered() - if len(visible) == 0 || m.Cursor < 0 || m.Cursor >= len(visible) { + e, ok := m.ZList.Selected() + if !ok { return nil } - return &visible[m.Cursor] + out := e + return &out } // View renders the dashboard list. @@ -89,97 +72,67 @@ func (m *DashModel) View(width int) string { if !m.Loaded { return styles.LoadingStyle.Render("Scanning repositories...") } - if len(m.Entries) == 0 { + if len(m.Items) == 0 { return styles.SubtitleStyle.Render("No repositories yet — open one with 'zgit -C '") } - m.ensureCursorVisible() - var b strings.Builder - title := fmt.Sprintf(" Repositories (%d)", len(m.Entries)) + title := fmt.Sprintf(" Repositories (%d)", len(m.Items)) if strings.TrimSpace(m.Filter) != "" { - title = fmt.Sprintf(" Repositories (%d/%d) filter: %s", len(m.Filtered()), len(m.Entries), m.Filter) + title = fmt.Sprintf(" Repositories (%d/%d) filter: %s", len(m.Filtered()), len(m.Items), m.Filter) } b.WriteString(styles.SectionTitleStyle.Render(title)) b.WriteString("\n") - if m.Filtering { - b.WriteString(styles.FilterBarStyle.Render(" /" + m.Filter + "█")) - b.WriteString("\n") - } - - visible := m.Filtered() - if len(visible) == 0 && strings.TrimSpace(m.Filter) != "" { + b.WriteString(m.FilterBar()) + if len(m.Filtered()) == 0 && strings.TrimSpace(m.Filter) != "" { return b.String() + styles.SubtitleStyle.Render("No repositories match filter") } - if len(visible) > m.Height { - end := m.Offset + m.Height - if end > len(visible) { - end = len(visible) - } - visible = visible[m.Offset:end] - } - for i, e := range visible { - globalIdx := m.Offset + i - - var mark string - switch { - case e.Err != "": - mark = styles.StatusDeletedStyle.Render("✗") - case !e.Clean: - mark = styles.StatusUnstagedStyle.Render("●") - default: - mark = styles.StatusStagedStyle.Render("●") - } + return b.String() + m.ViewRows(width) +} - name := e.Name - star := "" - if e.Favorite { - star = styles.StatusUnstagedStyle.Render("★ ") - } - openTag := "" - if e.Current { - openTag = styles.ListItemActiveStyle.Render(" ‹open›") - } +// dashRow renders one repository row (no cursor — ZList wraps it). +func dashRow(e DashEntry, width int) string { + var mark string + switch { + case e.Err != "": + mark = styles.StatusDeletedStyle.Render("✗") + case !e.Clean: + mark = styles.StatusUnstagedStyle.Render("●") + default: + mark = styles.StatusStagedStyle.Render("●") + } - detail := e.Branch - if e.Err != "" { - detail = "unreachable" - } else { - if e.Dirty > 0 { - detail += fmt.Sprintf(" %d dirty", e.Dirty) - } - if e.Ahead > 0 || e.Behind > 0 { - detail += fmt.Sprintf(" +%d/-%d", e.Ahead, e.Behind) - } - } - if detail == "" { - detail = "—" - } + name := e.Name + star := "" + if e.Favorite { + star = styles.StatusUnstagedStyle.Render("★ ") + } + openTag := "" + if e.Current { + openTag = styles.ListItemActiveStyle.Render(" ‹open›") + } - line := fmt.Sprintf(" %s %s%s%s %s", mark, star, truncateStr(name, 38), - openTag, styles.SubtitleStyle.Render(truncateStr(detail, 40))) - if lipgloss.Width(line) > width { - line = fmt.Sprintf(" %s %s%s", mark, star, truncateStr(name, width-8)) + detail := e.Branch + if e.Err != "" { + detail = "unreachable" + } else { + if e.Dirty > 0 { + detail += fmt.Sprintf(" %d dirty", e.Dirty) } - - if globalIdx == m.Cursor { - b.WriteString(styles.ListItemActiveStyle.Render(cursorLine(line))) - } else { - b.WriteString(styles.ListItemStyle.Render(line)) + if e.Ahead > 0 || e.Behind > 0 { + detail += fmt.Sprintf(" +%d/-%d", e.Ahead, e.Behind) } - b.WriteString("\n") } - - return b.String() -} - -func (m *DashModel) ensureCursorVisible() { - m.ClampCursor() - if m.Cursor < m.Offset { - m.Offset = m.Cursor + if detail == "" { + detail = "—" } - if m.Cursor >= m.Offset+m.Height { - m.Offset = m.Cursor - m.Height + 1 + + line := fmt.Sprintf(" %s %s%s%s %s", mark, star, truncateStr(name, 38), + openTag, styles.SubtitleStyle.Render(truncateStr(detail, 40))) + if lipgloss.Width(line) > width { + line = fmt.Sprintf(" %s %s%s", mark, star, truncateStr(name, width-8)) } + + return line } diff --git a/pkg/tui/views/newviews_test.go b/pkg/tui/views/newviews_test.go index 924f4a6..f438bf3 100644 --- a/pkg/tui/views/newviews_test.go +++ b/pkg/tui/views/newviews_test.go @@ -122,3 +122,27 @@ func TestBranchSelfTrackingShort(t *testing.T) { t.Fatalf("foreign upstream should stay:\n%s", out) } } + +func TestPaletteFuzzyRanking(t *testing.T) { + m := NewPaletteModel() + m.Commands = []PaletteCommand{ + {ID: "commit-push", Label: "Commit & Push"}, + {ID: "stash", Label: "Stash"}, + {ID: "commit", Label: "Commit"}, + } + m.Input = "commit" + m.filter() + if len(m.Filtered) != 2 { + t.Fatalf("filtered = %v", m.Filtered) + } + // Exact short prefix "Commit" outranks longer "Commit & Push". + first := m.Commands[m.Filtered[0]] + if first.ID != "commit" { + t.Fatalf("first = %q, want commit", first.ID) + } + m.Input = "zzz" + m.filter() + if len(m.Filtered) != 0 { + t.Fatalf("want empty, got %v", m.Filtered) + } +} diff --git a/pkg/tui/views/notifications.go b/pkg/tui/views/notifications.go index 0bcdaf4..b3cd2f1 100644 --- a/pkg/tui/views/notifications.go +++ b/pkg/tui/views/notifications.go @@ -6,25 +6,29 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/zaidejjo/zgit/pkg/core/models" + "github.com/zaidejjo/zgit/pkg/tui/components" "github.com/zaidejjo/zgit/pkg/tui/styles" ) // NotesModel holds state for the GitHub notifications inbox. type NotesModel struct { - Items []*models.Notification - ShowAll bool // false = unread only (server-side) - Loaded bool - Cursor int - Offset int - Error string - Height int - Filter string - Filtering bool + components.ZList[*models.Notification] + ShowAll bool // false = unread only (server-side) + Loaded bool + Error string } // NewNotesModel creates the notifications view model. func NewNotesModel() NotesModel { - return NotesModel{Height: 20} + m := NotesModel{} + m.Height = 20 + m.Match = func(it *models.Notification, q string) bool { + hay := strings.ToLower(strings.Join([]string{ + it.Title, it.RepoName, it.Reason, it.Type, + }, " ")) + return strings.Contains(hay, q) + } + return m } // UpdateNotes refreshes the inbox. @@ -62,34 +66,6 @@ func (m *NotesModel) MarkAllRead() { } } -// Filtered returns items matching the filter (title/repo/reason). -func (m *NotesModel) Filtered() []*models.Notification { - q := strings.ToLower(strings.TrimSpace(m.Filter)) - if q == "" { - return m.Items - } - out := make([]*models.Notification, 0, len(m.Items)) - for _, it := range m.Items { - hay := strings.ToLower(strings.Join([]string{ - it.Title, it.RepoName, it.Reason, it.Type, - }, " ")) - if strings.Contains(hay, q) { - out = append(out, it) - } - } - return out -} - -// ClampCursor keeps the cursor inside the filtered list. -func (m *NotesModel) ClampCursor() { - if m.Cursor >= len(m.Filtered()) { - m.Cursor = 0 - } - if m.Cursor < 0 { - m.Cursor = 0 - } -} - // Ordered returns filtered items unread-first (stable within groups). // Both View and Selected use it so the cursor always matches. func (m *NotesModel) Ordered() []*models.Notification { @@ -149,8 +125,6 @@ func (m *NotesModel) View(width int) string { return styles.SubtitleStyle.Render("Inbox zero — nothing unread") } - m.ensureCursorVisible() - var b strings.Builder mode := "Unread" if m.ShowAll { @@ -162,11 +136,9 @@ func (m *NotesModel) View(width int) string { } b.WriteString(styles.SectionTitleStyle.Render(title)) b.WriteString("\n") - if m.Filtering { - b.WriteString(styles.FilterBarStyle.Render(" /" + m.Filter + "█")) - b.WriteString("\n") - } + b.WriteString(m.FilterBar()) + m.EnsureVisible() visible := m.Ordered() if len(visible) == 0 && strings.TrimSpace(m.Filter) != "" { @@ -208,13 +180,3 @@ func (m *NotesModel) View(width int) string { return b.String() } - -func (m *NotesModel) ensureCursorVisible() { - m.ClampCursor() - if m.Cursor < m.Offset { - m.Offset = m.Cursor - } - if m.Cursor >= m.Offset+m.Height { - m.Offset = m.Cursor - m.Height + 1 - } -} diff --git a/pkg/tui/views/palette.go b/pkg/tui/views/palette.go index f7b93ef..02f9aa5 100644 --- a/pkg/tui/views/palette.go +++ b/pkg/tui/views/palette.go @@ -1,6 +1,7 @@ package views import ( + "sort" "strings" "unicode/utf8" @@ -185,20 +186,36 @@ func (m *PaletteModel) HandleKey(key string) bool { return true } -// filter updates the filtered results based on input query. +// filter updates the filtered results based on input query, ranked by +// fuzzy score (prefix/word/consecutive bonuses, shorter wins ties). func (m *PaletteModel) filter() { query := strings.ToLower(m.Input) m.Filtered = nil + type scored struct { + idx int + score float64 + } + var ranked []scored for i, cmd := range m.Commands { if query == "" { m.Filtered = append(m.Filtered, i) continue } - label := strings.ToLower(cmd.Label) - id := strings.ToLower(cmd.ID) - if fuzzyMatch(query, label) || fuzzyMatch(query, id) { - m.Filtered = append(m.Filtered, i) + best := -1.0 + for _, field := range []string{cmd.Label, cmd.ID, cmd.Description} { + if s := fuzzyScore(query, strings.ToLower(field)); s > best { + best = s + } + } + if best >= 0 { + ranked = append(ranked, scored{i, best}) + } + } + if query != "" { + sort.SliceStable(ranked, func(a, b int) bool { return ranked[a].score > ranked[b].score }) + for _, r := range ranked { + m.Filtered = append(m.Filtered, r.idx) } } @@ -210,15 +227,40 @@ func (m *PaletteModel) filter() { } } -// fuzzyMatch returns true if all chars in query appear in str in order. -func fuzzyMatch(query, str string) bool { +// fuzzyScore ranks a subsequence match; -1 means no match. +func fuzzyScore(query, str string) float64 { qi := 0 + score := 0.0 + consec := 0 for si := 0; si < len(str) && qi < len(query); si++ { - if str[si] == query[qi] { - qi++ + if str[si] != query[qi] { + consec = 0 + continue } + if si == 0 { + score += 15 // prefix match + } else { + if prev := str[si-1]; prev == '/' || prev == '-' || prev == '_' || prev == ' ' { + score += 8 // word start + } + } + if consec > 0 { + score += 5 + float64(consec)*2 // consecutive run + } else { + score += 5 + } + consec++ + qi++ + } + if qi != len(query) { + return -1 } - return qi == len(query) + return score - float64(len(str))*0.1 +} + +// fuzzyMatch returns true if all chars in query appear in str in order. +func fuzzyMatch(query, str string) bool { + return fuzzyScore(query, str) >= 0 } // View renders the command palette overlay with scrollable item list and accent selection.