adding another tui window for closed issues; adding scrolling for the issue display windows
This commit is contained in:
@@ -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().
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user