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 {
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().

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) {
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()

View File

@@ -12,6 +12,7 @@ type DashboardKeyMap struct {
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())
}
}

View File

@@ -1,6 +1,8 @@
package dashboard
import (
"context"
"github.com/LazyBachelor/LazyPM/internal/service"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/textarea"
@@ -11,12 +13,15 @@ type Model struct {
header Header
issueList IssueList
issueDetail IssueDetail
closedIssueList IssueList
helpBar HelpBar
keyMap DashboardKeyMap
svc *service.Services
width int
height int
focusedPane int // 0 = list, 1 = detail
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
@@ -43,11 +48,15 @@ func NewDashboard(svc *service.Services) *Model {
svc: svc,
width: 80,
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.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())
}

View File

@@ -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
}
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.closedIssueList.SelectedItem(); selected.ID != "" {
m.issueDetail.SetIssue(selected.Issue)
}
}
return m, cmd
}

View File

@@ -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 {