From 0291d7a16f14279693c5c4e2f450d777ad35f7cf Mon Sep 17 00:00:00 2001 From: zaidejjo Date: Fri, 16 Oct 2026 05:09:09 +0300 Subject: [PATCH] feat(tui): repos dashboard, actions, inbox + modern shell - dashboard (5): recents+favorites with live status, enter switches repo in place, filter + rescan - actions (6): runs with state marks, filter, enter drills into jobs, backspace returns - notifications (7): unread-first inbox, filter, x/X read, u toggles unread/all, header unread badge - shell: compact tab labels on narrow terms, palette jumps, help entries, nil-safe empty results - tests: filter/cursor/drill-in/unread ordering --- pkg/tui/app.go | 461 ++++++++++++++++++++++++++++++++- pkg/tui/app_palette.go | 17 ++ pkg/tui/views/actions.go | 227 ++++++++++++++++ pkg/tui/views/dashboard.go | 185 +++++++++++++ pkg/tui/views/help.go | 46 +++- pkg/tui/views/newviews_test.go | 89 +++++++ pkg/tui/views/notifications.go | 220 ++++++++++++++++ pkg/tui/views/palette.go | 5 + 8 files changed, 1242 insertions(+), 8 deletions(-) create mode 100644 pkg/tui/views/actions.go create mode 100644 pkg/tui/views/dashboard.go create mode 100644 pkg/tui/views/newviews_test.go create mode 100644 pkg/tui/views/notifications.go diff --git a/pkg/tui/app.go b/pkg/tui/app.go index 60c7b62..794dedc 100644 --- a/pkg/tui/app.go +++ b/pkg/tui/app.go @@ -35,9 +35,12 @@ const ( // Full-screen view identifiers (override panel layout). // Values 3+ to avoid overlap with subscriber event IDs (0=status,1=log,2=branches). const ( - fsNone = -1 // normal panel layout - fsPR = 3 // matches old ViewPR for subscriber compatibility - fsIssue = 4 // matches old ViewIssue + fsNone = -1 // normal panel layout + fsPR = 3 // matches old ViewPR for subscriber compatibility + fsIssue = 4 // matches old ViewIssue + fsDashboard = 5 + fsActions = 6 + fsNotes = 7 ) // Application modes (overlays and dialogs). @@ -73,6 +76,9 @@ type Model struct { branches views.BranchModel prs views.PRListModel issues views.IssueListModel + dash views.DashModel + actions views.ActionsModel + notes views.NotesModel helpView views.HelpModel // Overlay models @@ -120,6 +126,9 @@ func NewModel(gitExec *gitpkg.NativeExec) *Model { branches: views.NewBranchModel(), prs: views.NewPRListModel(), issues: views.NewIssueListModel(), + dash: views.NewDashModel(), + actions: views.NewActionsModel(), + notes: views.NewNotesModel(), helpView: views.HelpModel{}, diffViewer: views.NewDiffModel(), commitDlg: views.NewCommitModel(), @@ -493,6 +502,18 @@ func (m *Model) renderHeader() string { tabs = append(tabs, tab("Branches", panelActive && m.focusedPanel == PanelBranch)) tabs = append(tabs, tab("3 PRs", m.fullScreenView == fsPR)) tabs = append(tabs, tab("4 Issues", m.fullScreenView == fsIssue)) + // Compact labels on narrow terminals so the strip never wraps. + reposLabel, actionsLabel := "5 Repos", "6 Actions" + if m.width > 0 && m.width < 110 { + reposLabel, actionsLabel = "5R", "6A" + } + tabs = append(tabs, tab(reposLabel, m.fullScreenView == fsDashboard)) + tabs = append(tabs, tab(actionsLabel, m.fullScreenView == fsActions)) + notesLabel := "7 Notes" + if m.notes.Loaded && m.notes.UnreadCount() > 0 { + notesLabel = fmt.Sprintf("7 Notes (%d)", m.notes.UnreadCount()) + } + tabs = append(tabs, tab(notesLabel, m.fullScreenView == fsNotes)) left := lipgloss.JoinHorizontal(lipgloss.Center, tabs...) var meta []string @@ -531,6 +552,12 @@ func (m *Model) renderContent() string { return m.prs.View(m.width - 2) case fsIssue: return m.issues.View(m.width - 2) + case fsDashboard: + return m.dash.View(m.width - 2) + case fsActions: + return m.actions.View(m.width - 2) + case fsNotes: + return m.notes.View(m.width - 2) } // Panel layout: Status | (Log / Branches) @@ -743,6 +770,20 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.fullScreenView = fsIssue m.ensureGHDataLoaded() return m, nil + case "5": + m.fullScreenView = fsDashboard + if !m.dash.Loaded { + go m.fetchDashboard() + } + return m, nil + case "6": + m.fullScreenView = fsActions + m.ensureGHDataLoaded() + return m, nil + case "7": + m.fullScreenView = fsNotes + m.ensureGHDataLoaded() + return m, nil } // If help is open, route keys to help model for search @@ -765,6 +806,15 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.fullScreenView == fsIssue { return m.handleIssueKeys(key) } + if m.fullScreenView == fsDashboard { + return m.handleDashKeys(key) + } + if m.fullScreenView == fsActions { + return m.handleActionsKeys(key) + } + if m.fullScreenView == fsNotes { + return m.handleNotesKeys(key) + } // Delegate to focused panel switch m.focusedPanel { @@ -787,14 +837,22 @@ func (m *Model) ensureGHDataLoaded() { } switch m.fullScreenView { - case 3: // PRs + case fsPR: // PRs if m.prs.PRs == nil { go m.fetchPRs() } - case 4: // Issues + case fsIssue: // Issues if m.issues.Issues == nil { go m.fetchIssues() } + case fsActions: // Actions + if m.actions.Runs == nil { + go m.fetchActions() + } + case fsNotes: // Notifications + if !m.notes.Loaded { + go m.fetchNotes() + } } } @@ -804,6 +862,8 @@ func (m *Model) refreshGitHubData() { } go m.fetchPRs() go m.fetchIssues() + go m.fetchActions() + go m.fetchNotes() } func (m *Model) fetchPRs() { @@ -852,6 +912,188 @@ func (m *Model) fetchIssues() { m.msgs <- teaMsg{view: 4, data: issueEvent{issues: issues}} } +// fetchDashboard loads recents + favorites with a quick per-repo status. +// Runs async; entries stream in as one update. +func (m *Model) fetchDashboard() { + cfg, err := config.New() + if err != nil { + m.msgs <- teaMsg{view: 5, data: dashEvent{err: err}} + return + } + seen := map[string]bool{} + var paths []string + for _, p := range cfg.GetFavoriteRepos() { + if !seen[p] { + seen[p] = true + paths = append(paths, p) + } + } + for _, p := range cfg.GetRecentRepos() { + if !seen[p] { + seen[p] = true + paths = append(paths, p) + } + } + current := "" + if m.git != nil { + current = m.git.Path() + } + if current != "" && !seen[current] { + paths = append([]string{current}, paths...) + } + favs := map[string]bool{} + for _, p := range cfg.GetFavoriteRepos() { + favs[p] = true + } + + entries := make([]views.DashEntry, 0, len(paths)) + for _, p := range paths { + e := views.DashEntry{Name: baseName(p), Path: p, Favorite: favs[p], Current: p == current} + if st, err := quickRepoStatus(p); err != nil { + e.Err = "unreachable" + } else { + e.Branch = st.Branch + e.Clean = st.IsClean + e.Dirty = len(st.Files) + e.Ahead = st.Ahead + e.Behind = st.Behind + } + entries = append(entries, e) + } + m.msgs <- teaMsg{view: 5, data: dashEvent{entries: entries}} +} + +// quickRepoStatus stats one repo without touching the current exec. +func quickRepoStatus(path string) (*models.Status, error) { + exec, err := gitpkg.NewNativeExec("") + if err != nil { + return nil, err + } + if err := exec.Open(path); err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), 10e9) + defer cancel() + return exec.Status(ctx) +} + +// switchRepo repoints the TUI at another local repo. +func (m *Model) switchRepo(path string) { + if m.git == nil { + m.dash.Error = "no git backend" + return + } + if err := m.git.Open(path); err != nil { + m.dash.Error = fmt.Sprintf("open %s: %v", path, err) + return + } + if cfg, err := config.New(); err == nil { + cfg.AddRecentRepo(path) + _ = cfg.Save() + } + m.prs = views.NewPRListModel() + m.issues = views.NewIssueListModel() + m.actions = views.NewActionsModel() + m.notes = views.NewNotesModel() + m.ghOwner, m.ghRepo, m.ghDetected = "", "", false + m.tryDetectRepo() + m.sub.Refresh() + m.fullScreenView = fsNone + m.focusedPanel = PanelStatus + go m.fetchDashboard() +} + +// fetchActions loads workflow runs for the detected repo. +func (m *Model) fetchActions() { + if m.gh == nil || !m.ghDetected { + m.msgs <- teaMsg{view: 6, data: actionsEvent{err: fmt.Errorf("GitHub not authenticated; run 'zgit auth login'")}} + return + } + ctx, cancel := context.WithTimeout(context.Background(), 15e9) + defer cancel() + runs, err := m.gh.ListWorkflowRuns(ctx, m.ghOwner, m.ghRepo, github.RunsFilter{Limit: 30}) + if err != nil { + m.msgs <- teaMsg{view: 6, data: actionsEvent{err: err}} + return + } + if runs == nil { + runs = []*models.WorkflowRun{} + } + m.msgs <- teaMsg{view: 6, data: actionsEvent{runs: runs}} +} + +// fetchActionJobs loads jobs for one run (drill-in). +func (m *Model) fetchActionJobs(runID int64) { + if m.gh == nil || !m.ghDetected { + m.msgs <- teaMsg{view: 6, data: actionJobsEvent{runID: runID, err: fmt.Errorf("GitHub not authenticated")}} + return + } + ctx, cancel := context.WithTimeout(context.Background(), 15e9) + defer cancel() + jobs, err := m.gh.ListWorkflowJobs(ctx, m.ghOwner, m.ghRepo, runID) + if err != nil { + m.msgs <- teaMsg{view: 6, data: actionJobsEvent{runID: runID, err: err}} + return + } + if jobs == nil { + jobs = []*models.Job{} + } + m.msgs <- teaMsg{view: 6, data: actionJobsEvent{runID: runID, jobs: jobs}} +} + +// fetchNotes loads the global notification inbox. +func (m *Model) fetchNotes() { + if m.gh == nil { + m.msgs <- teaMsg{view: 7, data: notesEvent{err: fmt.Errorf("GitHub not authenticated; run 'zgit auth login'")}} + return + } + ctx, cancel := context.WithTimeout(context.Background(), 15e9) + defer cancel() + items, err := m.gh.ListNotifications(ctx, m.notes.ShowAll, 30) + if err != nil { + m.msgs <- teaMsg{view: 7, data: notesEvent{err: err}} + return + } + if items == nil { + items = []*models.Notification{} + } + m.msgs <- teaMsg{view: 7, data: notesEvent{items: items}} +} + +// markNoteRead marks one thread read (server + local flip). +func (m *Model) markNoteRead(id string) { + m.notes.MarkRead(id) + if m.gh == nil { + return + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10e9) + defer cancel() + _ = m.gh.MarkNotificationRead(ctx, id) + }() +} + +// markAllNotesRead marks the whole inbox read. +func (m *Model) markAllNotesRead() { + m.notes.MarkAllRead() + if m.gh == nil { + return + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10e9) + defer cancel() + _ = m.gh.MarkAllNotificationsRead(ctx) + }() +} + +// baseName returns the last path segment. +func baseName(p string) string { + if i := strings.LastIndex(p, "/"); i >= 0 { + return p[i+1:] + } + return p +} + // --- View-specific key handlers --- func (m *Model) handleStatusKeys(key string) (tea.Model, tea.Cmd) { @@ -1279,6 +1521,162 @@ 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) { + 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) + } + case "r": + m.dash.Loaded = false + go m.fetchDashboard() + } + 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 + 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) + 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 + } + 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) + } + } + case "r": + m.actions.Runs = nil + go m.fetchActions() + } + 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) { + 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() + case "u": + // Toggle unread/all (server-side) and reload. + m.notes.ShowAll = !m.notes.ShowAll + m.notes.Loaded = false + go m.fetchNotes() + case "r": + m.notes.Loaded = false + go m.fetchNotes() + } + return m, nil +} + // --- Diff & Commit --- func (m *Model) openFileDiff() { @@ -1620,6 +2018,27 @@ type issueEvent struct { err error } +type dashEvent struct { + entries []views.DashEntry + err error +} + +type actionsEvent struct { + runs []*models.WorkflowRun + err error +} + +type actionJobsEvent struct { + runID int64 + jobs []*models.Job + err error +} + +type notesEvent struct { + items []*models.Notification + err error +} + type prCreateResultEvent struct { Result views.PRCreateResultInfo } @@ -1734,6 +2153,38 @@ func (m *Model) handleEngineMsg(msg teaMsg) (tea.Model, tea.Cmd) { m.issues.UpdateIssues(evt.issues) } } + case 5: // Dashboard + if evt, ok := msg.data.(dashEvent); ok { + if evt.err != nil { + m.dash.Error = evt.err.Error() + m.dash.Loaded = true + } else if evt.entries != nil { + m.dash.UpdateEntries(evt.entries) + } + } + case 6: // Actions + if evt, ok := msg.data.(actionsEvent); ok { + if evt.err != nil { + m.actions.Error = evt.err.Error() + } else if evt.runs != nil { + m.actions.UpdateRuns(evt.runs) + } + } else if evt, ok := msg.data.(actionJobsEvent); ok { + if evt.err != nil { + m.actions.Error = evt.err.Error() + } else if evt.jobs != nil { + m.actions.UpdateJobs(evt.runID, evt.jobs) + } + } + case 7: // Notifications + if evt, ok := msg.data.(notesEvent); ok { + if evt.err != nil { + m.notes.Error = evt.err.Error() + m.notes.Loaded = true + } else if evt.items != nil { + m.notes.UpdateNotes(evt.items) + } + } } return m, nil } diff --git a/pkg/tui/app_palette.go b/pkg/tui/app_palette.go index 5ee5d4f..ffede37 100644 --- a/pkg/tui/app_palette.go +++ b/pkg/tui/app_palette.go @@ -92,6 +92,23 @@ func (m *Model) executePaletteCommand(id string) (tea.Model, tea.Cmd) { m.focusedPanel = (m.focusedPanel + 1) % panelCount case "prev-panel": m.focusedPanel = (m.focusedPanel - 1 + panelCount) % panelCount + case "dashboard": + m.fullScreenView = fsDashboard + if !m.dash.Loaded { + go m.fetchDashboard() + } + case "actions-view": + m.fullScreenView = fsActions + m.ensureGHDataLoaded() + case "notifications": + m.fullScreenView = fsNotes + m.ensureGHDataLoaded() + case "prs-view": + m.fullScreenView = fsPR + m.ensureGHDataLoaded() + case "issues-view": + m.fullScreenView = fsIssue + m.ensureGHDataLoaded() case "help": m.showHelp = true m.helpView.Input = "" diff --git a/pkg/tui/views/actions.go b/pkg/tui/views/actions.go new file mode 100644 index 0000000..dc4f229 --- /dev/null +++ b/pkg/tui/views/actions.go @@ -0,0 +1,227 @@ +package views + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/zaidejjo/zgit/pkg/core/models" + "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 +} + +// NewActionsModel creates the actions view model. +func NewActionsModel() ActionsModel { + return ActionsModel{Height: 20} +} + +// UpdateRuns refreshes the run list (clears any job drill-in). +func (m *ActionsModel) UpdateRuns(runs []*models.WorkflowRun) { + m.Runs = runs + m.Jobs = nil + m.JobsFor = 0 + m.Error = "" + m.ClampCursor() +} + +// UpdateJobs shows jobs for a run. +func (m *ActionsModel) UpdateJobs(runID int64, jobs []*models.Job) { + m.Jobs = jobs + m.JobsFor = runID + m.Cursor = 0 + m.Offset = 0 + 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] +} + +func runMark(r *models.WorkflowRun) string { + c := strings.ToLower(r.Conclusion) + switch c { + case "success": + return styles.StatusStagedStyle.Render("●") + case "failure": + return styles.StatusDeletedStyle.Render("●") + case "cancelled", "skipped": + return styles.StatusUntrackedStyle.Render("●") + } + if strings.ToLower(string(r.Status)) == "in_progress" { + return styles.StatusUnstagedStyle.Render("◉") + } + return styles.SubtitleStyle.Render("○") +} + +func jobMark(j *models.Job) string { + c := strings.ToLower(j.Conclusion) + switch c { + case "success": + return styles.StatusStagedStyle.Render("✓") + case "failure": + return styles.StatusDeletedStyle.Render("✗") + case "cancelled", "skipped": + return styles.StatusUntrackedStyle.Render("−") + } + if j.Status == "in_progress" { + return styles.StatusUnstagedStyle.Render("◉") + } + return styles.SubtitleStyle.Render("○") +} + +// View renders runs or, when drilled in, one run's jobs. +func (m *ActionsModel) View(width int) string { + if m.Error != "" { + return styles.ErrorStyle.Render("Error: " + m.Error) + } + if m.Runs == nil { + return styles.LoadingStyle.Render("Loading workflow runs...") + } + if m.JobsFor != 0 { + return m.jobsView(width) + } + + if len(m.Runs) == 0 { + return styles.SubtitleStyle.Render("No workflow runs found") + } + + m.ensureCursorVisible() + + var b strings.Builder + title := fmt.Sprintf(" Actions (%d)", len(m.Runs)) + if strings.TrimSpace(m.Filter) != "" { + title = fmt.Sprintf(" Actions (%d/%d) filter: %s", len(m.Filtered()), len(m.Runs), 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) != "" { + 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("❯" + line[1:])) + } else { + b.WriteString(styles.ListItemStyle.Render(line)) + } + b.WriteString("\n") + } + + return b.String() +} + +func (m *ActionsModel) jobsView(width int) string { + var b strings.Builder + b.WriteString(styles.SectionTitleStyle.Render(fmt.Sprintf(" Jobs (%d) — backspace to return", len(m.Jobs)))) + b.WriteString("\n") + if len(m.Jobs) == 0 { + return b.String() + styles.SubtitleStyle.Render("No jobs reported for this run") + } + jobs := m.Jobs + if len(jobs) > m.Height { + end := m.Offset + m.Height + if end > len(jobs) { + end = len(jobs) + } + jobs = jobs[m.Offset:end] + } + for i, j := range jobs { + globalIdx := m.Offset + i + line := fmt.Sprintf(" %s %s", jobMark(j), truncateStr(j.Name, width-6)) + if globalIdx == m.Cursor { + b.WriteString(styles.ListItemActiveStyle.Render("❯" + line[1:])) + } else { + b.WriteString(styles.ListItemStyle.Render(line)) + } + b.WriteString("\n") + } + 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 new file mode 100644 index 0000000..db7ccba --- /dev/null +++ b/pkg/tui/views/dashboard.go @@ -0,0 +1,185 @@ +package views + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/zaidejjo/zgit/pkg/tui/styles" +) + +// DashEntry is one repository row on the dashboard. +type DashEntry struct { + Name string + Path string + Branch string + Clean bool + Dirty int + Ahead int + Behind int + Favorite bool + Current bool + Err string +} + +// 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 +} + +// NewDashModel creates a dashboard view model. +func NewDashModel() DashModel { + return DashModel{Height: 20} +} + +// UpdateEntries refreshes the dashboard data. +func (m *DashModel) UpdateEntries(entries []DashEntry) { + m.Entries = 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) { + return nil + } + return &visible[m.Cursor] +} + +// View renders the dashboard list. +func (m *DashModel) View(width int) string { + if m.Error != "" { + return styles.ErrorStyle.Render("Error: " + m.Error) + } + if !m.Loaded { + return styles.LoadingStyle.Render("Scanning repositories...") + } + if len(m.Entries) == 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)) + if strings.TrimSpace(m.Filter) != "" { + title = fmt.Sprintf(" Repositories (%d/%d) filter: %s", len(m.Filtered()), len(m.Entries), 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) != "" { + 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("●") + } + + name := e.Name + star := "" + if e.Favorite { + star = styles.StatusUnstagedStyle.Render("★ ") + } + openTag := "" + if e.Current { + openTag = styles.ListItemActiveStyle.Render(" ‹open›") + } + + 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 = "—" + } + + 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)) + } + + if globalIdx == m.Cursor { + b.WriteString(styles.ListItemActiveStyle.Render("❯" + line[1:])) + } else { + b.WriteString(styles.ListItemStyle.Render(line)) + } + b.WriteString("\n") + } + + return b.String() +} + +func (m *DashModel) 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/help.go b/pkg/tui/views/help.go index cb89477..52bdea7 100644 --- a/pkg/tui/views/help.go +++ b/pkg/tui/views/help.go @@ -64,9 +64,20 @@ func allSections() []helpSection { }, }, { - title: "Pull Requests (PR view)", + title: "Views", keys: []helpEntry{ + {"1 / 2", "Focus Status / Log panels, Tab cycles"}, {"3", "Open Pull Requests view"}, + {"4", "Open Issues view"}, + {"5", "Open Repositories dashboard (enter switches repo)"}, + {"6", "Open Actions view (enter drills into jobs)"}, + {"7", "Open Notifications inbox (x marks read)"}, + {"Tab", "Back to panels from any full-screen view"}, + }, + }, + { + title: "Pull Requests (PR view)", + keys: []helpEntry{ {"/", "Filter PR list (enter keeps, esc clears)"}, {"c / o", "Create new PR"}, {"m", "Merge selected PR"}, @@ -77,9 +88,38 @@ func allSections() []helpSection { { title: "Issues (full-screen)", keys: []helpEntry{ - {"4", "Open Issues view"}, {"/", "Filter issue list (enter keeps, esc clears)"}, - {"Tab / Esc", "Back to panels"}, + {"Tab", "Back to panels"}, + }, + }, + { + title: "Repositories (dashboard)", + keys: []helpEntry{ + {"/", "Filter repo list (enter keeps, esc clears)"}, + {"enter", "Switch to selected repository"}, + {"r", "Rescan repositories"}, + {"Tab", "Back to panels"}, + }, + }, + { + title: "Actions (full-screen)", + keys: []helpEntry{ + {"/", "Filter run list (enter keeps, esc clears)"}, + {"enter", "Drill into run jobs"}, + {"backspace", "Back to run list"}, + {"r", "Refresh runs"}, + {"Tab", "Back to panels"}, + }, + }, + { + title: "Notifications (full-screen)", + keys: []helpEntry{ + {"/", "Filter inbox (enter keeps, esc clears)"}, + {"x", "Mark thread read"}, + {"X", "Mark all read"}, + {"u", "Toggle unread / all"}, + {"r", "Refresh inbox"}, + {"Tab", "Back to panels"}, }, }, { diff --git a/pkg/tui/views/newviews_test.go b/pkg/tui/views/newviews_test.go new file mode 100644 index 0000000..f534995 --- /dev/null +++ b/pkg/tui/views/newviews_test.go @@ -0,0 +1,89 @@ +package views + +import ( + "testing" + + "github.com/zaidejjo/zgit/pkg/core/models" +) + +func TestDashFiltered(t *testing.T) { + m := NewDashModel() + m.UpdateEntries([]DashEntry{ + {Name: "zgit", Path: "/r/zgit", Branch: "dev", Clean: true}, + {Name: "zz_lang", Path: "/r/zz_lang", Branch: "main", Clean: false, Dirty: 3}, + }) + if len(m.Filtered()) != 2 { + t.Fatalf("unfiltered len = %d", len(m.Filtered())) + } + m.Filter = "zz_" + if got := m.Filtered(); len(got) != 1 || got[0].Name != "zz_lang" { + t.Fatalf("filtered = %+v", got) + } + m.Filter = "/R/ZGIT" + if len(m.Filtered()) != 1 { + t.Fatalf("case-insensitive path match failed") + } + m.Cursor = 9 + m.ClampCursor() + if m.Cursor != 0 { + t.Fatalf("clamp = %d", m.Cursor) + } + if sel := m.Selected(); sel == nil || sel.Name != "zgit" { + t.Fatalf("selected = %+v", sel) + } +} + +func TestActionsFilteredAndJobs(t *testing.T) { + m := NewActionsModel() + m.UpdateRuns([]*models.WorkflowRun{ + {ID: 1, WorkflowName: "CI", Branch: "dev", HeadSHA: "abc1234", RunNumber: 10, Status: "COMPLETED", Conclusion: "SUCCESS"}, + {ID: 2, WorkflowName: "Docs", Branch: "main", HeadSHA: "def5678", RunNumber: 3, Status: "IN_PROGRESS"}, + }) + m.Filter = "docs" + if got := m.Filtered(); len(got) != 1 || got[0].ID != 2 { + t.Fatalf("filtered = %+v", got) + } + m.Filter = "" + if sel := m.SelectedRun(); sel == nil || sel.ID != 1 { + t.Fatalf("selected = %+v", sel) + } + m.UpdateJobs(1, []*models.Job{{ID: 7, Name: "test", Status: "completed", Conclusion: "success"}}) + if m.JobsFor != 1 || len(m.Jobs) != 1 { + t.Fatalf("drill-in state wrong: %+v", m) + } + if sel := m.SelectedRun(); sel != nil { + t.Fatalf("SelectedRun in job view should be nil") + } +} + +func TestNotesUnreadFirst(t *testing.T) { + m := NewNotesModel() + m.UpdateNotes([]*models.Notification{ + {ID: "1", Title: "old", Unread: false, RepoName: "o/r"}, + {ID: "2", Title: "new", Unread: true, RepoName: "o/r"}, + }) + if got := m.UnreadCount(); got != 1 { + t.Fatalf("unread = %d", got) + } + ordered := m.Ordered() + if len(ordered) != 2 || ordered[0].ID != "2" { + t.Fatalf("unread should sort first: %+v", ordered) + } + if sel := m.Selected(); sel == nil || sel.ID != "2" { + t.Fatalf("cursor should match view order: %+v", sel) + } + m.Filter = "zzz-no-match" + if sel := m.Selected(); sel != nil { + t.Fatalf("selected on empty filter should be nil") + } + m.Filter = "" + m.MarkRead("2") + if m.UnreadCount() != 0 { + t.Fatalf("mark read failed") + } + m.UpdateNotes([]*models.Notification{{ID: "3", Title: "x", Unread: true}}) + m.MarkAllRead() + if m.UnreadCount() != 0 { + t.Fatalf("mark all failed") + } +} diff --git a/pkg/tui/views/notifications.go b/pkg/tui/views/notifications.go new file mode 100644 index 0000000..a98057d --- /dev/null +++ b/pkg/tui/views/notifications.go @@ -0,0 +1,220 @@ +package views + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/zaidejjo/zgit/pkg/core/models" + "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 +} + +// NewNotesModel creates the notifications view model. +func NewNotesModel() NotesModel { + return NotesModel{Height: 20} +} + +// UpdateNotes refreshes the inbox. +func (m *NotesModel) UpdateNotes(items []*models.Notification) { + m.Items = items + m.Loaded = true + m.Error = "" + m.ClampCursor() +} + +// UnreadCount counts unread items locally (for the tab badge). +func (m *NotesModel) UnreadCount() int { + n := 0 + for _, it := range m.Items { + if it.Unread { + n++ + } + } + return n +} + +// MarkRead flips one thread locally (server confirm happens in app keys). +func (m *NotesModel) MarkRead(id string) { + for _, it := range m.Items { + if it.ID == id { + it.Unread = false + } + } +} + +// MarkAllRead flips everything locally. +func (m *NotesModel) MarkAllRead() { + for _, it := range m.Items { + it.Unread = false + } +} + +// 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 { + visible := m.Filtered() + ordered := make([]*models.Notification, 0, len(visible)) + for _, it := range visible { + if it.Unread { + ordered = append(ordered, it) + } + } + for _, it := range visible { + if !it.Unread { + ordered = append(ordered, it) + } + } + return ordered +} + +// Selected returns the cursor item (filter-aware). +func (m *NotesModel) Selected() *models.Notification { + visible := m.Ordered() + if len(visible) == 0 || m.Cursor < 0 || m.Cursor >= len(visible) { + return nil + } + return visible[m.Cursor] +} + +func noteTypeMark(t string) string { + switch t { + case "PullRequest": + return styles.ListItemActiveStyle.Render("◈") + case "Issue": + return styles.StatusStagedStyle.Render("●") + case "CheckSuite": + return styles.StatusUnstagedStyle.Render("◉") + case "Commit": + return styles.HeaderBranchStyle.Render("⬢") + case "Release": + return styles.StatusUnstagedStyle.Render("✦") + default: + return styles.SubtitleStyle.Render("○") + } +} + +// View renders the inbox, unread first. +func (m *NotesModel) View(width int) string { + if m.Error != "" { + return styles.ErrorStyle.Render("Error: " + m.Error) + } + if !m.Loaded { + return styles.LoadingStyle.Render("Loading notifications...") + } + if len(m.Items) == 0 { + if m.ShowAll { + return styles.SubtitleStyle.Render("No notifications at all — enjoy the quiet") + } + return styles.SubtitleStyle.Render("Inbox zero — nothing unread") + } + + m.ensureCursorVisible() + + var b strings.Builder + mode := "Unread" + if m.ShowAll { + mode = "All" + } + title := fmt.Sprintf(" Notifications [%s] (%d)", mode, len(m.Items)) + if strings.TrimSpace(m.Filter) != "" { + title = fmt.Sprintf(" Notifications (%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.Ordered() + + if len(visible) == 0 && strings.TrimSpace(m.Filter) != "" { + return b.String() + styles.SubtitleStyle.Render("No notifications 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, it := range visible { + globalIdx := m.Offset + i + + dot := " " + if it.Unread { + dot = styles.ListItemActiveStyle.Render("●") + } + reason := it.Reason + if reason != "" { + reason = " [" + strings.ReplaceAll(reason, "_", " ") + "]" + } + line := fmt.Sprintf(" %s %s %s %s%s", + dot, noteTypeMark(it.Type), truncateStr(it.Title, 44), + styles.SubtitleStyle.Render(truncateStr(it.RepoName, 26)), styles.SubtitleStyle.Render(reason)) + if lipgloss.Width(line) > width { + line = fmt.Sprintf(" %s %s %s", dot, noteTypeMark(it.Type), truncateStr(it.Title, width-8)) + } + + if globalIdx == m.Cursor { + b.WriteString(styles.ListItemActiveStyle.Render("❯" + line[1:])) + } else { + b.WriteString(styles.ListItemStyle.Render(line)) + } + b.WriteString("\n") + } + + 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 af3aece..f7b93ef 100644 --- a/pkg/tui/views/palette.go +++ b/pkg/tui/views/palette.go @@ -82,6 +82,11 @@ func (m *PaletteModel) SetDefaultCommands() { // Navigation {ID: "next-panel", Label: "Next Panel", Description: "Cycle focus to next panel", Keys: "Tab"}, {ID: "prev-panel", Label: "Prev Panel", Description: "Cycle focus to previous panel", Keys: "S-Tab"}, + {ID: "dashboard", Label: "Repositories", Description: "Open repository dashboard", Keys: "5"}, + {ID: "actions-view", Label: "Actions", Description: "Open CI workflow runs", Keys: "6"}, + {ID: "notifications", Label: "Notifications", Description: "Open GitHub inbox", Keys: "7"}, + {ID: "prs-view", Label: "Pull Requests", Description: "Open pull request list", Keys: "3"}, + {ID: "issues-view", Label: "Issues", Description: "Open issue list", Keys: "4"}, {ID: "help", Label: "Toggle Help", Description: "Show interactive keybindings help", Keys: "?"}, {ID: "refresh", Label: "Refresh State", Description: "Re-fetch all git state", Keys: "R"},