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

@@ -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())
}
}

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"
@@ -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())
}

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
}
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
}

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 {