From 196145205fd677bc2ace559518ae6ee0fdef6105 Mon Sep 17 00:00:00 2001 From: viljarb Date: Mon, 16 Feb 2026 19:23:02 +0100 Subject: [PATCH] adding another tui window for closed issues; adding scrolling for the issue display windows --- pkg/tui/views/dashboard/help_bar.go | 15 +++-- pkg/tui/views/dashboard/issue_list.go | 49 ++++++++++++++- pkg/tui/views/dashboard/keys.go | 30 +++++---- pkg/tui/views/dashboard/model.go | 90 ++++++++++++++++++++------- pkg/tui/views/dashboard/update.go | 48 +++++++++----- pkg/tui/views/dashboard/view.go | 22 ++++++- 6 files changed, 193 insertions(+), 61 deletions(-) diff --git a/pkg/tui/views/dashboard/help_bar.go b/pkg/tui/views/dashboard/help_bar.go index fee67e6..aa44393 100644 --- a/pkg/tui/views/dashboard/help_bar.go +++ b/pkg/tui/views/dashboard/help_bar.go @@ -31,8 +31,10 @@ func (h HelpBar) View() string { func (h HelpBar) shortHelp() string { keys := []string{ + styles.HighlightKey("tab") + " switch", styles.HighlightKey("↑/k") + " up", styles.HighlightKey("↓/j") + " down", + styles.HighlightKey("pgup/pgdn") + " page", styles.HighlightKey("a") + " add", styles.HighlightKey("e/d/s") + " edit", styles.HighlightKey("x") + " delete", @@ -68,12 +70,13 @@ func (h HelpBar) fullHelp() string { } rows := []string{ - renderRow("↑/k", "up", "enter", "view issue"), - renderRow("↓/j", "down", "b", "back to list"), - renderRow("a", "add issue", "e", "edit title"), - renderRow("d", "edit desc", "s", "change status"), - renderRow("x", "delete issue", "?", "help"), - renderRow("q", "quit", "", ""), + renderRow("tab", "switch window", "↑/k", "up"), + renderRow("enter", "view issue", "↓/j", "down"), + renderRow("pgup", "page up", "pgdn", "page down"), + renderRow("b", "back to list", "a", "add issue"), + renderRow("e", "edit title", "d", "edit desc"), + renderRow("s", "change status", "x", "delete issue"), + renderRow("?", "help", "q", "quit"), } content := lipgloss.JoinVertical(lipgloss.Left, rows...) return lipgloss.NewStyle(). diff --git a/pkg/tui/views/dashboard/issue_list.go b/pkg/tui/views/dashboard/issue_list.go index 11cdf08..64b2dec 100644 --- a/pkg/tui/views/dashboard/issue_list.go +++ b/pkg/tui/views/dashboard/issue_list.go @@ -108,6 +108,51 @@ func NewIssueList(svc *service.Services, width, height int) IssueList { } } +func NewIssueListFromIssues(svc *service.Services, issues []models.Issue, width, height int) IssueList { + // for making an IssueList from a pre-existing list of issues. + listIssues := make([]list.Item, len(issues)) + for i, issue := range issues { + listIssues[i] = ListIssue{Issue: issue} + } + l := list.New(listIssues, NewIssueListDelegate(width), width, height) + l.SetShowTitle(false) + l.SetShowHelp(false) + l.SetShowStatusBar(false) + l.SetFilteringEnabled(true) + l.FilterInput.PromptStyle = styles.FilterPromptStyle + l.FilterInput.Cursor.Style = styles.FilterStyle + l.FilterInput.TextStyle = styles.FilterInputStyle + l.FilterInput.Prompt = "🔍 " + return IssueList{ + list: l, + svc: svc, + width: width, + height: height, + } +} + +func OpenAndInProgressOnly(issues []models.Issue) []models.Issue { + // used for the first window in the dashboard + out := make([]models.Issue, 0, len(issues)) + for _, issue := range issues { + if issue.Status == models.StatusOpen || issue.Status == models.StatusInProgress { + out = append(out, issue) + } + } + return out +} + +func ClosedOnly(issues []models.Issue) []models.Issue { + // used for the second window in the dashboard + out := make([]models.Issue, 0, len(issues)) + for _, issue := range issues { + if issue.Status == models.StatusClosed { + out = append(out, issue) + } + } + return out +} + func (l *IssueList) Update(msg tea.Msg) (tea.Cmd, bool) { var cmd tea.Cmd oldIndex := l.list.Index() @@ -160,9 +205,7 @@ func (l IssueList) renderFilteredItems() string { visibleItems = l.list.Items() } - itemsPerPage := l.list.Paginator.ItemsOnPage(len(visibleItems)) - start := l.list.Paginator.Page * itemsPerPage - end := min(start+itemsPerPage, len(visibleItems)) + start, end := l.list.Paginator.GetSliceBounds(len(visibleItems)) cursor := l.list.Index() diff --git a/pkg/tui/views/dashboard/keys.go b/pkg/tui/views/dashboard/keys.go index 0cb780e..a77ed06 100644 --- a/pkg/tui/views/dashboard/keys.go +++ b/pkg/tui/views/dashboard/keys.go @@ -6,12 +6,13 @@ import ( ) type DashboardKeyMap struct { - Help key.Binding - Quit key.Binding - SelectIssue key.Binding - BackToList key.Binding - ScrollUp key.Binding - ScrollDown key.Binding + Help key.Binding + Quit key.Binding + SelectIssue key.Binding + BackToList key.Binding + ScrollUp key.Binding + ScrollDown key.Binding + SwitchWindow key.Binding EditTitle key.Binding EditDescription key.Binding ChangeStatus key.Binding @@ -44,6 +45,10 @@ var defaultDashboardKeyMap = DashboardKeyMap{ key.WithKeys("down", "j"), key.WithHelp("↓/j", "down"), ), + SwitchWindow: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch window"), + ), EditTitle: key.NewBinding( key.WithKeys("e"), key.WithHelp("e", "edit title"), @@ -74,6 +79,8 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { d.helpBar.ToggleHelp() case key.Matches(msg, d.keyMap.Quit): return tea.Quit + case key.Matches(msg, d.keyMap.SwitchWindow): + d.ToggleFocusedWindow() case d.IsFocusedOnList() && key.Matches(msg, d.keyMap.SelectIssue): d.FocusDetail() case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.BackToList): @@ -83,25 +90,26 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.ScrollDown): d.issueDetail.ScrollDown(1) case !d.editingTitle && !d.creatingIssue && !d.editingDescription && key.Matches(msg, d.keyMap.EditTitle): - if selected := d.issueList.SelectedItem(); selected.ID != "" { + if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startEditTitle(selected) cmd = d.titleInput.Focus() } case !d.editingTitle && !d.creatingIssue && !d.editingDescription && key.Matches(msg, d.keyMap.EditDescription): - if selected := d.issueList.SelectedItem(); selected.ID != "" { + if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startEditDescription(selected) cmd = d.descriptionInput.Focus() } case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && key.Matches(msg, d.keyMap.ChangeStatus): - if selected := d.issueList.SelectedItem(); selected.ID != "" { + if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startChooseStatus(selected) } case !d.editingTitle && !d.creatingIssue && key.Matches(msg, d.keyMap.AddIssue): d.startCreateIssue() cmd = d.createTitleInput.Focus() case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.confirmingDelete && key.Matches(msg, d.keyMap.DeleteIssue): - if selected := d.issueList.SelectedItem(); selected.ID != "" { - d.startConfirmDelete(selected.ID, d.issueList.Index()) + fl := d.FocusedIssueList() + if selected := fl.SelectedItem(); selected.ID != "" { + d.startConfirmDelete(selected.ID, fl.Index()) } } diff --git a/pkg/tui/views/dashboard/model.go b/pkg/tui/views/dashboard/model.go index 1b2f9dd..9cabeb9 100644 --- a/pkg/tui/views/dashboard/model.go +++ b/pkg/tui/views/dashboard/model.go @@ -1,6 +1,8 @@ package dashboard import ( + "context" + "github.com/LazyBachelor/LazyPM/internal/service" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textarea" @@ -8,15 +10,18 @@ import ( ) type Model struct { - header Header - issueList IssueList - issueDetail IssueDetail - helpBar HelpBar - keyMap DashboardKeyMap - svc *service.Services - width int - height int - focusedPane int // 0 = list, 1 = detail + header Header + issueList IssueList + issueDetail IssueDetail + closedIssueList IssueList + helpBar HelpBar + keyMap DashboardKeyMap + svc *service.Services + width int + height int + focusedWindow int // 0 = main (display issues), 1 = closed issues + focusedPaneMain int // 0 = list, 1 = detail + focusedPaneClosed int editingTitle bool // true while we are editing a title titleInput textinput.Model @@ -38,16 +43,20 @@ type Model struct { func NewDashboard(svc *service.Services) *Model { m := &Model{ - header: NewHeader("Project Manager Dashboard"), - keyMap: defaultDashboardKeyMap, - svc: svc, - width: 80, - height: 24, - focusedPane: 0, + header: NewHeader("Project Manager Dashboard"), + keyMap: defaultDashboardKeyMap, + svc: svc, + width: 80, + height: 24, + focusedWindow: 0, + focusedPaneMain: 0, + focusedPaneClosed: 0, } - m.issueList = NewIssueList(svc, 0, 0) + allIssues, _ := svc.Beads.AllIssues(context.Background()) + m.issueList = NewIssueListFromIssues(svc, OpenAndInProgressOnly(allIssues), 0, 0) m.issueDetail = NewIssueDetail() + m.closedIssueList = NewIssueListFromIssues(svc, ClosedOnly(allIssues), 0, 0) m.helpBar = NewHelpBar(m.keyMap) ti := textinput.New() @@ -68,6 +77,8 @@ func NewDashboard(svc *service.Services) *Model { if selected := m.issueList.SelectedItem(); selected.ID != "" { m.issueDetail.SetIssue(selected.Issue) + } else if selected := m.closedIssueList.SelectedItem(); selected.ID != "" { + m.issueDetail.SetIssue(selected.Issue) } return m @@ -109,27 +120,64 @@ func (m *Model) Init() tea.Cmd { } func (m *Model) IsFocusedOnList() bool { - return m.focusedPane == 0 + if m.focusedWindow == 0 { + return m.focusedPaneMain == 0 + } + return m.focusedPaneClosed == 0 } func (m *Model) IsFocusedOnDetail() bool { - return m.focusedPane == 1 + if m.focusedWindow == 0 { + return m.focusedPaneMain == 1 + } + return m.focusedPaneClosed == 1 } func (m *Model) FocusList() { - m.focusedPane = 0 + if m.focusedWindow == 0 { + m.focusedPaneMain = 0 + } else { + m.focusedPaneClosed = 0 + } m.issueDetail.SetFocused(false) } func (m *Model) FocusDetail() { - m.focusedPane = 1 + if m.focusedWindow == 0 { + m.focusedPaneMain = 1 + } else { + m.focusedPaneClosed = 1 + } m.issueDetail.SetFocused(true) } func (m *Model) ToggleFocus() { - if m.focusedPane == 0 { + if m.IsFocusedOnList() { m.FocusDetail() } else { m.FocusList() } } + +// FocusedIssueList returns the issue list of the currently focused window. +func (m *Model) FocusedIssueList() *IssueList { + if m.focusedWindow == 0 { + return &m.issueList + } + return &m.closedIssueList +} + +// ToggleFocusedWindow switches focus between main and closed issues window. +func (m *Model) ToggleFocusedWindow() { + m.focusedWindow = 1 - m.focusedWindow + if m.focusedWindow == 0 { + if selected := m.issueList.SelectedItem(); selected.ID != "" { + m.issueDetail.SetIssue(selected.Issue) + } + } else { + if selected := m.closedIssueList.SelectedItem(); selected.ID != "" { + m.issueDetail.SetIssue(selected.Issue) + } + } + m.issueDetail.SetFocused(m.IsFocusedOnDetail()) +} diff --git a/pkg/tui/views/dashboard/update.go b/pkg/tui/views/dashboard/update.go index 9dc9d3c..cafec00 100644 --- a/pkg/tui/views/dashboard/update.go +++ b/pkg/tui/views/dashboard/update.go @@ -95,14 +95,15 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err != nil { return m, nil } - setItemsCmd := m.issueList.SetIssues(issues) + setItemsCmd := m.issueList.SetIssues(OpenAndInProgressOnly(issues)) + closedSetCmd := m.closedIssueList.SetIssues(ClosedOnly(issues)) for _, issue := range issues { if issue.ID == msg.IssueID { m.issueDetail.SetIssue(issue) break } } - return m, tea.Sequence(setItemsCmd, func() tea.Msg { return selectIssueMsg{IssueID: msg.IssueID} }) + return m, tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { return selectIssueMsg{IssueID: msg.IssueID} }) case issueDescriptionUpdatedMsg: m.editingDescription = false @@ -115,15 +116,15 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err != nil { return m, nil } - setItemsCmd := m.issueList.SetIssues(issues) + setItemsCmd := m.issueList.SetIssues(OpenAndInProgressOnly(issues)) + closedSetCmd := m.closedIssueList.SetIssues(ClosedOnly(issues)) for _, issue := range issues { if issue.ID == msg.IssueID { m.issueDetail.SetIssue(issue) break } } - // after list is updated, select the edited issue again - return m, tea.Sequence(setItemsCmd, func() tea.Msg { return selectIssueMsg{IssueID: msg.IssueID} }) + return m, tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { return selectIssueMsg{IssueID: msg.IssueID} }) case issueStatusUpdatedMsg: m.choosingStatus = false @@ -135,17 +136,19 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err != nil { return m, nil } - setItemsCmd := m.issueList.SetIssues(issues) + setItemsCmd := m.issueList.SetIssues(OpenAndInProgressOnly(issues)) + closedSetCmd := m.closedIssueList.SetIssues(ClosedOnly(issues)) for _, issue := range issues { if issue.ID == msg.IssueID { m.issueDetail.SetIssue(issue) break } } - return m, tea.Sequence(setItemsCmd, func() tea.Msg { return selectIssueMsg{IssueID: msg.IssueID} }) + return m, tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { return selectIssueMsg{IssueID: msg.IssueID} }) case selectIssueMsg: m.issueList.SelectIssueID(msg.IssueID) + m.closedIssueList.SelectIssueID(msg.IssueID) return m, nil case issueCreatedMsg: @@ -159,9 +162,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err != nil { return m, nil } - setItemsCmd := m.issueList.SetIssues(issues) + setItemsCmd := m.issueList.SetIssues(OpenAndInProgressOnly(issues)) + closedSetCmd := m.closedIssueList.SetIssues(ClosedOnly(issues)) m.issueDetail.SetIssue(*msg.Issue) - return m, tea.Sequence(setItemsCmd, func() tea.Msg { return selectIssueMsg{IssueID: msg.Issue.ID} }) + return m, tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { return selectIssueMsg{IssueID: msg.Issue.ID} }) case issueDeletedMsg: m.confirmingDelete = false @@ -173,10 +177,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err != nil { return m, nil } - setItemsCmd := m.issueList.SetIssues(issues) + setItemsCmd := m.issueList.SetIssues(OpenAndInProgressOnly(issues)) + closedSetCmd := m.closedIssueList.SetIssues(ClosedOnly(issues)) if len(issues) == 0 { m.issueDetail.SetIssue(models.Issue{}) - return m, setItemsCmd + return m, tea.Sequence(setItemsCmd, closedSetCmd) } newIndex := msg.PreviousIndex if newIndex >= len(issues) { @@ -184,7 +189,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } selectID := issues[newIndex].ID m.issueDetail.SetIssue(issues[newIndex]) - return m, tea.Sequence(setItemsCmd, func() tea.Msg { return selectIssueMsg{IssueID: selectID} }) + return m, tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { return selectIssueMsg{IssueID: selectID} }) case tea.KeyMsg: if m.confirmingDelete { @@ -282,8 +287,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } - if m.issueList.FilterState() == list.Filtering { - cmd, _ := m.issueList.Update(msg) + focusedList := m.FocusedIssueList() + if focusedList.FilterState() == list.Filtering { + cmd, _ := focusedList.Update(msg) return m, cmd } @@ -298,12 +304,20 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - cmd, changed := m.issueList.Update(msg) + if m.focusedWindow == 0 { + cmd, changed := m.issueList.Update(msg) + if changed { + if selected := m.issueList.SelectedItem(); selected.ID != "" { + m.issueDetail.SetIssue(selected.Issue) + } + } + return m, cmd + } + cmd, changed := m.closedIssueList.Update(msg) if changed { - if selected := m.issueList.SelectedItem(); selected.ID != "" { + if selected := m.closedIssueList.SelectedItem(); selected.ID != "" { m.issueDetail.SetIssue(selected.Issue) } } - return m, cmd } diff --git a/pkg/tui/views/dashboard/view.go b/pkg/tui/views/dashboard/view.go index c17f380..cf0eb7f 100644 --- a/pkg/tui/views/dashboard/view.go +++ b/pkg/tui/views/dashboard/view.go @@ -19,20 +19,36 @@ func (m *Model) View() string { bottomHeight := m.helpBar.Height() contentHeight := m.height - headerHeight - bottomHeight + halfHeight := contentHeight / 2 + if halfHeight < 1 { + halfHeight = 1 + } totalContentWidth := m.width - 1 listWidth := totalContentWidth * styles.ListViewRatio / 100 detailWidth := totalContentWidth - listWidth - m.issueList.SetSize(listWidth, contentHeight) + m.issueList.SetSize(listWidth, halfHeight) + m.closedIssueList.SetSize(listWidth, halfHeight) m.issueDetail.SetSize(detailWidth, contentHeight) listView := m.issueList.View() + closedListView := m.closedIssueList.View() detailView := m.issueDetail.View() - content := lipgloss.JoinHorizontal(lipgloss.Left, listView, detailView) + mainLabel := styles.LabelStyle.Render("Display issues") + closedLabel := styles.LabelStyle.Render("Closed issues") + if m.focusedWindow == 0 { + mainLabel = lipgloss.NewStyle().Foreground(styles.Primary).Bold(true).Render("Display issues ▶") + } else { + closedLabel = lipgloss.NewStyle().Foreground(styles.Primary).Bold(true).Render("Closed issues ▶") + } + leftColumn := lipgloss.JoinVertical(lipgloss.Left, + mainLabel, listView, + closedLabel, closedListView, + ) + content := lipgloss.JoinHorizontal(lipgloss.Left, leftColumn, detailView) - // return lipgloss.JoinVertical(lipgloss.Left, header, content, bottomView) mainView := lipgloss.JoinVertical(lipgloss.Left, header, content, bottomView) if m.editingTitle {