adding another tui window for closed issues; adding scrolling for the issue display windows

This commit is contained in:
viljarb
2026-02-16 19:23:02 +01:00
parent 501c2a71e5
commit 196145205f
6 changed files with 193 additions and 61 deletions

View File

@@ -31,8 +31,10 @@ func (h HelpBar) View() string {
func (h HelpBar) shortHelp() string { func (h HelpBar) shortHelp() string {
keys := []string{ keys := []string{
styles.HighlightKey("tab") + " switch",
styles.HighlightKey("↑/k") + " up", styles.HighlightKey("↑/k") + " up",
styles.HighlightKey("↓/j") + " down", styles.HighlightKey("↓/j") + " down",
styles.HighlightKey("pgup/pgdn") + " page",
styles.HighlightKey("a") + " add", styles.HighlightKey("a") + " add",
styles.HighlightKey("e/d/s") + " edit", styles.HighlightKey("e/d/s") + " edit",
styles.HighlightKey("x") + " delete", styles.HighlightKey("x") + " delete",
@@ -68,12 +70,13 @@ func (h HelpBar) fullHelp() string {
} }
rows := []string{ rows := []string{
renderRow("↑/k", "up", "enter", "view issue"), renderRow("tab", "switch window", "↑/k", "up"),
renderRow("↓/j", "down", "b", "back to list"), renderRow("enter", "view issue", "↓/j", "down"),
renderRow("a", "add issue", "e", "edit title"), renderRow("pgup", "page up", "pgdn", "page down"),
renderRow("d", "edit desc", "s", "change status"), renderRow("b", "back to list", "a", "add issue"),
renderRow("x", "delete issue", "?", "help"), renderRow("e", "edit title", "d", "edit desc"),
renderRow("q", "quit", "", ""), renderRow("s", "change status", "x", "delete issue"),
renderRow("?", "help", "q", "quit"),
} }
content := lipgloss.JoinVertical(lipgloss.Left, rows...) content := lipgloss.JoinVertical(lipgloss.Left, rows...)
return lipgloss.NewStyle(). return lipgloss.NewStyle().

View File

@@ -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) { func (l *IssueList) Update(msg tea.Msg) (tea.Cmd, bool) {
var cmd tea.Cmd var cmd tea.Cmd
oldIndex := l.list.Index() oldIndex := l.list.Index()
@@ -160,9 +205,7 @@ func (l IssueList) renderFilteredItems() string {
visibleItems = l.list.Items() visibleItems = l.list.Items()
} }
itemsPerPage := l.list.Paginator.ItemsOnPage(len(visibleItems)) start, end := l.list.Paginator.GetSliceBounds(len(visibleItems))
start := l.list.Paginator.Page * itemsPerPage
end := min(start+itemsPerPage, len(visibleItems))
cursor := l.list.Index() cursor := l.list.Index()

View File

@@ -6,12 +6,13 @@ import (
) )
type DashboardKeyMap struct { type DashboardKeyMap struct {
Help key.Binding Help key.Binding
Quit key.Binding Quit key.Binding
SelectIssue key.Binding SelectIssue key.Binding
BackToList key.Binding BackToList key.Binding
ScrollUp key.Binding ScrollUp key.Binding
ScrollDown key.Binding ScrollDown key.Binding
SwitchWindow key.Binding
EditTitle key.Binding EditTitle key.Binding
EditDescription key.Binding EditDescription key.Binding
ChangeStatus key.Binding ChangeStatus key.Binding
@@ -44,6 +45,10 @@ var defaultDashboardKeyMap = DashboardKeyMap{
key.WithKeys("down", "j"), key.WithKeys("down", "j"),
key.WithHelp("↓/j", "down"), key.WithHelp("↓/j", "down"),
), ),
SwitchWindow: key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("tab", "switch window"),
),
EditTitle: key.NewBinding( EditTitle: key.NewBinding(
key.WithKeys("e"), key.WithKeys("e"),
key.WithHelp("e", "edit title"), key.WithHelp("e", "edit title"),
@@ -74,6 +79,8 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd {
d.helpBar.ToggleHelp() d.helpBar.ToggleHelp()
case key.Matches(msg, d.keyMap.Quit): case key.Matches(msg, d.keyMap.Quit):
return tea.Quit return tea.Quit
case key.Matches(msg, d.keyMap.SwitchWindow):
d.ToggleFocusedWindow()
case d.IsFocusedOnList() && key.Matches(msg, d.keyMap.SelectIssue): case d.IsFocusedOnList() && key.Matches(msg, d.keyMap.SelectIssue):
d.FocusDetail() d.FocusDetail()
case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.BackToList): 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): case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.ScrollDown):
d.issueDetail.ScrollDown(1) d.issueDetail.ScrollDown(1)
case !d.editingTitle && !d.creatingIssue && !d.editingDescription && key.Matches(msg, d.keyMap.EditTitle): 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) d.startEditTitle(selected)
cmd = d.titleInput.Focus() cmd = d.titleInput.Focus()
} }
case !d.editingTitle && !d.creatingIssue && !d.editingDescription && key.Matches(msg, d.keyMap.EditDescription): 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) d.startEditDescription(selected)
cmd = d.descriptionInput.Focus() cmd = d.descriptionInput.Focus()
} }
case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && key.Matches(msg, d.keyMap.ChangeStatus): 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) d.startChooseStatus(selected)
} }
case !d.editingTitle && !d.creatingIssue && key.Matches(msg, d.keyMap.AddIssue): case !d.editingTitle && !d.creatingIssue && key.Matches(msg, d.keyMap.AddIssue):
d.startCreateIssue() d.startCreateIssue()
cmd = d.createTitleInput.Focus() cmd = d.createTitleInput.Focus()
case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.confirmingDelete && key.Matches(msg, d.keyMap.DeleteIssue): case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.confirmingDelete && key.Matches(msg, d.keyMap.DeleteIssue):
if selected := d.issueList.SelectedItem(); selected.ID != "" { fl := d.FocusedIssueList()
d.startConfirmDelete(selected.ID, d.issueList.Index()) if selected := fl.SelectedItem(); selected.ID != "" {
d.startConfirmDelete(selected.ID, fl.Index())
} }
} }

View File

@@ -1,6 +1,8 @@
package dashboard package dashboard
import ( import (
"context"
"github.com/LazyBachelor/LazyPM/internal/service" "github.com/LazyBachelor/LazyPM/internal/service"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/textarea"
@@ -8,15 +10,18 @@ import (
) )
type Model struct { type Model struct {
header Header header Header
issueList IssueList issueList IssueList
issueDetail IssueDetail issueDetail IssueDetail
helpBar HelpBar closedIssueList IssueList
keyMap DashboardKeyMap helpBar HelpBar
svc *service.Services keyMap DashboardKeyMap
width int svc *service.Services
height int width int
focusedPane int // 0 = list, 1 = detail 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 editingTitle bool // true while we are editing a title
titleInput textinput.Model titleInput textinput.Model
@@ -38,16 +43,20 @@ type Model struct {
func NewDashboard(svc *service.Services) *Model { func NewDashboard(svc *service.Services) *Model {
m := &Model{ m := &Model{
header: NewHeader("Project Manager Dashboard"), header: NewHeader("Project Manager Dashboard"),
keyMap: defaultDashboardKeyMap, keyMap: defaultDashboardKeyMap,
svc: svc, svc: svc,
width: 80, width: 80,
height: 24, height: 24,
focusedPane: 0, 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.issueDetail = NewIssueDetail()
m.closedIssueList = NewIssueListFromIssues(svc, ClosedOnly(allIssues), 0, 0)
m.helpBar = NewHelpBar(m.keyMap) m.helpBar = NewHelpBar(m.keyMap)
ti := textinput.New() ti := textinput.New()
@@ -68,6 +77,8 @@ func NewDashboard(svc *service.Services) *Model {
if selected := m.issueList.SelectedItem(); selected.ID != "" { if selected := m.issueList.SelectedItem(); selected.ID != "" {
m.issueDetail.SetIssue(selected.Issue) m.issueDetail.SetIssue(selected.Issue)
} else if selected := m.closedIssueList.SelectedItem(); selected.ID != "" {
m.issueDetail.SetIssue(selected.Issue)
} }
return m return m
@@ -109,27 +120,64 @@ func (m *Model) Init() tea.Cmd {
} }
func (m *Model) IsFocusedOnList() bool { 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 { 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() { func (m *Model) FocusList() {
m.focusedPane = 0 if m.focusedWindow == 0 {
m.focusedPaneMain = 0
} else {
m.focusedPaneClosed = 0
}
m.issueDetail.SetFocused(false) m.issueDetail.SetFocused(false)
} }
func (m *Model) FocusDetail() { func (m *Model) FocusDetail() {
m.focusedPane = 1 if m.focusedWindow == 0 {
m.focusedPaneMain = 1
} else {
m.focusedPaneClosed = 1
}
m.issueDetail.SetFocused(true) m.issueDetail.SetFocused(true)
} }
func (m *Model) ToggleFocus() { func (m *Model) ToggleFocus() {
if m.focusedPane == 0 { if m.IsFocusedOnList() {
m.FocusDetail() m.FocusDetail()
} else { } else {
m.FocusList() 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())
}

View File

@@ -95,14 +95,15 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if err != nil { if err != nil {
return m, 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 { for _, issue := range issues {
if issue.ID == msg.IssueID { if issue.ID == msg.IssueID {
m.issueDetail.SetIssue(issue) m.issueDetail.SetIssue(issue)
break 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: case issueDescriptionUpdatedMsg:
m.editingDescription = false m.editingDescription = false
@@ -115,15 +116,15 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if err != nil { if err != nil {
return m, 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 { for _, issue := range issues {
if issue.ID == msg.IssueID { if issue.ID == msg.IssueID {
m.issueDetail.SetIssue(issue) m.issueDetail.SetIssue(issue)
break break
} }
} }
// after list is updated, select the edited issue again return m, tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { return selectIssueMsg{IssueID: msg.IssueID} })
return m, tea.Sequence(setItemsCmd, func() tea.Msg { return selectIssueMsg{IssueID: msg.IssueID} })
case issueStatusUpdatedMsg: case issueStatusUpdatedMsg:
m.choosingStatus = false m.choosingStatus = false
@@ -135,17 +136,19 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if err != nil { if err != nil {
return m, 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 { for _, issue := range issues {
if issue.ID == msg.IssueID { if issue.ID == msg.IssueID {
m.issueDetail.SetIssue(issue) m.issueDetail.SetIssue(issue)
break 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: case selectIssueMsg:
m.issueList.SelectIssueID(msg.IssueID) m.issueList.SelectIssueID(msg.IssueID)
m.closedIssueList.SelectIssueID(msg.IssueID)
return m, nil return m, nil
case issueCreatedMsg: case issueCreatedMsg:
@@ -159,9 +162,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if err != nil { if err != nil {
return m, 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) 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: case issueDeletedMsg:
m.confirmingDelete = false m.confirmingDelete = false
@@ -173,10 +177,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if err != nil { if err != nil {
return m, 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 { if len(issues) == 0 {
m.issueDetail.SetIssue(models.Issue{}) m.issueDetail.SetIssue(models.Issue{})
return m, setItemsCmd return m, tea.Sequence(setItemsCmd, closedSetCmd)
} }
newIndex := msg.PreviousIndex newIndex := msg.PreviousIndex
if newIndex >= len(issues) { if newIndex >= len(issues) {
@@ -184,7 +189,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
selectID := issues[newIndex].ID selectID := issues[newIndex].ID
m.issueDetail.SetIssue(issues[newIndex]) 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: case tea.KeyMsg:
if m.confirmingDelete { if m.confirmingDelete {
@@ -282,8 +287,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd return m, cmd
} }
if m.issueList.FilterState() == list.Filtering { focusedList := m.FocusedIssueList()
cmd, _ := m.issueList.Update(msg) if focusedList.FilterState() == list.Filtering {
cmd, _ := focusedList.Update(msg)
return m, cmd return m, cmd
} }
@@ -298,12 +304,20 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil 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 changed {
if selected := m.issueList.SelectedItem(); selected.ID != "" { if selected := m.closedIssueList.SelectedItem(); selected.ID != "" {
m.issueDetail.SetIssue(selected.Issue) m.issueDetail.SetIssue(selected.Issue)
} }
} }
return m, cmd return m, cmd
} }

View File

@@ -19,20 +19,36 @@ func (m *Model) View() string {
bottomHeight := m.helpBar.Height() bottomHeight := m.helpBar.Height()
contentHeight := m.height - headerHeight - bottomHeight contentHeight := m.height - headerHeight - bottomHeight
halfHeight := contentHeight / 2
if halfHeight < 1 {
halfHeight = 1
}
totalContentWidth := m.width - 1 totalContentWidth := m.width - 1
listWidth := totalContentWidth * styles.ListViewRatio / 100 listWidth := totalContentWidth * styles.ListViewRatio / 100
detailWidth := totalContentWidth - listWidth detailWidth := totalContentWidth - listWidth
m.issueList.SetSize(listWidth, contentHeight) m.issueList.SetSize(listWidth, halfHeight)
m.closedIssueList.SetSize(listWidth, halfHeight)
m.issueDetail.SetSize(detailWidth, contentHeight) m.issueDetail.SetSize(detailWidth, contentHeight)
listView := m.issueList.View() listView := m.issueList.View()
closedListView := m.closedIssueList.View()
detailView := m.issueDetail.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) mainView := lipgloss.JoinVertical(lipgloss.Left, header, content, bottomView)
if m.editingTitle { if m.editingTitle {