package modal import ( "context" "io" "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" "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 listHeight := min(max(len(m.list.Items())+2, 3), 20) m.list.SetSize(width-4, listHeight) } // 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 listHeight := min(max(len(m.list.Items())+2, 3), 20) m.list.SetSize(width-4, listHeight) } // 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 }