From 802d379de8ff89dffdf8db9398fced4b273af931 Mon Sep 17 00:00:00 2001 From: viljarb Date: Tue, 24 Mar 2026 19:57:45 +0100 Subject: [PATCH] implementing dependency management in tui --- pkg/tui/components/helpbar.go | 6 +- pkg/tui/components/issue_detail.go | 43 ++- pkg/tui/components/keymap.go | 11 +- pkg/tui/modal/dependencies.go | 444 ++++++++++++++++++++++++++ pkg/tui/modal/modal.go | 5 +- pkg/tui/msgs/msgs.go | 39 +++ pkg/tui/views/dashboard/keys.go | 6 + pkg/tui/views/dashboard/model.go | 10 +- pkg/tui/views/dashboard/operations.go | 74 +++++ pkg/tui/views/kanban/keys.go | 4 + pkg/tui/views/kanban/model.go | 10 +- pkg/tui/views/kanban/operations.go | 64 ++++ 12 files changed, 701 insertions(+), 15 deletions(-) create mode 100644 pkg/tui/modal/dependencies.go diff --git a/pkg/tui/components/helpbar.go b/pkg/tui/components/helpbar.go index 6ae9969..730c907 100644 --- a/pkg/tui/components/helpbar.go +++ b/pkg/tui/components/helpbar.go @@ -180,7 +180,7 @@ func helpBarConfig(view ViewKind) HelpBarConfig { {Key: "/", Desc: "search"}, {Key: "a", Desc: "add"}, {Key: "c", Desc: "comment"}, - {Key: "e/d/s/p/t/A", Desc: "edit"}, + {Key: "e/d/s/p/t/A/D", Desc: "edit"}, {Key: "x", Desc: "delete"}, {Key: "q", Desc: "quit"}, {Key: "?", Desc: "help"}, @@ -199,6 +199,7 @@ func helpBarConfig(view ViewKind) HelpBarConfig { {Key: "p", Desc: "change priority"}, {Key: "t", Desc: "change type"}, {Key: "A", Desc: "change assignee"}, + {Key: "D", Desc: "edit dependencies"}, {Key: "q", Desc: "quit"}, {Key: "?", Desc: "help"}, }, @@ -217,7 +218,7 @@ func helpBarConfig(view ViewKind) HelpBarConfig { {Key: "h/l", Desc: "column"}, {Key: "←/→", Desc: "move"}, {Key: "a", Desc: "add"}, - {Key: "e/d/s/p/t/A/", Desc: "edit"}, + {Key: "e/d/s/p/t/A/D", Desc: "edit"}, {Key: "x", Desc: "delete"}, {Key: "q", Desc: "quit"}, {Key: "?", Desc: "help"}, @@ -239,6 +240,7 @@ func helpBarConfig(view ViewKind) HelpBarConfig { {Key: "p", Desc: "change priority"}, {Key: "t", Desc: "change type"}, {Key: "A", Desc: "change assignee"}, + {Key: "D", Desc: "manage dependencies"}, {Key: "S", Desc: "select sprint"}, {Key: "n", Desc: "new sprint"}, {Key: "q", Desc: "quit"}, diff --git a/pkg/tui/components/issue_detail.go b/pkg/tui/components/issue_detail.go index 7e1ba92..037c008 100644 --- a/pkg/tui/components/issue_detail.go +++ b/pkg/tui/components/issue_detail.go @@ -10,10 +10,11 @@ import ( ) type IssueDetail struct { - viewport viewport.Model - issue models.Issue - comments []*models.Comment - focused bool + viewport viewport.Model + issue models.Issue + comments []*models.Comment + dependencies []*models.Issue + focused bool } func NewIssueDetail() IssueDetail { @@ -34,6 +35,12 @@ func (i *IssueDetail) SetComments(comments []*models.Comment) { i.refreshContent() } +// SetDependencies updates the list of dependencies displayed for the current issue. +func (i *IssueDetail) SetDependencies(deps []*models.Issue) { + i.dependencies = deps + i.refreshContent() +} + func (i *IssueDetail) SetSize(width, height int) { i.viewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(height)) i.refreshContent() @@ -82,6 +89,7 @@ func (i *IssueDetail) refreshContent() { style.LabelStyle.Render("Assignee:") + style.ValueStyle.Render(i.issue.Assignee), ) + dependenciesParts := i.renderDependencies() descLabel := style.LabelStyle.Render("Description:") descStyle := style.ValueStyle.Width(contentWidth) descContent := descStyle.Render(i.issue.Description) @@ -89,7 +97,9 @@ func (i *IssueDetail) refreshContent() { commentsLabel := style.LabelStyle.MarginTop(1).Render("Comments:") var parts []string - parts = append(parts, titleRow, idRow, typeRow, statusRow, closingReasonRow, priorityRow, assigneeRow, descLabel, descContent, commentsLabel) + parts = append(parts, titleRow, idRow, typeRow, statusRow, closingReasonRow, priorityRow, assigneeRow) + parts = append(parts, dependenciesParts...) + parts = append(parts, descLabel, descContent, commentsLabel) parts = append(parts, i.renderComments()...) content := lipgloss.JoinVertical(lipgloss.Left, parts...) @@ -128,6 +138,29 @@ func (i *IssueDetail) ScrollDown(lines int) { i.viewport.ScrollDown(lines) } +func (i *IssueDetail) renderDependencies() []string { + depsLabel := style.LabelStyle.Render("Dependencies:") + if len(i.dependencies) == 0 { + return []string{ + depsLabel, + style.ValueStyle.Render(" "), + } + } + var lines []string + lines = append(lines, depsLabel) + for _, d := range i.dependencies { + if d == nil { + continue + } + depText := d.ID + if d.Title != "" { + depText += " — " + d.Title + } + lines = append(lines, style.ValueStyle.MarginLeft(1).Render(depText)) + } + return lines +} + func (i *IssueDetail) renderComments() []string { contentWidth := max(i.viewport.Width()-4, 1) diff --git a/pkg/tui/components/keymap.go b/pkg/tui/components/keymap.go index d5baf34..74402ca 100644 --- a/pkg/tui/components/keymap.go +++ b/pkg/tui/components/keymap.go @@ -14,9 +14,10 @@ type CommonKeyMap struct { ChangeStatus key.Binding ChangePriority key.Binding ChangeType key.Binding - ChangeAssignee key.Binding - AddIssue key.Binding - DeleteIssue key.Binding + ChangeAssignee key.Binding + ManageDependencies key.Binding + AddIssue key.Binding + DeleteIssue key.Binding } // DefaultCommonKeyMap returns the shared default bindings used by both views. @@ -62,6 +63,10 @@ func DefaultCommonKeyMap() CommonKeyMap { key.WithKeys("A"), key.WithHelp("A", "change assignee"), ), + ManageDependencies: key.NewBinding( + key.WithKeys("D"), + key.WithHelp("D", "edit dependencies"), + ), AddIssue: key.NewBinding( key.WithKeys("a"), key.WithHelp("a", "add issue"), diff --git a/pkg/tui/modal/dependencies.go b/pkg/tui/modal/dependencies.go new file mode 100644 index 0000000..74a71c1 --- /dev/null +++ b/pkg/tui/modal/dependencies.go @@ -0,0 +1,444 @@ +package modal + +import ( + "context" + "io" + + tea "charm.land/bubbletea/v2" + "charm.land/bubbles/v2/list" + "charm.land/lipgloss/v2" + "github.com/LazyBachelor/LazyPM/internal/app" + "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/LazyBachelor/LazyPM/internal/style" + "github.com/LazyBachelor/LazyPM/pkg/tui/msgs" +) + +// struct for displaying an issue's dependencies and allows adding/removing new ones using keys 'a' and 'x' +type DependenciesModal struct { + BaseModal + app *app.App + issueID string + deps []*models.Issue + width int + height int +} + +func NewDependenciesModal(a *app.App, issueID string) *DependenciesModal { + return &DependenciesModal{ + BaseModal: NewBaseModal(ModalManageDependencies, TypeCustom), + app: a, + issueID: issueID, + deps: nil, + width: 60, + height: 20, + } +} + +func (d *DependenciesModal) Activate() tea.Cmd { + d.BaseModal.activate() + d.refreshDeps() + return nil +} + +func (d *DependenciesModal) Deactivate() { + d.BaseModal.deactivate() +} + +func (d *DependenciesModal) SetIssueID(issueID string) { + d.issueID = issueID + d.refreshDeps() +} + +func (d *DependenciesModal) RefreshDeps() { + d.refreshDeps() +} + +func (d *DependenciesModal) refreshDeps() { + if d.app == nil || d.issueID == "" { + d.deps = nil + return + } + deps, _ := d.app.Issues.GetDependencies(context.Background(), d.issueID) + d.deps = deps +} + +func (d *DependenciesModal) Update(msg tea.Msg) (tea.Cmd, bool) { + if !d.IsActive() { + return nil, false + } + + // let msg pass through to the view + if _, ok := msg.(msgs.DependencyAddRequestedMsg); ok { + return nil, false + } + if _, ok := msg.(msgs.DependencyRemoveRequestedMsg); ok { + return nil, false + } + if completed, ok := msg.(ModalCompletedMsg); ok && (completed.ModalID == ModalSelectDependency || completed.ModalID == ModalSelectRemoveDependency) { + return nil, false + } + + // refresh list when adding/removing dependencies + if _, ok := msg.(msgs.DependencyAddedMsg); ok { + d.refreshDeps() + return nil, false + } + if _, ok := msg.(msgs.DependencyRemovedMsg); ok { + d.refreshDeps() + return nil, false + } + + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch msg.String() { + case "esc": + d.Deactivate() + return func() tea.Msg { return ModalCancelledMsg{ModalID: d.ID()} }, true + case "a": + return func() tea.Msg { return msgs.DependencyAddRequestedMsg{IssueID: d.issueID} }, true + case "x": + return func() tea.Msg { return msgs.DependencyRemoveRequestedMsg{IssueID: d.issueID} }, true + } + } + + return nil, true // consume other keys when this modal is active (e.g. help key) +} + +func (d *DependenciesModal) View() string { + if d.width < 5 { + return "" + } + + boxWidth := max(min(60, d.width-4), 1) + + title := style.LabelStyle.Render("Dependencies for issue " + d.issueID) + helpRow := lipgloss.NewStyle().Foreground(style.FaintText).Render("a = add, x = remove, esc = close") + + var depsContent string + if len(d.deps) == 0 { + depsContent = style.ValueStyle.Render("No dependencies.") + } else { + var lines []string + for _, dep := range d.deps { + if dep == nil { + continue + } + line := " " + dep.ID + if dep.Title != "" { + line += " — " + dep.Title + } + lines = append(lines, style.ValueStyle.Render(line)) + } + depsContent = lipgloss.JoinVertical(lipgloss.Left, lines...) + } + + content := lipgloss.JoinVertical(lipgloss.Left, + title, + "", + depsContent, + "", + helpRow, + ) + + return style.ModalContainerStyle. + Width(boxWidth). + Render(content) +} + +func (d *DependenciesModal) SetSize(width, height int) { + d.BaseModal.SetSize(width, height) + d.width = width + d.height = height +} + +// dependencyListItem implements list.Item for the add-dependency list. +type dependencyListItem struct { + Issue *models.Issue +} + +func (i dependencyListItem) Title() string { + if i.Issue == nil { + return "" + } + return i.Issue.ID +} + +func (i dependencyListItem) Description() string { + if i.Issue == nil { + return "" + } + return i.Issue.Title +} + +func (i dependencyListItem) FilterValue() string { + if i.Issue == nil { + return "" + } + return i.Issue.ID + " " + i.Issue.Title +} + +// DependencyListModal is a list-based modal for selecting an issue to add as a dependency. +// Use j/k and up/down to navigate, Enter to select. +type DependencyListModal struct { + BaseModal + list list.Model + width int + height int +} + +type dependencyListDelegate struct { + width int +} + +func (d dependencyListDelegate) Height() int { return 1 } +func (d dependencyListDelegate) Spacing() int { return 0 } +func (d dependencyListDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } + +func (d dependencyListDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + li, ok := item.(dependencyListItem) + if !ok || li.Issue == nil { + return + } + text := li.Issue.ID + if li.Issue.Title != "" { + text += " — " + li.Issue.Title + } + if index == m.Index() { + text = style.HighlightKey("> ") + lipgloss.NewStyle().Bold(true).Render(text) + } else { + text = " " + text + } + io.WriteString(w, lipgloss.NewStyle().Width(d.width-2).Render(text)) +} + +// NewDependencyListModal creates a modal with a list of issues to add as dependency. +func NewDependencyListModal(issues []*models.Issue, width, height int) *DependencyListModal { + items := make([]list.Item, 0, len(issues)) + for _, iss := range issues { + if iss != nil { + items = append(items, dependencyListItem{Issue: iss}) + } + } + + if width < 40 { + width = 50 + } + if height < 10 { + height = 15 + } + + l := list.New(items, dependencyListDelegate{width: width}, width-4, height-6) + l.SetShowTitle(false) + l.SetShowHelp(false) + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + + return &DependencyListModal{ + BaseModal: NewBaseModal(ModalSelectDependency, TypeCustom), + list: l, + width: width, + height: height, + } +} + +func (m *DependencyListModal) Activate() tea.Cmd { + m.BaseModal.activate() + return nil +} + +func (m *DependencyListModal) Deactivate() { + m.BaseModal.deactivate() +} + +func (m *DependencyListModal) Update(msg tea.Msg) (tea.Cmd, bool) { + if !m.IsActive() { + return nil, false + } + + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch msg.String() { + case "esc": + m.Deactivate() + return func() tea.Msg { return ModalCancelledMsg{ModalID: m.ID()} }, true + case "enter": + item := m.list.SelectedItem() + if li, ok := item.(dependencyListItem); ok && li.Issue != nil { + m.Deactivate() + return func() tea.Msg { + return ModalCompletedMsg{ + ModalID: m.ID(), + Value: SelectResult{SelectedValue: li.Issue.ID}, + } + }, true + } + } + } + + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return cmd, true +} + +func (m *DependencyListModal) View() string { + boxWidth := max(min(60, m.width-4), 1) + helpRow := lipgloss.NewStyle().Foreground(style.FaintText).Render("↑/k up, ↓/j down, enter = add, esc = cancel") + + content := lipgloss.JoinVertical(lipgloss.Left, + style.LabelStyle.Render("Add dependency (select issue):"), + "", + m.list.View(), + "", + helpRow, + ) + + return style.ModalContainerStyle. + Width(boxWidth). + Render(content) +} + +func (m *DependencyListModal) SetSize(width, height int) { + m.BaseModal.SetSize(width, height) + m.width = width + m.height = height + m.list.SetSize(width-4, height-6) +} + +// DependencyRemoveListModal is a list-based modal for selecting a dependency to remove. +// Use j/k and up/down to navigate, Enter to remove. +type DependencyRemoveListModal struct { + BaseModal + list list.Model + width int + height int +} + +// NewDependencyRemoveListModal creates a modal with a list of dependencies to remove. +func NewDependencyRemoveListModal(deps []*models.Issue, width, height int) *DependencyRemoveListModal { + items := make([]list.Item, 0, len(deps)) + for _, dep := range deps { + if dep != nil { + items = append(items, dependencyListItem{Issue: dep}) + } + } + + if width < 40 { + width = 50 + } + if height < 10 { + height = 15 + } + + l := list.New(items, dependencyListDelegate{width: width}, width-4, height-6) + l.SetShowTitle(false) + l.SetShowHelp(false) + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + + return &DependencyRemoveListModal{ + BaseModal: NewBaseModal(ModalSelectRemoveDependency, TypeCustom), + list: l, + width: width, + height: height, + } +} + +func (m *DependencyRemoveListModal) Activate() tea.Cmd { + m.BaseModal.activate() + return nil +} + +func (m *DependencyRemoveListModal) Deactivate() { + m.BaseModal.deactivate() +} + +func (m *DependencyRemoveListModal) Update(msg tea.Msg) (tea.Cmd, bool) { + if !m.IsActive() { + return nil, false + } + + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch msg.String() { + case "esc": + m.Deactivate() + return func() tea.Msg { return ModalCancelledMsg{ModalID: m.ID()} }, true + case "enter": + item := m.list.SelectedItem() + if li, ok := item.(dependencyListItem); ok && li.Issue != nil { + m.Deactivate() + return func() tea.Msg { + return ModalCompletedMsg{ + ModalID: m.ID(), + Value: SelectResult{SelectedValue: li.Issue.ID}, + } + }, true + } + } + } + + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return cmd, true +} + +func (m *DependencyRemoveListModal) View() string { + boxWidth := max(min(60, m.width-4), 1) + helpRow := lipgloss.NewStyle().Foreground(style.FaintText).Render("↑/k up, ↓/j down, enter = remove, esc = cancel") + + content := lipgloss.JoinVertical(lipgloss.Left, + style.LabelStyle.Render("Remove dependency (select issue):"), + "", + m.list.View(), + "", + helpRow, + ) + + return style.ModalContainerStyle. + Width(boxWidth). + Render(content) +} + +func (m *DependencyRemoveListModal) SetSize(width, height int) { + m.BaseModal.SetSize(width, height) + m.width = width + m.height = height + m.list.SetSize(width-4, height-6) +} + +// EligibleDependencyIssues returns issues that can be added as dependencies: +// excludes current issue, existing dependencies, and dependents. +func EligibleDependencyIssues(a *app.App, issueID string) []*models.Issue { + if a == nil || issueID == "" { + return nil + } + ctx := context.Background() + all, err := a.Issues.SearchIssues(ctx, "", models.IssueFilter{}) + if err != nil { + return nil + } + deps, _ := a.Issues.GetDependencies(ctx, issueID) + dependents, _ := a.Issues.GetDependents(ctx, issueID) + + exclude := map[string]struct{}{issueID: {}} + for _, d := range deps { + if d != nil { + exclude[d.ID] = struct{}{} + } + } + for _, d := range dependents { + if d != nil { + exclude[d.ID] = struct{}{} + } + } + + var out []*models.Issue + for _, iss := range all { + if iss != nil { + if _, skip := exclude[iss.ID]; !skip { + out = append(out, iss) + } + } + } + return out +} diff --git a/pkg/tui/modal/modal.go b/pkg/tui/modal/modal.go index ccf9765..4220d64 100644 --- a/pkg/tui/modal/modal.go +++ b/pkg/tui/modal/modal.go @@ -62,7 +62,10 @@ const ( ModalSelectCloseReason = "select-close-reason" ModalSelectPriority = "select-priority" ModalSelectType = "select-type" - ModalSelectSprint = "select-sprint" + ModalSelectSprint = "select-sprint" + ModalManageDependencies = "manage-dependencies" + ModalSelectDependency = "select-dependency" + ModalSelectRemoveDependency = "select-remove-dependency" ) // ModalStack manages a stack of active modals with priority handling diff --git a/pkg/tui/msgs/msgs.go b/pkg/tui/msgs/msgs.go index a241d98..78ef520 100644 --- a/pkg/tui/msgs/msgs.go +++ b/pkg/tui/msgs/msgs.go @@ -62,6 +62,26 @@ type ( Err error } + DependencyAddedMsg struct { + IssueID string + DependsOnID string + Err error + } + + DependencyAddRequestedMsg struct { + IssueID string + } + + DependencyRemovedMsg struct { + IssueID string + DependsOnID string + Err error + } + + DependencyRemoveRequestedMsg struct { + IssueID string + } + ModalCompletedMsg struct { ModalID string Result interface{} @@ -161,3 +181,22 @@ func AddIssueCommentCmd(app *app.App, issueID, author, text string) tea.Cmd { return IssueCommentAddedMsg{IssueID: issueID, Err: err} } } + +func AddDependencyCmd(app *app.App, issueID, dependsOnID string) tea.Cmd { + return func() tea.Msg { + dep := &models.Dependency{ + IssueID: issueID, + DependsOnID: dependsOnID, + Type: models.DepBlocks, + } + err := app.Issues.AddDependency(context.Background(), dep, "tui") + return DependencyAddedMsg{IssueID: issueID, DependsOnID: dependsOnID, Err: err} + } +} + +func RemoveDependencyCmd(app *app.App, issueID, dependsOnID string) tea.Cmd { + return func() tea.Msg { + err := app.Issues.RemoveDependency(context.Background(), issueID, dependsOnID, "tui") + return DependencyRemovedMsg{IssueID: issueID, DependsOnID: dependsOnID, Err: err} + } +} diff --git a/pkg/tui/views/dashboard/keys.go b/pkg/tui/views/dashboard/keys.go index 34187f4..10ecd9e 100644 --- a/pkg/tui/views/dashboard/keys.go +++ b/pkg/tui/views/dashboard/keys.go @@ -123,6 +123,12 @@ func (m *Model) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { m.logAction("tui started editing assignee") } + case m.notInModalMsgWithKey(msg, m.keyMap.ManageDependencies): + if selected := m.issueList.SelectedItem(); selected.ID != "" { + cmd = m.startManageDependencies(selected) + m.logAction("tui opened dependencies management") + } + case m.notInModalMsgWithKey(msg, m.keyMap.AddComment): if selected := m.issueList.SelectedItem(); selected.ID != "" { cmd = m.startAddComment(selected) diff --git a/pkg/tui/views/dashboard/model.go b/pkg/tui/views/dashboard/model.go index 4ac5f07..dafe517 100644 --- a/pkg/tui/views/dashboard/model.go +++ b/pkg/tui/views/dashboard/model.go @@ -37,6 +37,8 @@ type Model struct { currentIssueID string deleteIndex int + dependenciesModal *modal.DependenciesModal + feedbackChan chan models.ValidationFeedback quitChan chan bool currentFeedback models.ValidationFeedback @@ -93,15 +95,19 @@ func (m *Model) registerModals() { modal.RegisterCommonModals(m.modalManager) } -// setDetailIssueWithComments sets the issue in the detail pane and loads its comments. +// setDetailIssueWithComments sets the issue in the detail pane and loads its comments and dependencies. func (m *Model) setDetailIssueWithComments(issue models.Issue) { m.issueDetail.SetIssue(issue) if issue.ID == "" { m.issueDetail.SetComments(nil) + m.issueDetail.SetDependencies(nil) return } - comments, _ := m.app.Issues.GetIssueComments(context.Background(), issue.ID) + ctx := context.Background() + comments, _ := m.app.Issues.GetIssueComments(ctx, issue.ID) m.issueDetail.SetComments(comments) + deps, _ := m.app.Issues.GetDependencies(ctx, issue.ID) + m.issueDetail.SetDependencies(deps) } func (m *Model) logAction(action string) { diff --git a/pkg/tui/views/dashboard/operations.go b/pkg/tui/views/dashboard/operations.go index 8b72da6..38d9f61 100644 --- a/pkg/tui/views/dashboard/operations.go +++ b/pkg/tui/views/dashboard/operations.go @@ -114,6 +114,13 @@ func (m *Model) startAddComment(selected ListIssue) tea.Cmd { return nil } +func (m *Model) startManageDependencies(selected ListIssue) tea.Cmd { + m.currentIssueID = selected.ID + depModal := modal.NewDependenciesModal(m.app, selected.ID) + m.dependenciesModal = depModal + return m.modalManager.PushModal(depModal) +} + // handleModalCompleted handles all modal completion messages func (m *Model) handleModalCompleted(msg modal.ModalCompletedMsg) tea.Cmd { switch msg.ModalID { @@ -198,6 +205,18 @@ func (m *Model) handleModalCompleted(msg modal.ModalCompletedMsg) tea.Cmd { cmd := msgs.UpdateIssueTypeCmd(m.app, m.currentIssueID, issueType) return func() tea.Msg { return cmd() } } + case modal.ModalSelectDependency: + if r, ok := msg.Value.(modal.SelectResult); ok && r.SelectedValue != "" { + m.logAction("tui added dependency") + m.modalManager.PopModal() + return msgs.AddDependencyCmd(m.app, m.currentIssueID, r.SelectedValue) + } + case modal.ModalSelectRemoveDependency: + if r, ok := msg.Value.(modal.SelectResult); ok && r.SelectedValue != "" { + m.logAction("tui removed dependency") + m.modalManager.PopModal() + return msgs.RemoveDependencyCmd(m.app, m.currentIssueID, r.SelectedValue) + } } return nil } @@ -239,6 +258,19 @@ func (m *Model) handleModalCancelled(msg modal.ModalCancelledMsg) { case modal.ModalSelectType: m.currentIssueID = "" m.logAction("tui canceled type picker") + case modal.ModalManageDependencies: + m.dependenciesModal = nil + m.modalManager.PopModal() + if selected := m.issueList.SelectedItem(); selected.ID != "" { + m.setDetailIssueWithComments(selected.Issue) + } + m.logAction("tui closed dependencies management") + case modal.ModalSelectDependency: + m.modalManager.PopModal() + m.logAction("tui canceled add dependency") + case modal.ModalSelectRemoveDependency: + m.modalManager.PopModal() + m.logAction("tui canceled remove dependency") } } @@ -255,6 +287,48 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.handleModalCancelled(msg) return m, nil + case msgs.DependencyAddRequestedMsg: + issues := modal.EligibleDependencyIssues(m.app, msg.IssueID) + if len(issues) == 0 { + m.logAction("tui no issues available to add as dependency") + return m, nil + } + listModal := modal.NewDependencyListModal(issues, m.width, m.height) + return m, m.modalManager.PushModal(listModal) + + case msgs.DependencyRemoveRequestedMsg: + deps, _ := m.app.Issues.GetDependencies(context.Background(), msg.IssueID) + if len(deps) == 0 { + m.logAction("tui no dependencies to remove") + return m, nil + } + listModal := modal.NewDependencyRemoveListModal(deps, m.width, m.height) + return m, m.modalManager.PushModal(listModal) + + case msgs.DependencyAddedMsg: + if m.dependenciesModal != nil { + m.dependenciesModal.RefreshDeps() + } + if msg.Err != nil { + m.logAction("tui failed to add dependency") + return m, nil + } + m.logAction("tui added dependency") + m.submitValidation() + return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + + case msgs.DependencyRemovedMsg: + if m.dependenciesModal != nil { + m.dependenciesModal.RefreshDeps() + } + if msg.Err != nil { + m.logAction("tui failed to remove dependency") + return m, nil + } + m.logAction("tui removed dependency") + m.submitValidation() + return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + case msgs.TitleUpdatedMsg: m.modalManager.GetTextInputModal(modal.ModalEditTitle).Reset() m.currentIssueID = "" diff --git a/pkg/tui/views/kanban/keys.go b/pkg/tui/views/kanban/keys.go index fbed166..82d25b9 100644 --- a/pkg/tui/views/kanban/keys.go +++ b/pkg/tui/views/kanban/keys.go @@ -114,6 +114,10 @@ func (m *Model) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { if selected := m.FocusedIssueList().SelectedItem(); selected.ID != "" { cmd = m.startEditAssignee(selected) } + case m.notInModalMsgWithKey(msg, m.keyMap.ManageDependencies): + if selected := m.FocusedIssueList().SelectedItem(); selected.ID != "" { + cmd = m.startManageDependencies(selected) + } case m.notInModalMsgWithKey(msg, m.keyMap.AddIssue): cmd = m.startCreateIssue() diff --git a/pkg/tui/views/kanban/model.go b/pkg/tui/views/kanban/model.go index f53ce16..5ceef5d 100644 --- a/pkg/tui/views/kanban/model.go +++ b/pkg/tui/views/kanban/model.go @@ -43,6 +43,8 @@ type Model struct { currentIssueID string deleteIndex int + dependenciesModal *modal.DependenciesModal + feedbackChan chan models.ValidationFeedback quitChan chan bool currentFeedback models.ValidationFeedback @@ -193,15 +195,19 @@ func (m *Model) updateDetailFromSelection() { } } -// setDetailIssueWithComments sets the issue in the detail pane and loads its comments. +// setDetailIssueWithComments sets the issue in the detail pane and loads its comments and dependencies. func (m *Model) setDetailIssueWithComments(issue models.Issue) { m.issueDetail.SetIssue(issue) if issue.ID == "" { m.issueDetail.SetComments(nil) + m.issueDetail.SetDependencies(nil) return } - comments, _ := m.app.Issues.GetIssueComments(context.Background(), issue.ID) + ctx := context.Background() + comments, _ := m.app.Issues.GetIssueComments(ctx, issue.ID) m.issueDetail.SetComments(comments) + deps, _ := m.app.Issues.GetDependencies(ctx, issue.ID) + m.issueDetail.SetDependencies(deps) } func statusForColumn(col modal.FocusArea) models.Status { diff --git a/pkg/tui/views/kanban/operations.go b/pkg/tui/views/kanban/operations.go index d95c29d..0095976 100644 --- a/pkg/tui/views/kanban/operations.go +++ b/pkg/tui/views/kanban/operations.go @@ -237,6 +237,13 @@ func (m *Model) startAddComment(selected ListIssue) tea.Cmd { return nil } +func (m *Model) startManageDependencies(selected ListIssue) tea.Cmd { + m.currentIssueID = selected.ID + depModal := modal.NewDependenciesModal(m.app, selected.ID) + m.dependenciesModal = depModal + return m.modalManager.PushModal(depModal) +} + // handleModalCompleted handles all modal completion messages func (m *Model) handleModalCompleted(msg modal.ModalCompletedMsg) tea.Cmd { switch msg.ModalID { @@ -324,6 +331,16 @@ func (m *Model) handleModalCompleted(msg modal.ModalCompletedMsg) tea.Cmd { cmd := msgs.AddIssueCommentCmd(m.app, m.currentIssueID, user.GetOsUsername(), r.Value) return func() tea.Msg { return cmd() } } + case modal.ModalSelectDependency: + if r, ok := msg.Value.(modal.SelectResult); ok && r.SelectedValue != "" { + m.modalManager.PopModal() + return msgs.AddDependencyCmd(m.app, m.currentIssueID, r.SelectedValue) + } + case modal.ModalSelectRemoveDependency: + if r, ok := msg.Value.(modal.SelectResult); ok && r.SelectedValue != "" { + m.modalManager.PopModal() + return msgs.RemoveDependencyCmd(m.app, m.currentIssueID, r.SelectedValue) + } } return nil } @@ -357,6 +374,16 @@ func (m *Model) handleModalCancelled(msg modal.ModalCancelledMsg) { m.currentIssueID = "" case modal.ModalAddComment: m.currentIssueID = "" + case modal.ModalManageDependencies: + m.dependenciesModal = nil + m.modalManager.PopModal() + if selected := m.FocusedIssueList().SelectedItem(); selected.ID != "" { + m.setDetailIssueWithComments(selected.Issue) + } + case modal.ModalSelectDependency: + m.modalManager.PopModal() + case modal.ModalSelectRemoveDependency: + m.modalManager.PopModal() } } @@ -372,6 +399,43 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case modal.ModalCancelledMsg: m.handleModalCancelled(msg) return m, nil + + case msgs.DependencyAddRequestedMsg: + issues := modal.EligibleDependencyIssues(m.app, msg.IssueID) + if len(issues) == 0 { + return m, nil + } + listModal := modal.NewDependencyListModal(issues, m.width, m.height) + return m, m.modalManager.PushModal(listModal) + + case msgs.DependencyRemoveRequestedMsg: + deps, _ := m.app.Issues.GetDependencies(context.Background(), msg.IssueID) + if len(deps) == 0 { + return m, nil + } + listModal := modal.NewDependencyRemoveListModal(deps, m.width, m.height) + return m, m.modalManager.PushModal(listModal) + + case msgs.DependencyAddedMsg: + if m.dependenciesModal != nil { + m.dependenciesModal.RefreshDeps() + } + if msg.Err != nil { + return m, nil + } + m.submitValidation() + return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + + case msgs.DependencyRemovedMsg: + if m.dependenciesModal != nil { + m.dependenciesModal.RefreshDeps() + } + if msg.Err != nil { + return m, nil + } + m.submitValidation() + return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + case msgs.TitleUpdatedMsg: m.modalManager.GetTextInputModal(modal.ModalEditTitle).Reset() m.currentIssueID = ""