diff --git a/pkg/tui/issues/operations.go b/pkg/tui/issues/operations.go index cf94913..6798692 100644 --- a/pkg/tui/issues/operations.go +++ b/pkg/tui/issues/operations.go @@ -96,3 +96,10 @@ func DeleteIssueCmd(app *app.App, issueID string, currentIndex int) tea.Cmd { return DeletedMsg{IssueID: issueID, Err: err, PreviousIndex: currentIndex} } } + +func CloseIssueCmd(app *app.App, issueID, reason string) tea.Cmd { + return func() tea.Msg { + err := app.Issues.CloseIssue(context.Background(), issueID, reason, "tui", "") + return StatusUpdatedMsg{IssueID: issueID, Err: err} + } +} diff --git a/pkg/tui/views/dashboard/model.go b/pkg/tui/views/dashboard/model.go index d386143..bed24a1 100644 --- a/pkg/tui/views/dashboard/model.go +++ b/pkg/tui/views/dashboard/model.go @@ -46,23 +46,27 @@ type Model struct { deleteConfirmID string deleteConfirmIndex int - choosingStatus bool // true while choosing a status - statusIssueID string - choosingPriority bool // true while choosing a priority - priorityIssueID string - choosingType bool // true while choosing a type - typeIssueID string - editingAssignee bool // true while editing assignee - assigneeInput textinput.Model - assigneeIssueID string - addingComment bool // true while adding a comment - commentInput textarea.Model - commentIssueID string - feedbackChan chan models.ValidationFeedback - quitChan chan bool - currentFeedback models.ValidationFeedback - showComplete bool - submitChan chan<- struct{} + choosingStatus bool + statusIssueID string + choosingPriority bool + priorityIssueID string + choosingType bool + typeIssueID string + editingAssignee bool + assigneeInput textinput.Model + assigneeIssueID string + addingComment bool + commentInput textarea.Model + commentIssueID string + choosingCloseReason bool + closeReasonIssueID string + closingOtherReason bool + closeReasonInput textarea.Model + feedbackChan chan models.ValidationFeedback + quitChan chan bool + currentFeedback models.ValidationFeedback + showComplete bool + submitChan chan<- struct{} } func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool, submitChan chan<- struct{}) *Model { @@ -92,6 +96,12 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui m.descriptionInput = inputs.Description m.assigneeInput = inputs.Assignee + closeReasonTa := textarea.New() + closeReasonTa.Placeholder = "Enter closing reason..." + closeReasonTa.SetWidth(56) + closeReasonTa.SetHeight(4) + m.closeReasonInput = closeReasonTa + commentTa := textarea.New() commentTa.Placeholder = "Write your comment..." commentTa.SetWidth(56) @@ -189,7 +199,9 @@ func (m *Model) Init() tea.Cmd { // IsInModal returns true when a modal (edit, create, delete confirm, choose status/priority/type) is active. func (m *Model) IsInModal() bool { return m.editingTitle || m.creatingIssue || m.editingDescription || - m.choosingStatus || m.choosingPriority || m.confirmingDelete || m.choosingType || m.editingAssignee + m.choosingStatus || m.choosingPriority || m.confirmingDelete || + m.choosingType || m.editingAssignee || + m.choosingCloseReason || m.closingOtherReason } func (m *Model) IsFocusedOnList() bool { diff --git a/pkg/tui/views/dashboard/operations.go b/pkg/tui/views/dashboard/operations.go index ca7ebf6..d514c00 100644 --- a/pkg/tui/views/dashboard/operations.go +++ b/pkg/tui/views/dashboard/operations.go @@ -361,11 +361,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.statusIssueID = "" return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusReadyToSprint)) case "c": - m.logAction("tui selected issue status closed") + m.logAction("tui selected issue status closing") issueID := m.statusIssueID m.choosingStatus = false m.statusIssueID = "" - return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusClosed)) + m.choosingCloseReason = true + m.closeReasonIssueID = issueID + return m, nil case "esc": m.logAction("tui canceled status picker") m.choosingStatus = false @@ -374,6 +376,64 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + if m.choosingCloseReason { + var reason string + switch msg.String() { + case "d": + m.logAction("tui selected close reason done") + reason = "Done" + case "u": + m.logAction("tui selected close reason duplicate issue") + reason = "Duplicate issue" + case "w": + m.logAction("tui selected close reason won't fix") + reason = "Won't fix" + case "o": + m.logAction("tui selected close reason obsolete") + reason = "Obsolete" + case "h": + m.logAction("tui selected close reason other") + m.choosingCloseReason = false + m.closingOtherReason = true + m.closeReasonInput.SetValue("") + return m, nil + case "esc": + m.logAction("tui canceled close reason picker") + m.choosingCloseReason = false + m.closeReasonIssueID = "" + return m, nil + } + + if reason != "" { + issueID := m.closeReasonIssueID + m.choosingCloseReason = false + m.closeReasonIssueID = "" + return m, issues.CloseIssueCmd(m.app, issueID, reason) + } + } + + if m.closingOtherReason { + switch msg.String() { + case "enter": + reason := m.closeReasonInput.Value() + if reason != "" { + m.logAction("tui submitted custom close reason") + issueID := m.closeReasonIssueID + m.closingOtherReason = false + m.closeReasonIssueID = "" + return m, issues.CloseIssueCmd(m.app, issueID, reason) + } + case "esc": + m.logAction("tui canceled custom close reason") + m.closingOtherReason = false + m.closeReasonIssueID = "" + return m, nil + } + var cmd tea.Cmd + m.closeReasonInput, cmd = m.closeReasonInput.Update(msg) + return m, cmd + } + if m.choosingPriority { switch msg.String() { case "0", "1", "2", "3", "4": diff --git a/pkg/tui/views/dashboard/view.go b/pkg/tui/views/dashboard/view.go index 1612a36..c0eb6ec 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 r = ready_to_sprint c = closing (choose reason)"), lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), ) statusBoxWidth := min(50, m.width-4) @@ -174,6 +174,35 @@ func (m *Model) View() string { return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, priorityBox) } + if m.choosingCloseReason { + reasonContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Choose closing reason for "+m.closeReasonIssueID+":"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("d = Done u = Duplicate issue w = Won't fix o = Obsolete h = Other"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), + ) + reasonBoxWidth := min(70, m.width-4) + reasonBox := styles.ContainerStyle. + Width(reasonBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(reasonContent) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, reasonBox) + } + + if m.closingOtherReason { + editBoxWidth := min(60, m.width-4) + m.closeReasonInput.SetWidth(editBoxWidth - 2) + m.closeReasonInput.SetHeight(4) + editContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Enter closing reason for "+m.closeReasonIssueID+" (Enter to save, Esc to cancel):"), + m.closeReasonInput.View(), + ) + editBox := styles.ContainerStyle. + Width(editBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(editContent) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, editBox) + } + if m.choosingType { typeContent := lipgloss.JoinVertical(lipgloss.Left, styles.LabelStyle.Render("Change type for "+m.typeIssueID+":"), diff --git a/pkg/tui/views/kanban/model.go b/pkg/tui/views/kanban/model.go index 988438f..581ddfe 100644 --- a/pkg/tui/views/kanban/model.go +++ b/pkg/tui/views/kanban/model.go @@ -56,11 +56,15 @@ type Model struct { editingAssignee bool // true while editing assignee assigneeInput textinput.Model assigneeIssueID string - feedbackChan chan models.ValidationFeedback - quitChan chan bool - submitChan chan<- struct{} - currentFeedback models.ValidationFeedback - showComplete bool + choosingCloseReason bool // true while choosing a close reason + closeReasonIssueID string + closingOtherReason bool // true while entering a custom close reason + closeReasonInput textarea.Model + feedbackChan chan models.ValidationFeedback + quitChan chan bool + submitChan chan<- struct{} + currentFeedback models.ValidationFeedback + showComplete bool } func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool, submitChan chan<- struct{}) *Model { @@ -94,6 +98,12 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui m.descriptionInput = inputs.Description m.assigneeInput = inputs.Assignee + closeReasonTa := textarea.New() + closeReasonTa.Placeholder = "Enter closing reason..." + closeReasonTa.SetWidth(56) + closeReasonTa.SetHeight(4) + m.closeReasonInput = closeReasonTa + if selected := m.todoList.SelectedItem(); selected.ID != "" { m.issueDetail.SetIssue(selected.Issue) } else if selected := m.inProgList.SelectedItem(); selected.ID != "" { @@ -160,7 +170,9 @@ func (m *Model) Init() tea.Cmd { // IsInModal returns true when a modal (edit, create, delete confirm, choose status/priority/type) is active. func (m *Model) IsInModal() bool { return m.editingTitle || m.creatingIssue || m.editingDescription || - m.choosingStatus || m.choosingPriority || m.confirmingDelete || m.choosingType || m.editingAssignee + m.choosingStatus || m.choosingPriority || m.confirmingDelete || + m.choosingType || m.editingAssignee || + m.choosingCloseReason || m.closingOtherReason } func (m *Model) IsFocusedOnList() bool { diff --git a/pkg/tui/views/kanban/operations.go b/pkg/tui/views/kanban/operations.go index cd6ae23..e0fbbf3 100644 --- a/pkg/tui/views/kanban/operations.go +++ b/pkg/tui/views/kanban/operations.go @@ -262,7 +262,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { issueID := m.statusIssueID m.choosingStatus = false m.statusIssueID = "" - return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusClosed)) + m.choosingCloseReason = true + m.closeReasonIssueID = issueID + return m, nil case "esc": m.choosingStatus = false m.statusIssueID = "" @@ -270,6 +272,56 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + if m.choosingCloseReason { + var reason string + switch msg.String() { + case "d": + reason = "Done" + case "u": + reason = "Duplicate issue" + case "w": + reason = "Won't fix" + case "o": + reason = "Obsolete" + case "h": + m.choosingCloseReason = false + m.closingOtherReason = true + m.closeReasonInput.SetValue("") + return m, nil + case "esc": + m.choosingCloseReason = false + m.closeReasonIssueID = "" + return m, nil + } + + if reason != "" { + issueID := m.closeReasonIssueID + m.choosingCloseReason = false + m.closeReasonIssueID = "" + return m, issues.CloseIssueCmd(m.app, issueID, reason) + } + } + + if m.closingOtherReason { + switch msg.String() { + case "enter": + reason := m.closeReasonInput.Value() + if reason != "" { + issueID := m.closeReasonIssueID + m.closingOtherReason = false + m.closeReasonIssueID = "" + return m, issues.CloseIssueCmd(m.app, issueID, reason) + } + case "esc": + m.closingOtherReason = false + m.closeReasonIssueID = "" + return m, nil + } + var cmd tea.Cmd + m.closeReasonInput, cmd = m.closeReasonInput.Update(msg) + return m, cmd + } + if m.choosingPriority { switch msg.String() { case "0", "1", "2", "3", "4": diff --git a/pkg/tui/views/kanban/view.go b/pkg/tui/views/kanban/view.go index 0cff2c3..f167f20 100644 --- a/pkg/tui/views/kanban/view.go +++ b/pkg/tui/views/kanban/view.go @@ -76,6 +76,35 @@ func (m *Model) View() string { mainView = lipgloss.JoinVertical(lipgloss.Left, header, content, spacer, footer) } + if m.choosingCloseReason { + reasonContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Choose closing reason for "+m.closeReasonIssueID+":"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("d = Done u = Duplicate issue w = Won't fix o = Obsolete h = Other"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), + ) + reasonBoxWidth := min(70, m.width-4) + reasonBox := styles.ContainerStyle. + Width(reasonBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(reasonContent) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, reasonBox) + } + + if m.closingOtherReason { + editBoxWidth := min(60, m.width-4) + m.closeReasonInput.SetWidth(editBoxWidth - 2) + m.closeReasonInput.SetHeight(4) + editContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Enter closing reason for "+m.closeReasonIssueID+" (Enter to save, Esc to cancel):"), + m.closeReasonInput.View(), + ) + editBox := styles.ContainerStyle. + Width(editBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(editContent) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, editBox) + } + return components.RenderModals( m.width, m.height,