From 1f98308d9b96b090cf531fb42bbb06f659dc6d62 Mon Sep 17 00:00:00 2001 From: viljarb Date: Thu, 12 Mar 2026 13:18:15 +0100 Subject: [PATCH] adding blocked status to tui --- pkg/tui/components/issue_list.go | 1 + pkg/tui/components/modals.go | 2 +- pkg/tui/styles/styles.go | 2 + pkg/tui/views/dashboard/operations.go | 6 +++ pkg/tui/views/dashboard/view.go | 2 +- pkg/tui/views/kanban/keys.go | 2 +- pkg/tui/views/kanban/model.go | 13 +++++- pkg/tui/views/kanban/operations.go | 58 ++++++++++++++++++++++----- pkg/tui/views/kanban/view.go | 12 ++++-- 9 files changed, 80 insertions(+), 18 deletions(-) diff --git a/pkg/tui/components/issue_list.go b/pkg/tui/components/issue_list.go index ff920ef..da81d08 100644 --- a/pkg/tui/components/issue_list.go +++ b/pkg/tui/components/issue_list.go @@ -230,6 +230,7 @@ func OpenAndInProgressOnly(issues []*models.Issue) []*models.Issue { for _, issue := range issues { if issue.Status == models.StatusOpen || issue.Status == models.StatusInProgress || + issue.Status == models.StatusBlocked || issue.Status == models.StatusReadyToSprint { out = append(out, issue) } diff --git a/pkg/tui/components/modals.go b/pkg/tui/components/modals.go index c6f2e21..5eee8d7 100644 --- a/pkg/tui/components/modals.go +++ b/pkg/tui/components/modals.go @@ -90,7 +90,7 @@ func RenderChooseStatus(width, height int, issueID string) string { } statusContent := lipgloss.JoinVertical(lipgloss.Left, styles.LabelStyle.Render("Change status for "+issueID+":"), - lipgloss.NewStyle().Foreground(styles.FaintText).Render("o = open i = in_progress c = closed ready_to_sprint = r"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("o = open i = in_progress b = blocked r = ready_to_sprint c = closed"), lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), ) statusBoxWidth := modalBoxWidth(50, width) diff --git a/pkg/tui/styles/styles.go b/pkg/tui/styles/styles.go index 076b214..73909f3 100644 --- a/pkg/tui/styles/styles.go +++ b/pkg/tui/styles/styles.go @@ -66,6 +66,8 @@ func StatusStyle(status string) lipgloss.Style { return style.Foreground(FaintText) case "in_progress": return style.Foreground(Warning) + case "blocked": + return style.Foreground(Error) default: return style.Foreground(SecondaryText) } diff --git a/pkg/tui/views/dashboard/operations.go b/pkg/tui/views/dashboard/operations.go index ca7ebf6..b3a8405 100644 --- a/pkg/tui/views/dashboard/operations.go +++ b/pkg/tui/views/dashboard/operations.go @@ -354,6 +354,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.choosingStatus = false m.statusIssueID = "" return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusInProgress)) + case "b": + m.logAction("tui selected issue status blocked") + issueID := m.statusIssueID + m.choosingStatus = false + m.statusIssueID = "" + return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusBlocked)) case "r": m.logAction("tui selected issue status ready_to_sprint") issueID := m.statusIssueID diff --git a/pkg/tui/views/dashboard/view.go b/pkg/tui/views/dashboard/view.go index 1612a36..87daf94 100644 --- a/pkg/tui/views/dashboard/view.go +++ b/pkg/tui/views/dashboard/view.go @@ -149,7 +149,7 @@ func (m *Model) View() string { if m.choosingStatus { statusContent := lipgloss.JoinVertical(lipgloss.Left, styles.LabelStyle.Render("Change status for "+m.statusIssueID+":"), - lipgloss.NewStyle().Foreground(styles.FaintText).Render("o = open i = in_progress c = closed ready_to_sprint = r"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("o = open i = in_progress b = blocked r = ready_to_sprint c = closed"), lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), ) statusBoxWidth := min(50, m.width-4) diff --git a/pkg/tui/views/kanban/keys.go b/pkg/tui/views/kanban/keys.go index db11527..df8345b 100644 --- a/pkg/tui/views/kanban/keys.go +++ b/pkg/tui/views/kanban/keys.go @@ -70,7 +70,7 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { d.updateDetailFromSelection() } case !d.IsInModal() && key.Matches(msg, d.keyMap.MoveColumnRight): - if d.focusedColumn < 2 { + if d.focusedColumn < 3 { d.focusedColumn++ d.updateDetailFromSelection() } diff --git a/pkg/tui/views/kanban/model.go b/pkg/tui/views/kanban/model.go index 988438f..54244fc 100644 --- a/pkg/tui/views/kanban/model.go +++ b/pkg/tui/views/kanban/model.go @@ -22,6 +22,7 @@ type Model struct { header Header todoList IssueList inProgList IssueList + blockedList IssueList doneList IssueList issueDetail IssueDetail helpBar components.HelpBar @@ -30,7 +31,7 @@ type Model struct { width int height int - focusedColumn int // 0 = To Do, 1 = In Progress, 2 = Done + focusedColumn int // 0 = To Do, 1 = In Progress, 2 = Blocked, 3 = Done focusOnDetail bool // true when detail pane is focused editingTitle bool // true while we are editing a title @@ -80,10 +81,12 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui allIssues, _ := app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) todoIssues := components.StatusOnly(allIssues, models.StatusOpen) inProgIssues := components.StatusOnly(allIssues, models.StatusInProgress) + blockedIssues := components.StatusOnly(allIssues, models.StatusBlocked) doneIssues := components.StatusOnly(allIssues, models.StatusClosed) m.todoList = components.NewIssueListFromIssues(app, todoIssues, 0, 0) m.inProgList = components.NewIssueListFromIssues(app, inProgIssues, 0, 0) + m.blockedList = components.NewIssueListFromIssues(app, blockedIssues, 0, 0) m.doneList = components.NewIssueListFromIssues(app, doneIssues, 0, 0) m.issueDetail = components.NewIssueDetail() m.helpBar = components.NewHelpBar(components.ViewKanban) @@ -98,6 +101,8 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui m.issueDetail.SetIssue(selected.Issue) } else if selected := m.inProgList.SelectedItem(); selected.ID != "" { m.issueDetail.SetIssue(selected.Issue) + } else if selected := m.blockedList.SelectedItem(); selected.ID != "" { + m.issueDetail.SetIssue(selected.Issue) } else if selected := m.doneList.SelectedItem(); selected.ID != "" { m.issueDetail.SetIssue(selected.Issue) } @@ -196,6 +201,8 @@ func (m *Model) FocusedIssueList() *IssueList { case 1: return &m.inProgList case 2: + return &m.blockedList + case 3: return &m.doneList default: return &m.todoList @@ -219,6 +226,8 @@ func statusForColumn(col int) models.Status { case 1: return models.StatusInProgress case 2: + return models.StatusBlocked + case 3: return models.StatusClosed default: return models.StatusOpen @@ -235,7 +244,7 @@ func (m *Model) moveIssue(delta int) tea.Cmd { } newCol := m.focusedColumn + delta - if newCol < 0 || newCol > 2 { + if newCol < 0 || newCol > 3 { return nil } diff --git a/pkg/tui/views/kanban/operations.go b/pkg/tui/views/kanban/operations.go index cd6ae23..8cfda91 100644 --- a/pkg/tui/views/kanban/operations.go +++ b/pkg/tui/views/kanban/operations.go @@ -21,10 +21,12 @@ func (m *Model) refreshIssueListsAndSelectIssue(issueID string) tea.Cmd { todoIssues := components.StatusOnly(allIssues, models.StatusOpen) inProgIssues := components.StatusOnly(allIssues, models.StatusInProgress) + blockedIssues := components.StatusOnly(allIssues, models.StatusBlocked) doneIssues := components.StatusOnly(allIssues, models.StatusClosed) todoCmd := m.todoList.SetIssues(todoIssues) inProgCmd := m.inProgList.SetIssues(inProgIssues) + blockedCmd := m.blockedList.SetIssues(blockedIssues) doneCmd := m.doneList.SetIssues(doneIssues) var targetStatus models.Status @@ -41,16 +43,19 @@ func (m *Model) refreshIssueListsAndSelectIssue(issueID string) tea.Cmd { m.focusedColumn = 0 case models.StatusInProgress: m.focusedColumn = 1 - case models.StatusClosed: + case models.StatusBlocked: m.focusedColumn = 2 + case models.StatusClosed: + m.focusedColumn = 3 } // Select the moved issue in its new column immediately so the highlight follows it. m.todoList.SelectIssueID(issueID) m.inProgList.SelectIssueID(issueID) + m.blockedList.SelectIssueID(issueID) m.doneList.SelectIssueID(issueID) - return tea.Sequence(todoCmd, inProgCmd, doneCmd) + return tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd) } func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -109,6 +114,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case issues.SelectIssueMsg: m.todoList.SelectIssueID(msg.IssueID) m.inProgList.SelectIssueID(msg.IssueID) + m.blockedList.SelectIssueID(msg.IssueID) m.doneList.SelectIssueID(msg.IssueID) return m, nil @@ -126,10 +132,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { todoIssues := components.StatusOnly(allIssues, models.StatusOpen) inProgIssues := components.StatusOnly(allIssues, models.StatusInProgress) + blockedIssues := components.StatusOnly(allIssues, models.StatusBlocked) doneIssues := components.StatusOnly(allIssues, models.StatusClosed) todoCmd := m.todoList.SetIssues(todoIssues) inProgCmd := m.inProgList.SetIssues(inProgIssues) + blockedCmd := m.blockedList.SetIssues(blockedIssues) doneCmd := m.doneList.SetIssues(doneIssues) // Determine the created issue from the refreshed list to ensure all fields (like ID) are populated. @@ -145,7 +153,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.issueDetail.SetIssue(*selectedIssue) - return m, tea.Sequence(todoCmd, inProgCmd, doneCmd, func() tea.Msg { return issues.SelectIssueMsg{IssueID: selectedIssue.ID} }) + return m, tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd, func() tea.Msg { return issues.SelectIssueMsg{IssueID: selectedIssue.ID} }) case issues.DeletedMsg: m.confirmingDelete = false m.deleteConfirmID = "" @@ -159,16 +167,18 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { todoIssues := components.StatusOnly(allIssues, models.StatusOpen) inProgIssues := components.StatusOnly(allIssues, models.StatusInProgress) + blockedIssues := components.StatusOnly(allIssues, models.StatusBlocked) doneIssues := components.StatusOnly(allIssues, models.StatusClosed) todoCmd := m.todoList.SetIssues(todoIssues) inProgCmd := m.inProgList.SetIssues(inProgIssues) + blockedCmd := m.blockedList.SetIssues(blockedIssues) doneCmd := m.doneList.SetIssues(doneIssues) // If there are no issues at all, clear the detail view and return. - if len(todoIssues) == 0 && len(inProgIssues) == 0 && len(doneIssues) == 0 { + if len(todoIssues) == 0 && len(inProgIssues) == 0 && len(blockedIssues) == 0 && len(doneIssues) == 0 { m.issueDetail.SetIssue(models.Issue{}) - return m, tea.Sequence(todoCmd, inProgCmd, doneCmd) + return m, tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd) } // Determine which column to use for the next selection based on the current focus. @@ -180,9 +190,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(inProgIssues) > 0 { targetIssues = inProgIssues m.focusedColumn = 1 + } else if len(blockedIssues) > 0 { + targetIssues = blockedIssues + m.focusedColumn = 2 } else if len(doneIssues) > 0 { targetIssues = doneIssues - m.focusedColumn = 2 + m.focusedColumn = 3 } } case 1: @@ -191,13 +204,16 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(todoIssues) > 0 { targetIssues = todoIssues m.focusedColumn = 0 + } else if len(blockedIssues) > 0 { + targetIssues = blockedIssues + m.focusedColumn = 2 } else if len(doneIssues) > 0 { targetIssues = doneIssues - m.focusedColumn = 2 + m.focusedColumn = 3 } } case 2: - targetIssues = doneIssues + targetIssues = blockedIssues if len(targetIssues) == 0 { if len(inProgIssues) > 0 { targetIssues = inProgIssues @@ -205,6 +221,23 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else if len(todoIssues) > 0 { targetIssues = todoIssues m.focusedColumn = 0 + } else if len(doneIssues) > 0 { + targetIssues = doneIssues + m.focusedColumn = 3 + } + } + case 3: + targetIssues = doneIssues + if len(targetIssues) == 0 { + if len(blockedIssues) > 0 { + targetIssues = blockedIssues + m.focusedColumn = 2 + } else if len(inProgIssues) > 0 { + targetIssues = inProgIssues + m.focusedColumn = 1 + } else if len(todoIssues) > 0 { + targetIssues = todoIssues + m.focusedColumn = 0 } } } @@ -212,7 +245,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Safety: if targetIssues is still empty here, just clear detail and return. if len(targetIssues) == 0 { m.issueDetail.SetIssue(models.Issue{}) - return m, tea.Sequence(todoCmd, inProgCmd, doneCmd) + return m, tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd) } newIndex := msg.PreviousIndex @@ -221,7 +254,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } selectedIssue := targetIssues[newIndex] m.issueDetail.SetIssue(*selectedIssue) - return m, tea.Sequence(todoCmd, inProgCmd, doneCmd, func() tea.Msg { + return m, tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd, func() tea.Msg { return issues.SelectIssueMsg{IssueID: selectedIssue.ID} }) @@ -253,6 +286,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.choosingStatus = false m.statusIssueID = "" return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusInProgress)) + case "b": + issueID := m.statusIssueID + m.choosingStatus = false + m.statusIssueID = "" + return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusBlocked)) case "r": issueID := m.statusIssueID m.choosingStatus = false diff --git a/pkg/tui/views/kanban/view.go b/pkg/tui/views/kanban/view.go index 0cff2c3..e1bd5d1 100644 --- a/pkg/tui/views/kanban/view.go +++ b/pkg/tui/views/kanban/view.go @@ -22,7 +22,7 @@ func (m *Model) View() string { contentHeight := m.height - headerHeight - footerHeight totalContentWidth := m.width - 1 - colWidth := totalContentWidth / 3 + colWidth := totalContentWidth / 4 if colWidth < 20 { colWidth = 20 } @@ -35,18 +35,21 @@ func (m *Model) View() string { m.todoList.SetSize(colWidth, boardHeight-1) m.inProgList.SetSize(colWidth, boardHeight-1) + m.blockedList.SetSize(colWidth, boardHeight-1) m.doneList.SetSize(colWidth, boardHeight-1) // Only highlight the selected row in the focused column. m.todoList.SetHighlightSelected(!m.focusOnDetail && m.focusedColumn == 0) m.inProgList.SetHighlightSelected(!m.focusOnDetail && m.focusedColumn == 1) - m.doneList.SetHighlightSelected(!m.focusOnDetail && m.focusedColumn == 2) + m.blockedList.SetHighlightSelected(!m.focusOnDetail && m.focusedColumn == 2) + m.doneList.SetHighlightSelected(!m.focusOnDetail && m.focusedColumn == 3) // Detail view takes full width below the board. m.issueDetail.SetSize(totalContentWidth, contentHeight-boardHeight) todoLabel := styles.LabelStyle.Render("To Do") inProgLabel := styles.LabelStyle.Render("In Progress") + blockedLabel := styles.LabelStyle.Render("Blocked") doneLabel := styles.LabelStyle.Render("Done") highlight := lipgloss.NewStyle().Foreground(styles.Primary).Bold(true) @@ -56,14 +59,17 @@ func (m *Model) View() string { case 1: inProgLabel = highlight.Render("In Progress ▶") case 2: + blockedLabel = highlight.Render("Blocked ▶") + case 3: doneLabel = highlight.Render("Done ▶") } todoCol := lipgloss.JoinVertical(lipgloss.Left, todoLabel, m.todoList.View()) inProgCol := lipgloss.JoinVertical(lipgloss.Left, inProgLabel, m.inProgList.View()) + blockedCol := lipgloss.JoinVertical(lipgloss.Left, blockedLabel, m.blockedList.View()) doneCol := lipgloss.JoinVertical(lipgloss.Left, doneLabel, m.doneList.View()) - board := lipgloss.JoinHorizontal(lipgloss.Left, todoCol, inProgCol, doneCol) + board := lipgloss.JoinHorizontal(lipgloss.Left, todoCol, inProgCol, blockedCol, doneCol) content := lipgloss.JoinVertical(lipgloss.Left, board, m.issueDetail.View()) // Add spacer to lock footer to bottom of screen when content is shorter than available space