package kanban import ( "context" "fmt" "strconv" "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/utils/user" "github.com/LazyBachelor/LazyPM/pkg/tui/components" "github.com/LazyBachelor/LazyPM/pkg/tui/modal" "github.com/LazyBachelor/LazyPM/pkg/tui/msgs" ) func (m *Model) refreshIssueListsAndSelectIssue(issueID string) tea.Cmd { ctx := context.Background() backlogIssues, _ := m.app.Issues.GetIssuesNotInAnySprint(ctx) backlogCmd := m.backlogList.SetIssues(backlogIssues) sprintIssues, _ := m.app.Issues.GetIssuesBySprint(ctx, m.currentSprintNum) todoIssues := components.StatusOnly(sprintIssues, models.StatusOpen) inProgIssues := components.StatusOnly(sprintIssues, models.StatusInProgress) blockedIssues := components.StatusOnly(sprintIssues, models.StatusBlocked) doneIssues := components.StatusOnly(sprintIssues, models.StatusClosed) todoCmd := m.todoList.SetIssues(todoIssues) inProgCmd := m.inProgList.SetIssues(inProgIssues) blockedCmd := m.blockedList.SetIssues(blockedIssues) doneCmd := m.doneList.SetIssues(doneIssues) // Find the issue to determine which column it belongs to var targetStatus models.Status var inBacklog bool for _, issue := range sprintIssues { if issue == nil { continue } if issue.ID == issueID { targetStatus = issue.Status m.setDetailIssueWithComments(*issue) break } } // Check if issue is in backlog if targetStatus == "" { for _, issue := range backlogIssues { if issue == nil { continue } if issue.ID == issueID { inBacklog = true m.setDetailIssueWithComments(*issue) break } } } switch { case inBacklog: m.focusManager.SetCurrent(modal.FocusColumn0) case targetStatus == models.StatusOpen: m.focusManager.SetCurrent(modal.FocusColumn1) case targetStatus == models.StatusInProgress: m.focusManager.SetCurrent(modal.FocusColumn2) case targetStatus == models.StatusBlocked: m.focusManager.SetCurrent(modal.FocusColumn3) case targetStatus == models.StatusClosed: m.focusManager.SetCurrent(modal.FocusColumn4) } return tea.Sequence(backlogCmd, todoCmd, inProgCmd, blockedCmd, doneCmd, func() tea.Msg { return msgs.SelectIssueMsg{IssueID: issueID} }) } func (m *Model) refreshAndSubmit(issueID string) tea.Cmd { refreshCmd := m.refreshIssueListsAndSelectIssue(issueID) m.submitValidation() return refreshCmd } // refreshAllIssueLists refreshes all issue lists without selecting a specific issue func (m *Model) refreshAllIssueLists() tea.Cmd { ctx := context.Background() backlogIssues, _ := m.app.Issues.GetIssuesNotInAnySprint(ctx) backlogCmd := m.backlogList.SetIssues(backlogIssues) sprintIssues, _ := m.app.Issues.GetIssuesBySprint(ctx, m.currentSprintNum) todoIssues := components.StatusOnly(sprintIssues, models.StatusOpen) inProgIssues := components.StatusOnly(sprintIssues, models.StatusInProgress) blockedIssues := components.StatusOnly(sprintIssues, models.StatusBlocked) doneIssues := components.StatusOnly(sprintIssues, models.StatusClosed) todoCmd := m.todoList.SetIssues(todoIssues) inProgCmd := m.inProgList.SetIssues(inProgIssues) blockedCmd := m.blockedList.SetIssues(blockedIssues) doneCmd := m.doneList.SetIssues(doneIssues) return tea.Batch(backlogCmd, todoCmd, inProgCmd, blockedCmd, doneCmd) } // Modal action handlers func (m *Model) startConfirmExit() tea.Cmd { return m.modalManager.ShowModal(modal.ModalConfirmExit) } func (m *Model) startEditTitle(selected ListIssue) tea.Cmd { m.currentIssueID = selected.ID titleModal := m.modalManager.GetTextInputModal(modal.ModalEditTitle) if titleModal != nil { titleModal.SetValue(selected.Issue.Title) titleModal.CursorEnd() return m.modalManager.ShowModal(modal.ModalEditTitle) } return nil } func (m *Model) startEditDescription(selected ListIssue) tea.Cmd { m.currentIssueID = selected.ID descModal := m.modalManager.GetTextAreaModal(modal.ModalEditDescription) if descModal != nil { descModal.SetValue(selected.Issue.Description) return m.modalManager.ShowModal(modal.ModalEditDescription) } return nil } func (m *Model) startCreateIssue() tea.Cmd { createModal := m.modalManager.GetTextInputModal(modal.ModalCreateIssue) if createModal != nil { createModal.Reset() return m.modalManager.ShowModal(modal.ModalCreateIssue) } return nil } func (m *Model) startConfirmDelete(issueID string, index int) tea.Cmd { m.currentIssueID = issueID m.deleteIndex = index deleteModal := m.modalManager.GetConfirmModal(modal.ModalConfirmDelete) if deleteModal != nil { return m.modalManager.ShowModal(modal.ModalConfirmDelete) } return nil } func (m *Model) startChooseStatus(selected ListIssue) tea.Cmd { m.currentIssueID = selected.ID return m.modalManager.ShowModal(modal.ModalSelectStatus) } func (m *Model) startChoosePriority(selected ListIssue) tea.Cmd { m.currentIssueID = selected.ID return m.modalManager.ShowModal(modal.ModalSelectPriority) } func (m *Model) startChooseType(selected ListIssue) tea.Cmd { m.currentIssueID = selected.ID return m.modalManager.ShowModal(modal.ModalSelectType) } func (m *Model) startSelectSprint() tea.Cmd { sprints, err := m.app.Issues.GetSprints(context.Background()) if err != nil { return nil } options := make([]modal.SelectOption, 0, len(sprints)) for _, sprintNum := range sprints { if sprintNum == m.backlogNum { continue } label := fmt.Sprintf("Sprint %d", sprintNum) key := fmt.Sprintf("%d", sprintNum) if sprintNum > 9 { continue } options = append(options, modal.SelectOption{ Key: key, Label: label, Value: fmt.Sprintf("%d", sprintNum), }) } if len(options) == 0 { return nil } sprintModal := modal.NewSelectModal(modal.SelectConfig{ ID: "dynamic-sprint-select", Label: "Select sprint to view:", Options: options, CancelKey: "esc", }) return m.modalManager.PushModal(sprintModal) } func (m *Model) startCreateSprint() tea.Cmd { return tea.Sequence( func() tea.Msg { ctx := context.Background() newSprintNum, err := m.app.Issues.AddSprint(ctx) if err != nil { return nil } m.currentSprintNum = newSprintNum return nil }, m.refreshAllIssueLists(), ) } func (m *Model) startEditAssignee(selected ListIssue) tea.Cmd { m.currentIssueID = selected.ID assigneeModal := m.modalManager.GetTextInputModal(modal.ModalEditAssignee) if assigneeModal != nil { assigneeModal.SetValue(selected.Assignee) assigneeModal.CursorEnd() return m.modalManager.ShowModal(modal.ModalEditAssignee) } return nil } func (m *Model) startAddComment(selected ListIssue) tea.Cmd { m.currentIssueID = selected.ID commentModal := m.modalManager.GetTextAreaModal(modal.ModalAddComment) if commentModal != nil { commentModal.Reset() return m.modalManager.ShowModal(modal.ModalAddComment) } return nil } // handleModalCompleted handles all modal completion messages func (m *Model) handleModalCompleted(msg modal.ModalCompletedMsg) tea.Cmd { switch msg.ModalID { case modal.ModalConfirmExit: if r, ok := msg.Value.(modal.ConfirmResult); ok && r.Confirmed { m.logAction("tui confirmed exit") return tea.Quit } case modal.ModalEditTitle: if r, ok := msg.Value.(modal.TextInputResult); ok { cmd := msgs.UpdateIssueTitleCmd(m.app, m.currentIssueID, r.Value) return func() tea.Msg { return cmd() } } case modal.ModalCreateIssue: if r, ok := msg.Value.(modal.TextInputResult); ok && r.Value != "" { cmd := msgs.CreateIssueCmd(m.app, r.Value) return func() tea.Msg { return cmd() } } case modal.ModalEditAssignee: if r, ok := msg.Value.(modal.TextInputResult); ok { cmd := msgs.UpdateIssueAssigneeCmd(m.app, m.currentIssueID, r.Value) return func() tea.Msg { return cmd() } } case modal.ModalEditDescription: if r, ok := msg.Value.(modal.TextAreaResult); ok { cmd := msgs.UpdateIssueDescriptionCmd(m.app, m.currentIssueID, r.Value) return func() tea.Msg { return cmd() } } case modal.ModalConfirmDelete: if r, ok := msg.Value.(modal.ConfirmResult); ok && r.Confirmed { idx := m.deleteIndex issueID := m.currentIssueID m.deleteIndex = -1 m.currentIssueID = "" cmd := msgs.DeleteIssueCmd(m.app, issueID, idx) return func() tea.Msg { return cmd() } } case modal.ModalSelectStatus: if r, ok := msg.Value.(modal.SelectResult); ok { if r.SelectedValue == "closing" { return m.modalManager.ShowModal(modal.ModalSelectCloseReason) } cmd := msgs.UpdateIssueStatusCmd(m.app, m.currentIssueID, r.SelectedValue) return func() tea.Msg { return cmd() } } case modal.ModalSelectCloseReason: if r, ok := msg.Value.(modal.SelectResult); ok { if r.SelectedValue == "other" { return m.modalManager.ShowModal(modal.ModalCloseReason) } cmd := msgs.CloseIssueCmd(m.app, m.currentIssueID, r.SelectedValue) return func() tea.Msg { return cmd() } } case modal.ModalSelectPriority: if r, ok := msg.Value.(modal.SelectResult); ok { priority, err := strconv.Atoi(r.SelectedValue) if err != nil { return nil } cmd := msgs.UpdateIssuePriorityCmd(m.app, m.currentIssueID, priority) return func() tea.Msg { return cmd() } } case modal.ModalSelectType: if r, ok := msg.Value.(modal.SelectResult); ok { issueType := models.IssueType(r.SelectedValue) cmd := msgs.UpdateIssueTypeCmd(m.app, m.currentIssueID, issueType) return func() tea.Msg { return cmd() } } case modal.ModalSelectSprint, "dynamic-sprint-select": if r, ok := msg.Value.(modal.SelectResult); ok { sprintNum, err := strconv.Atoi(r.SelectedValue) if err != nil { return nil } m.currentSprintNum = sprintNum return m.refreshAllIssueLists() } case modal.ModalCloseReason: if r, ok := msg.Value.(modal.TextAreaResult); ok && r.Value != "" { cmd := msgs.CloseIssueCmd(m.app, m.currentIssueID, r.Value) return func() tea.Msg { return cmd() } } case modal.ModalAddComment: if r, ok := msg.Value.(modal.TextAreaResult); ok && r.Value != "" { cmd := msgs.AddIssueCommentCmd(m.app, m.currentIssueID, user.GetOsUsername(), r.Value) return func() tea.Msg { return cmd() } } } return nil } // handleModalCancelled handles all modal cancellation messages func (m *Model) handleModalCancelled(msg modal.ModalCancelledMsg) { switch msg.ModalID { case modal.ModalConfirmExit: m.logAction("tui canceled exit") case modal.ModalEditTitle: m.currentIssueID = "" case modal.ModalCreateIssue: // No cleanup needed case modal.ModalEditAssignee: m.currentIssueID = "" case modal.ModalEditDescription: m.currentIssueID = "" case modal.ModalConfirmDelete: m.deleteIndex = -1 m.currentIssueID = "" case modal.ModalSelectStatus: m.currentIssueID = "" case modal.ModalSelectCloseReason: m.currentIssueID = "" case modal.ModalSelectPriority: m.currentIssueID = "" case modal.ModalSelectType: m.currentIssueID = "" case modal.ModalSelectSprint, "dynamic-sprint-select": case modal.ModalCloseReason: m.currentIssueID = "" case modal.ModalAddComment: m.currentIssueID = "" } } func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd, handled := m.modalManager.Update(msg); handled { return m, cmd } switch msg := msg.(type) { case modal.ModalCompletedMsg: return m, m.handleModalCompleted(msg) case modal.ModalCancelledMsg: m.handleModalCancelled(msg) return m, nil case msgs.TitleUpdatedMsg: m.modalManager.GetTextInputModal(modal.ModalEditTitle).Reset() m.currentIssueID = "" if msg.Err != nil { return m, nil } return m, m.refreshAndSubmit(msg.IssueID) case msgs.DescriptionUpdatedMsg: m.modalManager.GetTextAreaModal(modal.ModalEditDescription).Reset() m.currentIssueID = "" if msg.Err != nil { return m, nil } return m, m.refreshAndSubmit(msg.IssueID) case msgs.StatusUpdatedMsg: m.currentIssueID = "" if msg.Err != nil { return m, nil } return m, m.refreshAndSubmit(msg.IssueID) case msgs.PriorityUpdatedMsg: m.currentIssueID = "" if msg.Err != nil { return m, nil } return m, m.refreshAndSubmit(msg.IssueID) case msgs.TypeUpdatedMsg: m.currentIssueID = "" if msg.Err != nil { return m, nil } return m, m.refreshAndSubmit(msg.IssueID) case msgs.AssigneeUpdatedMsg: m.modalManager.GetTextInputModal(modal.ModalEditAssignee).Reset() m.currentIssueID = "" if msg.Err != nil { return m, nil } return m, m.refreshAndSubmit(msg.IssueID) case msgs.IssueCommentAddedMsg: m.modalManager.GetTextAreaModal(modal.ModalAddComment).Reset() m.currentIssueID = "" if msg.Err != nil { return m, nil } m.submitValidation() return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) case msgs.SelectIssueMsg: m.backlogList.SelectIssueID(msg.IssueID) m.todoList.SelectIssueID(msg.IssueID) m.inProgList.SelectIssueID(msg.IssueID) m.blockedList.SelectIssueID(msg.IssueID) m.doneList.SelectIssueID(msg.IssueID) return m, nil case msgs.CreatedMsg: m.modalManager.GetTextInputModal(modal.ModalCreateIssue).Reset() if msg.Err != nil || msg.Issue == nil { return m, nil } backlogIssues, _ := m.app.Issues.GetIssuesNotInAnySprint(context.Background()) backlogCmd := m.backlogList.SetIssues(backlogIssues) sprintIssues, _ := m.app.Issues.GetIssuesBySprint(context.Background(), m.currentSprintNum) todoIssues := components.StatusOnly(sprintIssues, models.StatusOpen) inProgIssues := components.StatusOnly(sprintIssues, models.StatusInProgress) blockedIssues := components.StatusOnly(sprintIssues, models.StatusBlocked) doneIssues := components.StatusOnly(sprintIssues, models.StatusClosed) todoCmd := m.todoList.SetIssues(todoIssues) inProgCmd := m.inProgList.SetIssues(inProgIssues) blockedCmd := m.blockedList.SetIssues(blockedIssues) doneCmd := m.doneList.SetIssues(doneIssues) allIssues := append(backlogIssues, sprintIssues...) selectedIssue := msg.Issue if selectedIssue.ID == "" { for _, issue := range allIssues { if issue.Title == msg.Issue.Title { selectedIssue = issue break } } } m.setDetailIssueWithComments(*selectedIssue) m.submitValidation() return m, tea.Sequence(backlogCmd, todoCmd, inProgCmd, blockedCmd, doneCmd, func() tea.Msg { return msgs.SelectIssueMsg{IssueID: selectedIssue.ID} }) case msgs.DeletedMsg: m.deleteIndex = -1 m.currentIssueID = "" if msg.Err != nil { return m, nil } allIssues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) if err != nil { return m, nil } 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 len(todoIssues) == 0 && len(inProgIssues) == 0 && len(blockedIssues) == 0 && len(doneIssues) == 0 { m.setDetailIssueWithComments(models.Issue{}) m.submitValidation() return m, tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd) } var targetIssues []*models.Issue switch m.focusManager.Current() { case modal.FocusColumn1: targetIssues = todoIssues if len(targetIssues) == 0 { if len(inProgIssues) > 0 { targetIssues = inProgIssues m.focusManager.SetCurrent(modal.FocusColumn2) } else if len(blockedIssues) > 0 { targetIssues = blockedIssues m.focusManager.SetCurrent(modal.FocusColumn3) } else if len(doneIssues) > 0 { targetIssues = doneIssues m.focusManager.SetCurrent(modal.FocusColumn4) } } case modal.FocusColumn2: targetIssues = inProgIssues if len(targetIssues) == 0 { if len(todoIssues) > 0 { targetIssues = todoIssues m.focusManager.SetCurrent(modal.FocusColumn1) } else if len(blockedIssues) > 0 { targetIssues = blockedIssues m.focusManager.SetCurrent(modal.FocusColumn3) } else if len(doneIssues) > 0 { targetIssues = doneIssues m.focusManager.SetCurrent(modal.FocusColumn4) } } case modal.FocusColumn3: targetIssues = blockedIssues if len(targetIssues) == 0 { if len(inProgIssues) > 0 { targetIssues = inProgIssues m.focusManager.SetCurrent(modal.FocusColumn2) } else if len(todoIssues) > 0 { targetIssues = todoIssues m.focusManager.SetCurrent(modal.FocusColumn1) } else if len(doneIssues) > 0 { targetIssues = doneIssues m.focusManager.SetCurrent(modal.FocusColumn4) } } case modal.FocusColumn4: targetIssues = doneIssues if len(targetIssues) == 0 { if len(blockedIssues) > 0 { targetIssues = blockedIssues m.focusManager.SetCurrent(modal.FocusColumn3) } else if len(inProgIssues) > 0 { targetIssues = inProgIssues m.focusManager.SetCurrent(modal.FocusColumn2) } else if len(todoIssues) > 0 { targetIssues = todoIssues m.focusManager.SetCurrent(modal.FocusColumn1) } } } if len(targetIssues) == 0 { m.setDetailIssueWithComments(models.Issue{}) m.submitValidation() return m, tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd) } newIndex := msg.PreviousIndex if newIndex >= len(targetIssues) { newIndex = len(targetIssues) - 1 } selectedIssue := targetIssues[newIndex] m.setDetailIssueWithComments(*selectedIssue) m.submitValidation() return m, tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd, func() tea.Msg { return msgs.SelectIssueMsg{IssueID: selectedIssue.ID} }) case tea.KeyPressMsg: if msg.String() == "ctrl+c" { return m, tea.Quit } fl := m.FocusedIssueList() if fl.FilterState() == list.Filtering { cmd, _ := fl.Update(msg) return m, cmd } if msg.String() == "esc" { return m, nil } cmd := m.handleKeyPressMsg(msg) if cmd != nil || m.IsInModal() { return m, cmd } case components.ValidationFeedbackMsg: m.currentFeedback = msg.Feedback if msg.Feedback.Success { m.showComplete = true return m, tea.Quit } return m, components.ListenForValidation(m.feedbackChan) case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height m.modalManager.SetSize(msg.Width, msg.Height) return m, nil } fl := m.FocusedIssueList() cmd, changed := fl.Update(msg) if changed { if selected := fl.SelectedItem(); selected.ID != "" { m.setDetailIssueWithComments(selected.Issue) } } // Only propagate non-key messages to other lists (SetItems, etc.) // Key messages should only affect the focused list if _, isKeyMsg := msg.(tea.KeyPressMsg); !isKeyMsg { // Update all lists to ensure they receive commands like SetItems todoCmd, _ := m.todoList.Update(msg) inProgCmd, _ := m.inProgList.Update(msg) blockedCmd, _ := m.blockedList.Update(msg) doneCmd, _ := m.doneList.Update(msg) return m, tea.Sequence(cmd, todoCmd, inProgCmd, blockedCmd, doneCmd) } return m, cmd }