diff --git a/pkg/tui/views/dashboard/header.go b/pkg/tui/components/header.go similarity index 78% rename from pkg/tui/views/dashboard/header.go rename to pkg/tui/components/header.go index fcd2b7e..87d434d 100644 --- a/pkg/tui/views/dashboard/header.go +++ b/pkg/tui/components/header.go @@ -1,4 +1,4 @@ -package dashboard +package components import ( "github.com/LazyBachelor/LazyPM/pkg/tui/styles" @@ -6,17 +6,15 @@ import ( ) type Header struct { - title string + Title string } func NewHeader(title string) Header { - return Header{ - title: title, - } + return Header{Title: title} } func (h Header) View(width int) string { - title := styles.HeaderTitleStyle.Render(h.title) + title := styles.HeaderTitleStyle.Render(h.Title) return lipgloss.PlaceHorizontal( width, diff --git a/pkg/tui/components/helpbar.go b/pkg/tui/components/helpbar.go new file mode 100644 index 0000000..146d27f --- /dev/null +++ b/pkg/tui/components/helpbar.go @@ -0,0 +1,181 @@ +package components + +import ( + "github.com/LazyBachelor/LazyPM/pkg/tui/styles" + "github.com/charmbracelet/lipgloss" +) + +type ViewKind int + +const ( + ViewIssues ViewKind = iota + ViewKanban +) + +type ShortItem struct { + Key string + Desc string +} + +type FullRow struct { + LeftKey string + LeftDesc string + RightKey string + RightDesc string +} + + +type HelpBarConfig struct { + ShortItems []ShortItem + FullRows []FullRow +} + + +type HelpBar struct { + view ViewKind + config HelpBarConfig + showAll bool + width int +} + + +func NewHelpBar(view ViewKind) HelpBar { + return HelpBar{view: view, config: helpBarConfig(view)} +} + +func (h *HelpBar) SetWidth(width int) { + h.width = width +} + + +func (h HelpBar) View() string { + if h.width == 0 { + return "" + } + if h.showAll { + return h.fullHelp() + } + return h.shortHelp() +} + +func (h HelpBar) shortHelp() string { + keys := make([]string, 0, len(h.config.ShortItems)) + for _, item := range h.config.ShortItems { + keys = append(keys, styles.HighlightKey(item.Key)+" "+item.Desc) + } + content := lipgloss.JoinHorizontal(lipgloss.Left, keys...) + return lipgloss.NewStyle(). + Border(lipgloss.Border{Top: "─"}, true, false, false, false). + BorderForeground(styles.SecondaryBorder). + Padding(0, 1). + Width(h.width). + Render(content) +} + +func (h HelpBar) fullHelp() string { + keyStyle := lipgloss.NewStyle().Width(8).Align(lipgloss.Right) + descStyle := lipgloss.NewStyle().Width(12) + + renderHelpItem := func(key, desc string) string { + return lipgloss.JoinHorizontal( + lipgloss.Left, + keyStyle.Render(styles.HighlightKey(key)), + " ", + descStyle.Render(desc), + ) + } + + renderRow := func(leftKey, leftDesc, rightKey, rightDesc string) string { + leftItem := renderHelpItem(leftKey, leftDesc) + rightItem := renderHelpItem(rightKey, rightDesc) + return lipgloss.JoinHorizontal(lipgloss.Left, leftItem, " ", rightItem) + } + + rows := make([]string, 0, len(h.config.FullRows)) + for _, r := range h.config.FullRows { + rows = append(rows, renderRow(r.LeftKey, r.LeftDesc, r.RightKey, r.RightDesc)) + } + content := lipgloss.JoinVertical(lipgloss.Left, rows...) + return lipgloss.NewStyle(). + Border(lipgloss.Border{Top: "─"}, true, false, false, false). + BorderForeground(styles.SecondaryBorder). + Padding(0, 1). + Width(h.width). + Render(content) +} + +func (h HelpBar) Height() int { + if h.width == 0 { + return 0 + } + return lipgloss.Height(h.View()) +} + + +func (h HelpBar) IsExpanded() bool { + return h.showAll +} + +func (h *HelpBar) ToggleHelp() { + h.showAll = !h.showAll +} + +func helpBarConfig(view ViewKind) HelpBarConfig { + switch view { + case ViewIssues: + return HelpBarConfig{ + ShortItems: []ShortItem{ + {Key: "tab", Desc: "switch"}, + {Key: "k", Desc: "kanban"}, + {Key: "↑/k", Desc: "up"}, + {Key: "↓/j", Desc: "down"}, + {Key: "pgup/pgdn", Desc: "page"}, + {Key: "a", Desc: "add"}, + {Key: "e/d/s/p/t", Desc: "edit"}, + {Key: "x", Desc: "delete"}, + {Key: "q", Desc: "quit"}, + {Key: "?", Desc: "help"}, + }, + FullRows: []FullRow{ + {LeftKey: "tab", LeftDesc: "switch window", RightKey: "↑/k", RightDesc: "up"}, + {LeftKey: "enter", LeftDesc: "view issue", RightKey: "↓/j", RightDesc: "down"}, + {LeftKey: "pgup", LeftDesc: "page up", RightKey: "pgdn", RightDesc: "page down"}, + {LeftKey: "b", LeftDesc: "back to list", RightKey: "a", RightDesc: "add issue"}, + {LeftKey: "e", LeftDesc: "edit title", RightKey: "d", RightDesc: "edit description"}, + {LeftKey: "s", LeftDesc: "change status", RightKey: "p", RightDesc: "change priority"}, + {LeftKey: "t", LeftDesc: "change type", RightKey: "x", RightDesc: "delete issue"}, + {LeftKey: "2", LeftDesc: "kanban", RightKey: "q", RightDesc: "quit"}, + {LeftKey: "?", LeftDesc: "help", RightKey: "", RightDesc: ""}, + }, + } + case ViewKanban: + return HelpBarConfig{ + ShortItems: []ShortItem{ + {Key: "1", Desc: "issues"}, + {Key: "↑/k", Desc: "up"}, + {Key: "↓/j", Desc: "down"}, + {Key: "pgup/pgdn", Desc: "page"}, + {Key: "h/l", Desc: "column"}, + {Key: "←/→", Desc: "move"}, + {Key: "a", Desc: "add"}, + {Key: "e/d/s/p/t", Desc: "edit"}, + {Key: "x", Desc: "delete"}, + {Key: "q", Desc: "quit"}, + {Key: "?", Desc: "help"}, + }, + FullRows: []FullRow{ + {LeftKey: "1", LeftDesc: "issues", RightKey: "↑/k", RightDesc: "up"}, + {LeftKey: "enter", LeftDesc: "view issue", RightKey: "↓/j", RightDesc: "down"}, + {LeftKey: "pgup", LeftDesc: "page up", RightKey: "pgdn", RightDesc: "page down"}, + {LeftKey: "h/l", LeftDesc: "switch column", RightKey: "←/→", RightDesc: "move issue"}, + {LeftKey: "b", LeftDesc: "back to list", RightKey: "a", RightDesc: "add issue"}, + {LeftKey: "e", LeftDesc: "edit title", RightKey: "d", RightDesc: "edit description"}, + {LeftKey: "s", LeftDesc: "change status", RightKey: "p", RightDesc: "change priority"}, + {LeftKey: "t", LeftDesc: "change type", RightKey: "x", RightDesc: "delete issue"}, + {LeftKey: "q", LeftDesc: "quit", RightKey: "?", RightDesc: "help"}, + }, + } + default: + return HelpBarConfig{} + } +} diff --git a/pkg/tui/views/dashboard/issue_detail.go b/pkg/tui/components/issue_detail.go similarity index 97% rename from pkg/tui/views/dashboard/issue_detail.go rename to pkg/tui/components/issue_detail.go index 8681800..3c69bfc 100644 --- a/pkg/tui/views/dashboard/issue_detail.go +++ b/pkg/tui/components/issue_detail.go @@ -1,4 +1,4 @@ -package dashboard +package components import ( "github.com/LazyBachelor/LazyPM/internal/models" @@ -20,11 +20,13 @@ func NewIssueDetail() IssueDetail { } } + func (i *IssueDetail) SetIssue(issue models.Issue) { i.issue = issue i.refreshContent() } + func (i *IssueDetail) SetSize(width, height int) { i.viewport.Height = height i.viewport.Width = width @@ -36,7 +38,6 @@ func (i *IssueDetail) SetFocused(focused bool) { } func (i *IssueDetail) refreshContent() { - titleRow := styles.RowStyle.Render( styles.TitleStyle.Render(i.issue.Title), ) @@ -54,7 +55,7 @@ func (i *IssueDetail) refreshContent() { ) priorityRow := styles.RowStyle.Render( - styles.LabelStyle.Render("Priority:") + styles.ValueStyle.Render(priorityCodeName(i.issue.Priority)), + styles.LabelStyle.Render("Priority:") + styles.ValueStyle.Render(PriorityCodeName(i.issue.Priority)), ) descLabel := styles.LabelStyle.Render("Description:") diff --git a/pkg/tui/views/dashboard2/issue_list.go b/pkg/tui/components/issue_list.go similarity index 66% rename from pkg/tui/views/dashboard2/issue_list.go rename to pkg/tui/components/issue_list.go index 3c5c35a..1667e34 100644 --- a/pkg/tui/views/dashboard2/issue_list.go +++ b/pkg/tui/components/issue_list.go @@ -1,4 +1,4 @@ -package dashboard2 +package components import ( "context" @@ -10,18 +10,23 @@ import ( "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/pkg/tui/styles" "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/muesli/reflow/truncate" ) + type IssueList struct { - list list.Model - app *app.App - width int - height int + list list.Model + app *app.App + width int + height int + highlightSelected bool } + type ListIssue struct { models.Issue } @@ -30,27 +35,27 @@ func (l ListIssue) Title() string { return l.Issue.Title } func (l ListIssue) Description() string { return l.Issue.Description } func (l ListIssue) FilterValue() string { return l.Issue.ID + " " + l.Issue.Title } -type TableColumn struct { +type tableColumn struct { width uint label string key string } -func getTableColumns(width int) []TableColumn { +func getTableColumns(width int) []tableColumn { switch { case width < 45: - return []TableColumn{ + return []tableColumn{ {width: 10, label: "ID", key: "id"}, {width: uint(width - 10), label: "TITLE", key: "title"}, } case width < 60: - return []TableColumn{ + return []tableColumn{ {width: 10, label: "ID", key: "id"}, {width: 20, label: "TITLE", key: "title"}, {width: 15, label: "STATUS", key: "status"}, } default: - return []TableColumn{ + return []tableColumn{ {width: 12, label: "ID", key: "id"}, {width: 20, label: "TITLE", key: "title"}, {width: 15, label: "STATUS", key: "status"}, @@ -60,7 +65,24 @@ func getTableColumns(width int) []TableColumn { } } -func renderHeaders(cols []TableColumn) string { +// PriorityCodeName returns the human-readable name for a priority code. +// Exported for use by IssueDetail. +func PriorityCodeName(priority int) string { + if name, ok := priorityCodeNames[priority]; ok { + return name + } + return fmt.Sprintf("%d", priority) +} + +var priorityCodeNames = map[int]string{ + 0: "irrelevant", + 1: "low", + 2: "normal", + 3: "high", + 4: "critical", +} + +func renderHeaders(cols []tableColumn) string { var parts []string headerStyle := lipgloss.NewStyle().Foreground(styles.FaintText).Bold(true) @@ -77,7 +99,52 @@ func renderHeaders(cols []TableColumn) string { return lipgloss.JoinHorizontal(lipgloss.Left, parts...) } +// IssueInputs bundles the common text inputs used for issue title, creation, and description. +type IssueInputs struct { + Title textinput.Model + CreateTitle textinput.Model + Description textarea.Model +} + +// NewIssueInputs creates initialized inputs for issue title, new issue title, and description. +func NewIssueInputs() IssueInputs { + ti := textinput.New() + ti.Placeholder = "Issue title ..." + ti.CharLimit = 256 + + createTi := textinput.New() + createTi.Placeholder = "New issue title ..." + createTi.CharLimit = 256 + + descTa := textarea.New() + descTa.Placeholder = "Issue description..." + descTa.SetWidth(56) + descTa.SetHeight(8) + + return IssueInputs{ + Title: ti, + CreateTitle: createTi, + Description: descTa, + } +} + +// ValidationFeedbackMsg is a shared message carrying validation results from the app. +type ValidationFeedbackMsg struct { + Feedback models.ValidationFeedback +} + +// ListenForValidation returns a command that waits for a validation feedback message +// on the given channel and wraps it in a ValidationFeedbackMsg. +func ListenForValidation(ch chan models.ValidationFeedback) tea.Cmd { + return func() tea.Msg { + feedback := <-ch + return ValidationFeedbackMsg{Feedback: feedback} + } +} + + func NewIssueList(app *app.App, width, height int) IssueList { + // NewIssueList creates an IssueList populated from the app. issues, err := app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) if err != nil { return IssueList{} @@ -93,7 +160,7 @@ func NewIssueList(app *app.App, width, height int) IssueList { items[i] = issue } - l := list.New(items, NewIssueListDelegate(width), width, height) + l := list.New(items, newIssueListDelegate(width), width, height) l.SetShowTitle(false) l.SetShowHelp(false) l.SetShowStatusBar(false) @@ -104,20 +171,27 @@ func NewIssueList(app *app.App, width, height int) IssueList { l.FilterInput.Prompt = "🔍 " return IssueList{ - list: l, - app: app, - width: width, - height: height, + list: l, + app: app, + width: width, + height: height, + highlightSelected: true, } } +// SetHighlightSelected controls whether the selected row is visually highlighted. +// Use false for lists that are not currently focused (e.g. non-focused Kanban columns). +func (l *IssueList) SetHighlightSelected(show bool) { + l.highlightSelected = show +} + func NewIssueListFromIssues(app *app.App, issues []*models.Issue, width, height int) IssueList { - // for making an IssueList from a pre-existing list of issues. + // NewIssueListFromIssues creates an IssueList from a slice of issues. listIssues := make([]list.Item, len(issues)) for i, issue := range issues { listIssues[i] = ListIssue{Issue: *issue} } - l := list.New(listIssues, NewIssueListDelegate(width), width, height) + l := list.New(listIssues, newIssueListDelegate(width), width, height) l.SetShowTitle(false) l.SetShowHelp(false) l.SetShowStatusBar(false) @@ -127,10 +201,11 @@ func NewIssueListFromIssues(app *app.App, issues []*models.Issue, width, height l.FilterInput.TextStyle = styles.FilterInputStyle l.FilterInput.Prompt = "🔍 " return IssueList{ - list: l, - app: app, - width: width, - height: height, + list: l, + app: app, + width: width, + height: height, + highlightSelected: true, } } @@ -158,8 +233,19 @@ func ClosedOnly(issues []*models.Issue) []*models.Issue { return out } +// StatusOnly returns issues that exactly match the given status, sorted by priority. +func StatusOnly(issues []*models.Issue, status models.Status) []*models.Issue { + out := make([]*models.Issue, 0, len(issues)) + for _, issue := range issues { + if issue.Status == status { + out = append(out, issue) + } + } + sortByPriorityDesc(out) + return out +} + func sortByPriorityDesc(issues []*models.Issue) { - // sorts issues by priority, highest first. sort.Slice(issues, func(i, j int) bool { return issues[i].Priority > issues[j].Priority }) @@ -178,10 +264,11 @@ func (l *IssueList) SetSize(width, height int) { l.height = height l.list.SetSize(width, height) - l.list.SetDelegate(NewIssueListDelegate(width)) + l.list.SetDelegate(newIssueListDelegate(width)) } func (l IssueList) View() string { + // renders the list return l.renderResponsive() } @@ -218,11 +305,10 @@ func (l IssueList) renderFilteredItems() string { } start, end := l.list.Paginator.GetSliceBounds(len(visibleItems)) - cursor := l.list.Index() for i := start; i < end && i < len(visibleItems); i++ { - isSelected := i == cursor + isSelected := l.highlightSelected && i == cursor if issue, ok := visibleItems[i].(ListIssue); ok { cols := getTableColumns(l.width) row := renderRow(issue, isSelected, cols) @@ -233,6 +319,7 @@ func (l IssueList) renderFilteredItems() string { return lipgloss.JoinVertical(lipgloss.Left, items...) } +// selectedItem returns the currently selected issue. func (l IssueList) SelectedItem() ListIssue { if item, ok := l.list.SelectedItem().(ListIssue); ok { return item @@ -266,19 +353,19 @@ func (l *IssueList) SelectIssueID(issueID string) { } } -type IssueListDelegate struct { +type issueListDelegate struct { width int } -func NewIssueListDelegate(width int) IssueListDelegate { - return IssueListDelegate{width: width} +func newIssueListDelegate(width int) issueListDelegate { + return issueListDelegate{width: width} } -func (d IssueListDelegate) Height() int { return 1 } -func (d IssueListDelegate) Spacing() int { return 0 } -func (d IssueListDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } +func (d issueListDelegate) Height() int { return 1 } +func (d issueListDelegate) Spacing() int { return 0 } +func (d issueListDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } -func (d IssueListDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { +func (d issueListDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { issue, ok := listItem.(ListIssue) if !ok { return @@ -286,12 +373,11 @@ func (d IssueListDelegate) Render(w io.Writer, m list.Model, index int, listItem isSelected := index == m.Index() cols := getTableColumns(d.width) - row := renderRow(issue, isSelected, cols) io.WriteString(w, row) } -func renderRow(issue ListIssue, isSelected bool, cols []TableColumn) string { +func renderRow(issue ListIssue, isSelected bool, cols []tableColumn) string { var parts []string for _, col := range cols { @@ -313,22 +399,7 @@ func renderRow(issue ListIssue, isSelected bool, cols []TableColumn) string { return lipgloss.JoinHorizontal(lipgloss.Left, parts...) } -var priorityCodeNames = map[int]string{ - 0: "irrelevant", - 1: "low", - 2: "normal", - 3: "high", - 4: "critical", -} - -func priorityCodeName(priority int) string { - if name, ok := priorityCodeNames[priority]; ok { - return name - } - return fmt.Sprintf("%d", priority) -} - -func getColumnValue(col TableColumn, issue ListIssue) string { +func getColumnValue(col tableColumn, issue ListIssue) string { switch col.key { case "id": return issue.ID @@ -339,7 +410,7 @@ func getColumnValue(col TableColumn, issue ListIssue) string { case "type": return string(issue.Issue.IssueType) case "priority": - return priorityCodeName(issue.Issue.Priority) + return PriorityCodeName(issue.Issue.Priority) default: return "" } diff --git a/pkg/tui/components/keymap.go b/pkg/tui/components/keymap.go new file mode 100644 index 0000000..c75379a --- /dev/null +++ b/pkg/tui/components/keymap.go @@ -0,0 +1,80 @@ +package components + +import "github.com/charmbracelet/bubbles/key" + +// CommonKeyMap holds the key bindings shared between the issues dashboard +// and the kanban board. +type CommonKeyMap struct { + Help key.Binding + Quit key.Binding + SelectIssue key.Binding + BackToList key.Binding + ScrollUp key.Binding + ScrollDown key.Binding + EditTitle key.Binding + EditDescription key.Binding + ChangeStatus key.Binding + ChangePriority key.Binding + ChangeType key.Binding + AddIssue key.Binding + DeleteIssue key.Binding +} + +// DefaultCommonKeyMap returns the shared default bindings used by both views. +func DefaultCommonKeyMap() CommonKeyMap { + return CommonKeyMap{ + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "help"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), + SelectIssue: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "view issue"), + ), + BackToList: key.NewBinding( + key.WithKeys("b"), + key.WithHelp("b", "back to list"), + ), + ScrollUp: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + ScrollDown: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + EditTitle: key.NewBinding( + key.WithKeys("e"), + key.WithHelp("e", "edit title"), + ), + EditDescription: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "edit description"), + ), + ChangeStatus: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "change status"), + ), + ChangePriority: key.NewBinding( + key.WithKeys("p"), + key.WithHelp("p", "change priority"), + ), + ChangeType: key.NewBinding( + key.WithKeys("t"), + key.WithHelp("t", "change type"), + ), + AddIssue: key.NewBinding( + key.WithKeys("a"), + key.WithHelp("a", "add issue"), + ), + DeleteIssue: key.NewBinding( + key.WithKeys("x"), + key.WithHelp("x", "delete issue"), + ), + } +} + diff --git a/pkg/tui/components/modals.go b/pkg/tui/components/modals.go new file mode 100644 index 0000000..3541b5f --- /dev/null +++ b/pkg/tui/components/modals.go @@ -0,0 +1,163 @@ +package components + +import ( + "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/LazyBachelor/LazyPM/pkg/tui/styles" + "github.com/charmbracelet/lipgloss" +) + +// components contains reusable TUI modal renderers for issue actions. + +func RenderEditTitle(width, height int, inputView string) string { + editBoxWidth := min(60, width-4) + editContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Edit title (Enter to save, Esc to cancel):"), + inputView, + ) + editBox := styles.ContainerStyle. + Width(editBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(editContent) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, editBox) +} + +func RenderEditDescription(width, height int, inputView string) string { + editBoxWidth := min(60, width-4) + editContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Edit description (Ctrl+S to save, Esc to cancel):"), + inputView, + ) + editBox := styles.ContainerStyle. + Width(editBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(editContent) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, editBox) +} + +func RenderCreateIssue(width, height int, inputView string) string { + createBoxWidth := min(60, width-4) + createContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("New issue (Enter to create, Esc to cancel):"), + inputView, + ) + createBox := styles.ContainerStyle. + Width(createBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(createContent) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, createBox) +} + +func RenderConfirmDelete(width, height int, issueID string) string { + confirmContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Delete issue "+issueID+"?"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("Press y to delete, n or Esc to cancel"), + ) + confirmBoxWidth := min(50, width-4) + confirmBox := styles.ContainerStyle. + Width(confirmBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(confirmContent) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, confirmBox) +} + +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"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), + ) + statusBoxWidth := min(50, width-4) + statusBox := styles.ContainerStyle. + Width(statusBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(statusContent) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, statusBox) +} + +func RenderChoosePriority(width, height int, issueID string) string { + priorityContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Change priority for "+issueID+":"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("0 = irrelevant 1 = low 2 = normal 3 = high 4 = critical"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), + ) + priorityBoxWidth := min(60, width-4) + priorityBox := styles.ContainerStyle. + Width(priorityBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(priorityContent) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, priorityBox) +} + +func RenderChooseType(width, height int, issueID string) string { + typeContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Change type for "+issueID+":"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("b = bug f = feature t = task e = epic c = chore"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), + ) + typeBoxWidth := min(65, width-4) + typeBox := styles.ContainerStyle. + Width(typeBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(typeContent) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, typeBox) +} + +// RenderModals wraps the common modal overlay logic used by different views. +// It returns either one of the modal overlays (edit title/description, create, +// confirm delete, choose status/priority/type) or the provided main view if +// no modal is active. +func RenderModals( + width, height int, + editingTitle bool, titleInputView string, + editingDescription bool, descriptionInputView string, + creatingIssue bool, createTitleInputView string, + confirmingDelete bool, deleteIssueID string, + choosingStatus bool, statusIssueID string, + choosingPriority bool, priorityIssueID string, + choosingType bool, typeIssueID string, + mainView string, +) string { + if editingTitle { + return RenderEditTitle(width, height, titleInputView) + } + + if editingDescription { + return RenderEditDescription(width, height, descriptionInputView) + } + + if creatingIssue { + return RenderCreateIssue(width, height, createTitleInputView) + } + + if confirmingDelete { + return RenderConfirmDelete(width, height, deleteIssueID) + } + + if choosingStatus { + return RenderChooseStatus(width, height, statusIssueID) + } + + if choosingPriority { + return RenderChoosePriority(width, height, priorityIssueID) + } + + if choosingType { + return RenderChooseType(width, height, typeIssueID) + } + + return mainView +} + +// RenderFooter renders the shared footer with the help bar and optional +// validation feedback message. +func RenderFooter(width int, helpBar *HelpBar, feedback models.ValidationFeedback) string { + feedbackStatus := feedback.Message + + if feedbackStatus == "" { + return helpBar.View() + } + + helpBar.SetWidth(width - lipgloss.Width(feedbackStatus)) + return lipgloss.JoinHorizontal(lipgloss.Left, helpBar.View(), feedbackStatus) +} + diff --git a/pkg/tui/issues/operations.go b/pkg/tui/issues/operations.go new file mode 100644 index 0000000..01966fb --- /dev/null +++ b/pkg/tui/issues/operations.go @@ -0,0 +1,88 @@ +package issues + +import ( + "context" + + "github.com/LazyBachelor/LazyPM/internal/app" + "github.com/LazyBachelor/LazyPM/internal/models" + tea "github.com/charmbracelet/bubbletea" +) + +// Msg types used by both dashboard and kanban TUI views. +type ( + TitleUpdatedMsg struct{ IssueID string; Err error } + DescriptionUpdatedMsg struct{ IssueID string; Err error } + StatusUpdatedMsg struct{ IssueID string; Err error } + PriorityUpdatedMsg struct{ IssueID string; Err error } + TypeUpdatedMsg struct{ IssueID string; Err error } + SelectIssueMsg struct{ IssueID string } + CreatedMsg struct{ Issue *models.Issue; Err error } + DeletedMsg struct{ IssueID string; Err error; PreviousIndex int } +) + +// UpdateIssueTitleCmd returns a command that updates an issue's title. +func UpdateIssueTitleCmd(app *app.App, issueID, newTitle string) tea.Cmd { + return func() tea.Msg { + updates := map[string]interface{}{"title": newTitle} + err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") + return TitleUpdatedMsg{IssueID: issueID, Err: err} + } +} + +// UpdateIssueDescriptionCmd returns a command that updates an issue's description. +func UpdateIssueDescriptionCmd(app *app.App, issueID, newDescription string) tea.Cmd { + return func() tea.Msg { + updates := map[string]interface{}{"description": newDescription} + err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") + return DescriptionUpdatedMsg{IssueID: issueID, Err: err} + } +} + +// UpdateIssueStatusCmd returns a command that updates an issue's status. +func UpdateIssueStatusCmd(app *app.App, issueID, status string) tea.Cmd { + return func() tea.Msg { + updates := map[string]interface{}{"status": status} + err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") + return StatusUpdatedMsg{IssueID: issueID, Err: err} + } +} + +// UpdateIssuePriorityCmd returns a command that updates an issue's priority. +func UpdateIssuePriorityCmd(app *app.App, issueID string, priority int) tea.Cmd { + return func() tea.Msg { + updates := map[string]interface{}{"priority": priority} + err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") + return PriorityUpdatedMsg{IssueID: issueID, Err: err} + } +} + +// UpdateIssueTypeCmd returns a command that updates an issue's type. +func UpdateIssueTypeCmd(app *app.App, issueID string, issueType models.IssueType) tea.Cmd { + return func() tea.Msg { + updates := map[string]interface{}{"issue_type": string(issueType)} + err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") + return TypeUpdatedMsg{IssueID: issueID, Err: err} + } +} + +// CreateIssueCmd returns a command that creates a new issue. +func CreateIssueCmd(app *app.App, title string) tea.Cmd { + return func() tea.Msg { + issue := &models.Issue{ + Title: title, + Status: models.StatusOpen, + IssueType: models.TypeTask, + Priority: 2, + } + err := app.Issues.CreateIssue(context.Background(), issue, "tui") + return CreatedMsg{Issue: issue, Err: err} + } +} + +// DeleteIssueCmd returns a command that deletes an issue. +func DeleteIssueCmd(app *app.App, issueID string, currentIndex int) tea.Cmd { + return func() tea.Msg { + err := app.Issues.DeleteIssue(context.Background(), issueID) + return DeletedMsg{IssueID: issueID, Err: err, PreviousIndex: currentIndex} + } +} diff --git a/pkg/tui/msgs/msgs.go b/pkg/tui/msgs/msgs.go index 8b48d50..047787f 100644 --- a/pkg/tui/msgs/msgs.go +++ b/pkg/tui/msgs/msgs.go @@ -3,5 +3,5 @@ package msgs // SwitchToDashboardMsg signals to switch to the main dashboard view. type SwitchToDashboardMsg struct{} -// SwitchToDashboard2Msg signals to switch to the dashboard2 view. -type SwitchToDashboard2Msg struct{} +// SwitchToKanbanBoardMsg signals to switch to the kanban board view. +type SwitchToKanbanBoardMsg struct{} diff --git a/pkg/tui/views/dashboard/help_bar.go b/pkg/tui/views/dashboard/help_bar.go deleted file mode 100644 index e8771eb..0000000 --- a/pkg/tui/views/dashboard/help_bar.go +++ /dev/null @@ -1,106 +0,0 @@ -package dashboard - -import ( - "github.com/LazyBachelor/LazyPM/pkg/tui/styles" - "github.com/charmbracelet/lipgloss" -) - -type HelpBar struct { - keyMap DashboardKeyMap - showAll bool - width int -} - -func NewHelpBar(keyMap DashboardKeyMap) HelpBar { - return HelpBar{keyMap: keyMap} -} - -func (h *HelpBar) SetWidth(width int) { - h.width = width -} - -func (h HelpBar) View() string { - if h.width == 0 { - return "" - } - if h.showAll { - return h.fullHelp() - } - return h.shortHelp() -} - -func (h HelpBar) shortHelp() string { - keys := []string{ - styles.HighlightKey("tab") + " switch", - styles.HighlightKey("2") + " dash2", - styles.HighlightKey("↑/k") + " up", - styles.HighlightKey("↓/j") + " down", - styles.HighlightKey("pgup/pgdn") + " page", - styles.HighlightKey("a") + " add", - styles.HighlightKey("e/d/s/p/t") + " edit", - styles.HighlightKey("x") + " delete", - styles.HighlightKey("q") + " quit", - styles.HighlightKey("?") + " help", - } - content := lipgloss.JoinHorizontal(lipgloss.Left, keys...) - return lipgloss.NewStyle(). - Border(lipgloss.Border{Top: "─"}, true, false, false, false). - BorderForeground(styles.SecondaryBorder). - Padding(0, 1). - Width(h.width). - Render(content) -} - -func (h HelpBar) fullHelp() string { - keyStyle := lipgloss.NewStyle().Width(8).Align(lipgloss.Right) - descStyle := lipgloss.NewStyle().Width(12) - - renderHelpItem := func(key, desc string) string { - return lipgloss.JoinHorizontal( - lipgloss.Left, - keyStyle.Render(styles.HighlightKey(key)), - " ", - descStyle.Render(desc), - ) - } - - renderRow := func(leftKey, leftDesc, rightKey, rightDesc string) string { - leftItem := renderHelpItem(leftKey, leftDesc) - rightItem := renderHelpItem(rightKey, rightDesc) - return lipgloss.JoinHorizontal(lipgloss.Left, leftItem, " ", rightItem) - } - - rows := []string{ - renderRow("tab", "switch window", "↑/k", "up"), - renderRow("enter", "view issue", "↓/j", "down"), - renderRow("pgup", "page up", "pgdn", "page down"), - renderRow("b", "back to list", "a", "add issue"), - renderRow("e", "edit title", "d", "edit description"), - renderRow("s", "change status", "p", "change priority"), - renderRow("t", "change type", "x", "delete issue"), - renderRow("ctrl+2", "dashboard 2", "q", "quit"), - renderRow("?", "help", "", ""), - } - content := lipgloss.JoinVertical(lipgloss.Left, rows...) - return lipgloss.NewStyle(). - Border(lipgloss.Border{Top: "─"}, true, false, false, false). - BorderForeground(styles.SecondaryBorder). - Padding(0, 1). - Width(h.width). - Render(content) -} - -func (h HelpBar) Height() int { - if h.width == 0 { - return 0 - } - return lipgloss.Height(h.View()) -} - -func (h HelpBar) IsExpanded() bool { - return h.showAll -} - -func (h *HelpBar) ToggleHelp() { - h.showAll = !h.showAll -} diff --git a/pkg/tui/views/dashboard/issue_list.go b/pkg/tui/views/dashboard/issue_list.go deleted file mode 100644 index c2eb4cb..0000000 --- a/pkg/tui/views/dashboard/issue_list.go +++ /dev/null @@ -1,346 +0,0 @@ -package dashboard - -import ( - "context" - "fmt" - "io" - "sort" - - "github.com/LazyBachelor/LazyPM/internal/app" - "github.com/LazyBachelor/LazyPM/internal/models" - "github.com/LazyBachelor/LazyPM/pkg/tui/styles" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/muesli/reflow/truncate" -) - -type IssueList struct { - list list.Model - app *app.App - width int - height int -} - -type ListIssue struct { - models.Issue -} - -func (l ListIssue) Title() string { return l.Issue.Title } -func (l ListIssue) Description() string { return l.Issue.Description } -func (l ListIssue) FilterValue() string { return l.Issue.ID + " " + l.Issue.Title } - -type TableColumn struct { - width uint - label string - key string -} - -func getTableColumns(width int) []TableColumn { - switch { - case width < 45: - return []TableColumn{ - {width: 10, label: "ID", key: "id"}, - {width: uint(width - 10), label: "TITLE", key: "title"}, - } - case width < 60: - return []TableColumn{ - {width: 10, label: "ID", key: "id"}, - {width: 20, label: "TITLE", key: "title"}, - {width: 15, label: "STATUS", key: "status"}, - } - default: - return []TableColumn{ - {width: 12, label: "ID", key: "id"}, - {width: 20, label: "TITLE", key: "title"}, - {width: 15, label: "STATUS", key: "status"}, - {width: 10, label: "TYPE", key: "type"}, - {width: 15, label: "PRIORITY", key: "priority"}, - } - } -} - -func renderHeaders(cols []TableColumn) string { - var parts []string - headerStyle := lipgloss.NewStyle().Foreground(styles.FaintText).Bold(true) - - for _, col := range cols { - colWidth := col.width - if colWidth == 0 { - colWidth = 1 - } - style := lipgloss.NewStyle().Width(int(colWidth)) - headerText := headerStyle.Render(truncate.StringWithTail(col.label, colWidth, "...")) - parts = append(parts, style.Render(headerText)) - } - - return lipgloss.JoinHorizontal(lipgloss.Left, parts...) -} - -func NewIssueList(app *app.App, width, height int) IssueList { - issues, err := app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) - if err != nil { - return IssueList{} - } - - listIssues := []ListIssue{} - for _, issue := range issues { - listIssues = append(listIssues, ListIssue{Issue: *issue}) - } - - items := make([]list.Item, len(listIssues)) - for i, issue := range listIssues { - items[i] = issue - } - - l := list.New(items, NewIssueListDelegate(width), width, height) - l.SetShowTitle(false) - l.SetShowHelp(false) - l.SetShowStatusBar(false) - l.SetFilteringEnabled(true) - l.FilterInput.PromptStyle = styles.FilterPromptStyle - l.FilterInput.Cursor.Style = styles.FilterStyle - l.FilterInput.TextStyle = styles.FilterInputStyle - l.FilterInput.Prompt = "🔍 " - - return IssueList{ - list: l, - app: app, - width: width, - height: height, - } -} - -func NewIssueListFromIssues(app *app.App, issues []*models.Issue, width, height int) IssueList { - // for making an IssueList from a pre-existing list of issues. - listIssues := make([]list.Item, len(issues)) - for i, issue := range issues { - listIssues[i] = ListIssue{Issue: *issue} - } - l := list.New(listIssues, NewIssueListDelegate(width), width, height) - l.SetShowTitle(false) - l.SetShowHelp(false) - l.SetShowStatusBar(false) - l.SetFilteringEnabled(true) - l.FilterInput.PromptStyle = styles.FilterPromptStyle - l.FilterInput.Cursor.Style = styles.FilterStyle - l.FilterInput.TextStyle = styles.FilterInputStyle - l.FilterInput.Prompt = "🔍 " - return IssueList{ - list: l, - app: app, - width: width, - height: height, - } -} - -func OpenAndInProgressOnly(issues []*models.Issue) []*models.Issue { - // used to display open & in-progress issues in the first window in the dashboard - out := make([]*models.Issue, 0, len(issues)) - for _, issue := range issues { - if issue.Status == models.StatusOpen || issue.Status == models.StatusInProgress { - out = append(out, issue) - } - } - sortByPriorityDesc(out) - return out -} - -func ClosedOnly(issues []*models.Issue) []*models.Issue { - // used to display issues in the second window in the dashboard - out := make([]*models.Issue, 0, len(issues)) - for _, issue := range issues { - if issue.Status == models.StatusClosed { - out = append(out, issue) - } - } - sortByPriorityDesc(out) - return out -} - -func sortByPriorityDesc(issues []*models.Issue) { - // sorts issues by priority, highest first. - sort.Slice(issues, func(i, j int) bool { - return issues[i].Priority > issues[j].Priority - }) -} - -func (l *IssueList) Update(msg tea.Msg) (tea.Cmd, bool) { - var cmd tea.Cmd - oldIndex := l.list.Index() - l.list, cmd = l.list.Update(msg) - changed := l.list.Index() != oldIndex - return cmd, changed -} - -func (l *IssueList) SetSize(width, height int) { - l.width = width - l.height = height - - l.list.SetSize(width, height) - l.list.SetDelegate(NewIssueListDelegate(width)) -} - -func (l IssueList) View() string { - return l.renderResponsive() -} - -func (l IssueList) renderResponsive() string { - cols := getTableColumns(l.width) - header := renderHeaders(cols) - - var content []string - - if l.list.FilterState() == list.Filtering { - filterText := l.list.FilterInput.Value() - filterView := styles.FilterStyle.Render("🔍 " + filterText) - content = append(content, filterView) - } - - itemsView := l.renderFilteredItems() - content = append(content, header, itemsView) - - return styles.ContainerStyle. - Width(l.width). - MaxWidth(l.width). - MaxHeight(l.height). - Render(lipgloss.JoinVertical(lipgloss.Left, content...)) -} - -func (l IssueList) renderFilteredItems() string { - var items []string - - var visibleItems []list.Item - if l.list.FilterState() == list.Filtering || l.list.FilterState() == list.FilterApplied { - visibleItems = l.list.VisibleItems() - } else { - visibleItems = l.list.Items() - } - - start, end := l.list.Paginator.GetSliceBounds(len(visibleItems)) - - cursor := l.list.Index() - - for i := start; i < end && i < len(visibleItems); i++ { - isSelected := i == cursor - if issue, ok := visibleItems[i].(ListIssue); ok { - cols := getTableColumns(l.width) - row := renderRow(issue, isSelected, cols) - items = append(items, row) - } - } - - return lipgloss.JoinVertical(lipgloss.Left, items...) -} - -func (l IssueList) SelectedItem() ListIssue { - if item, ok := l.list.SelectedItem().(ListIssue); ok { - return item - } - return ListIssue{} -} - -func (l IssueList) Index() int { - return l.list.Index() -} - -func (l IssueList) FilterState() list.FilterState { - return l.list.FilterState() -} - -func (l *IssueList) SetIssues(issues []*models.Issue) tea.Cmd { - listIssues := make([]list.Item, len(issues)) - for i, issue := range issues { - listIssues[i] = ListIssue{Issue: *issue} - } - return l.list.SetItems(listIssues) -} - -func (l *IssueList) SelectIssueID(issueID string) { - items := l.list.Items() - for i := 0; i < len(items); i++ { - if item, ok := items[i].(ListIssue); ok && item.ID == issueID { - l.list.Select(i) - return - } - } -} - -type IssueListDelegate struct { - width int -} - -func NewIssueListDelegate(width int) IssueListDelegate { - return IssueListDelegate{width: width} -} - -func (d IssueListDelegate) Height() int { return 1 } -func (d IssueListDelegate) Spacing() int { return 0 } -func (d IssueListDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } - -func (d IssueListDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { - issue, ok := listItem.(ListIssue) - if !ok { - return - } - - isSelected := index == m.Index() - cols := getTableColumns(d.width) - - row := renderRow(issue, isSelected, cols) - io.WriteString(w, row) -} - -func renderRow(issue ListIssue, isSelected bool, cols []TableColumn) string { - var parts []string - - for _, col := range cols { - value := getColumnValue(col, issue) - colWidth := col.width - if colWidth == 0 { - colWidth = 1 - } - - style := lipgloss.NewStyle().Width(int(colWidth)) - if isSelected { - style = style.Background(styles.SelectedBackground).Bold(true) - } - - truncated := truncate.StringWithTail(value, colWidth, "...") - parts = append(parts, style.Render(truncated)) - } - - return lipgloss.JoinHorizontal(lipgloss.Left, parts...) -} - -var priorityCodeNames = map[int]string{ - 0: "irrelevant", - 1: "low", - 2: "normal", - 3: "high", - 4: "critical", -} - -func priorityCodeName(priority int) string { - if name, ok := priorityCodeNames[priority]; ok { - return name - } - return fmt.Sprintf("%d", priority) -} - -func getColumnValue(col TableColumn, issue ListIssue) string { - switch col.key { - case "id": - return issue.ID - case "title": - return issue.Title() - case "status": - return string(issue.Issue.Status) - case "type": - return string(issue.Issue.IssueType) - case "priority": - return priorityCodeName(issue.Issue.Priority) - default: - return "" - } -} diff --git a/pkg/tui/views/dashboard/keys.go b/pkg/tui/views/dashboard/keys.go index b6ecc67..63a74d4 100644 --- a/pkg/tui/views/dashboard/keys.go +++ b/pkg/tui/views/dashboard/keys.go @@ -1,89 +1,27 @@ package dashboard import ( + "github.com/LazyBachelor/LazyPM/pkg/tui/components" "github.com/LazyBachelor/LazyPM/pkg/tui/msgs" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" ) type DashboardKeyMap struct { - Help key.Binding - Quit key.Binding - SelectIssue key.Binding - BackToList key.Binding - ScrollUp key.Binding - ScrollDown key.Binding - SwitchWindow key.Binding - SwitchToDashboard2 key.Binding - EditTitle key.Binding - EditDescription key.Binding - ChangeStatus key.Binding - ChangePriority key.Binding - ChangeType key.Binding - AddIssue key.Binding - DeleteIssue key.Binding + components.CommonKeyMap + SwitchWindow key.Binding + SwitchToKanbanBoard key.Binding } var defaultDashboardKeyMap = DashboardKeyMap{ - Help: key.NewBinding( - key.WithKeys("?"), - key.WithHelp("?", "help"), - ), - Quit: key.NewBinding( - key.WithKeys("q", "ctrl+c"), - key.WithHelp("q", "quit"), - ), - SelectIssue: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "view issue"), - ), - BackToList: key.NewBinding( - key.WithKeys("b"), - key.WithHelp("b", "back to list"), - ), - ScrollUp: key.NewBinding( - key.WithKeys("up", "k"), - key.WithHelp("↑/k", "up"), - ), - ScrollDown: key.NewBinding( - key.WithKeys("down", "j"), - key.WithHelp("↓/j", "down"), - ), + CommonKeyMap: components.DefaultCommonKeyMap(), SwitchWindow: key.NewBinding( key.WithKeys("tab"), key.WithHelp("tab", "switch window"), ), - SwitchToDashboard2: key.NewBinding( - key.WithKeys("2"), - key.WithHelp("2", "dashboard 2"), - ), - EditTitle: key.NewBinding( - key.WithKeys("e"), - key.WithHelp("e", "edit title"), - ), - EditDescription: key.NewBinding( - key.WithKeys("d"), - key.WithHelp("d", "edit description"), - ), - ChangeStatus: key.NewBinding( - key.WithKeys("s"), - key.WithHelp("s", "change status"), - ), - ChangePriority: key.NewBinding( - key.WithKeys("p"), - key.WithHelp("p", "change priority"), - ), - ChangeType: key.NewBinding( - key.WithKeys("t"), - key.WithHelp("t", "change type"), - ), - AddIssue: key.NewBinding( - key.WithKeys("a"), - key.WithHelp("a", "add issue"), - ), - DeleteIssue: key.NewBinding( - key.WithKeys("x"), - key.WithHelp("x", "delete issue"), + SwitchToKanbanBoard: key.NewBinding( + key.WithKeys("k"), + key.WithHelp("k", "switch to kanban"), ), } @@ -97,8 +35,8 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { return tea.Quit case key.Matches(msg, d.keyMap.SwitchWindow): d.ToggleFocusedWindow() - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.SwitchToDashboard2): - return func() tea.Msg { return msgs.SwitchToDashboard2Msg{} } + case !d.IsInModal() && key.Matches(msg, d.keyMap.SwitchToKanbanBoard): + return func() tea.Msg { return msgs.SwitchToKanbanBoardMsg{} } case d.IsFocusedOnList() && key.Matches(msg, d.keyMap.SelectIssue): d.FocusDetail() case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.BackToList): @@ -107,32 +45,32 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { d.issueDetail.ScrollUp(1) case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.ScrollDown): d.issueDetail.ScrollDown(1) - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.EditTitle): + case !d.IsInModal() && key.Matches(msg, d.keyMap.EditTitle): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startEditTitle(selected) cmd = d.titleInput.Focus() } - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.EditDescription): + case !d.IsInModal() && key.Matches(msg, d.keyMap.EditDescription): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startEditDescription(selected) cmd = d.descriptionInput.Focus() } - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.ChangeStatus): + case !d.IsInModal() && key.Matches(msg, d.keyMap.ChangeStatus): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startChooseStatus(selected) } - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.ChangePriority): + case !d.IsInModal() && key.Matches(msg, d.keyMap.ChangePriority): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startChoosePriority(selected) } - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.ChangeType): + case !d.IsInModal() && key.Matches(msg, d.keyMap.ChangeType): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startChooseType(selected) } - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.AddIssue): + case !d.IsInModal() && key.Matches(msg, d.keyMap.AddIssue): d.startCreateIssue() cmd = d.createTitleInput.Focus() - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.DeleteIssue): + case !d.IsInModal() && key.Matches(msg, d.keyMap.DeleteIssue): fl := d.FocusedIssueList() if selected := fl.SelectedItem(); selected.ID != "" { d.startConfirmDelete(selected.ID, fl.Index()) diff --git a/pkg/tui/views/dashboard/model.go b/pkg/tui/views/dashboard/model.go index dde0979..ceb37cd 100644 --- a/pkg/tui/views/dashboard/model.go +++ b/pkg/tui/views/dashboard/model.go @@ -5,21 +5,26 @@ import ( "github.com/LazyBachelor/LazyPM/internal/app" "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/LazyBachelor/LazyPM/pkg/tui/components" "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" ) -type ValidationFeedbackMsg struct { - Feedback models.ValidationFeedback -} +// Use shared types from components for consistency. +type ( + Header = components.Header + IssueList = components.IssueList + IssueDetail = components.IssueDetail + ListIssue = components.ListIssue +) type Model struct { header Header issueList IssueList issueDetail IssueDetail closedIssueList IssueList - helpBar HelpBar + helpBar components.HelpBar keyMap DashboardKeyMap app *app.App width int @@ -55,7 +60,7 @@ type Model struct { func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool) *Model { m := &Model{ - header: NewHeader("Project Manager Dashboard"), + header: components.NewHeader("Project Manager Dashboard"), keyMap: defaultDashboardKeyMap, app: app, width: 80, @@ -68,26 +73,15 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui } allIssues, _ := app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) - m.issueList = NewIssueListFromIssues(app, OpenAndInProgressOnly(allIssues), 0, 0) - m.issueDetail = NewIssueDetail() - m.closedIssueList = NewIssueListFromIssues(app, ClosedOnly(allIssues), 0, 0) - m.helpBar = NewHelpBar(m.keyMap) + m.issueList = components.NewIssueListFromIssues(app, components.OpenAndInProgressOnly(allIssues), 0, 0) + m.issueDetail = components.NewIssueDetail() + m.closedIssueList = components.NewIssueListFromIssues(app, components.ClosedOnly(allIssues), 0, 0) + m.helpBar = components.NewHelpBar(components.ViewIssues) - ti := textinput.New() - ti.Placeholder = "Issue title ..." - ti.CharLimit = 256 - m.titleInput = ti - - createTi := textinput.New() - createTi.Placeholder = "New issue title ..." - createTi.CharLimit = 256 - m.createTitleInput = createTi - - descTa := textarea.New() - descTa.Placeholder = "Issue description..." - descTa.SetWidth(56) - descTa.SetHeight(8) - m.descriptionInput = descTa + inputs := components.NewIssueInputs() + m.titleInput = inputs.Title + m.createTitleInput = inputs.CreateTitle + m.descriptionInput = inputs.Description if selected := m.issueList.SelectedItem(); selected.ID != "" { m.issueDetail.SetIssue(selected.Issue) @@ -140,14 +134,13 @@ func (m *Model) startChooseType(selected ListIssue) { } func (m *Model) Init() tea.Cmd { - return m.listenForValidation() + return components.ListenForValidation(m.feedbackChan) } -func (m *Model) listenForValidation() tea.Cmd { - return func() tea.Msg { - feedback := <-m.feedbackChan - return ValidationFeedbackMsg{Feedback: feedback} - } +// 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 } func (m *Model) IsFocusedOnList() bool { diff --git a/pkg/tui/views/dashboard/operations.go b/pkg/tui/views/dashboard/operations.go index 8d53ee9..3826d1c 100644 --- a/pkg/tui/views/dashboard/operations.go +++ b/pkg/tui/views/dashboard/operations.go @@ -3,134 +3,35 @@ package dashboard import ( "context" - "github.com/LazyBachelor/LazyPM/internal/app" "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/LazyBachelor/LazyPM/pkg/tui/components" + "github.com/LazyBachelor/LazyPM/pkg/tui/issues" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" ) -type issueTitleUpdatedMsg struct { - IssueID string - Err error -} - -type issueDescriptionUpdatedMsg struct { - IssueID string - Err error -} - -type issueStatusUpdatedMsg struct { - IssueID string - Err error -} - -type issuePriorityUpdatedMsg struct { - IssueID string - Err error -} - -type issueTypeUpdatedMsg struct { - IssueID string - Err error -} - -type selectIssueMsg struct { - IssueID string -} - -type issueCreatedMsg struct { - Issue *models.Issue - Err error -} - -type issueDeletedMsg struct { - IssueID string - Err error - PreviousIndex int -} - -func updateIssueTitleCmd(app *app.App, issueID, newTitle string) tea.Cmd { - return func() tea.Msg { - updates := map[string]interface{}{"title": newTitle} - err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") - return issueTitleUpdatedMsg{IssueID: issueID, Err: err} - } -} - -func updateIssueDescriptionCmd(app *app.App, issueID, newDescription string) tea.Cmd { - return func() tea.Msg { - updates := map[string]interface{}{"description": newDescription} - err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") - return issueDescriptionUpdatedMsg{IssueID: issueID, Err: err} - } -} - -func updateIssueStatusCmd(app *app.App, issueID, status string) tea.Cmd { - return func() tea.Msg { - updates := map[string]interface{}{"status": status} - err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") - return issueStatusUpdatedMsg{IssueID: issueID, Err: err} - } -} - -func updateIssuePriorityCmd(app *app.App, issueID string, priority int) tea.Cmd { - return func() tea.Msg { - updates := map[string]interface{}{"priority": priority} - err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") - return issuePriorityUpdatedMsg{IssueID: issueID, Err: err} - } -} - -func updateIssueTypeCmd(app *app.App, issueID string, issueType models.IssueType) tea.Cmd { - return func() tea.Msg { - updates := map[string]interface{}{"issue_type": string(issueType)} - err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") - return issueTypeUpdatedMsg{IssueID: issueID, Err: err} - } -} - -func createIssueCmd(app *app.App, title string) tea.Cmd { - return func() tea.Msg { - issue := &models.Issue{ - Title: title, - Status: models.StatusOpen, - IssueType: models.TypeTask, - Priority: 2, - } - err := app.Issues.CreateIssue(context.Background(), issue, "tui") - return issueCreatedMsg{Issue: issue, Err: err} - } -} - -func deleteIssueCmd(app *app.App, issueID string, currentIndex int) tea.Cmd { - return func() tea.Msg { - err := app.Issues.DeleteIssue(context.Background(), issueID) - return issueDeletedMsg{IssueID: issueID, Err: err, PreviousIndex: currentIndex} - } -} - func (m *Model) refreshIssueListsAndSelectIssue(issueID string) tea.Cmd { /* update handler for issueTitleUpdatedMsg, issueDescriptionUpdatedMsg, and issueStatusUpdatedMsg to avoid using nearly identical code for refreshing the issue lists and updating the detail view Fetch all issues, update both lists, set the detail view for the given issue, and return a command to select that issue. Returns nil if fetch fails. */ - issues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) + allIssues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) if err != nil { return nil } - setItemsCmd := m.issueList.SetIssues(OpenAndInProgressOnly(issues)) - closedSetCmd := m.closedIssueList.SetIssues(ClosedOnly(issues)) - for _, issue := range issues { + setItemsCmd := m.issueList.SetIssues(components.OpenAndInProgressOnly(allIssues)) + closedSetCmd := m.closedIssueList.SetIssues(components.ClosedOnly(allIssues)) + for _, issue := range allIssues { if issue.ID == issueID { m.issueDetail.SetIssue(*issue) break } } - return tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { return selectIssueMsg{IssueID: issueID} }) + return tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { return issues.SelectIssueMsg{IssueID: issueID} }) } func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case issueTitleUpdatedMsg: + case issues.TitleUpdatedMsg: m.editingTitle = false m.editingIssueID = "" m.titleInput.Blur() @@ -139,7 +40,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) - case issueDescriptionUpdatedMsg: + case issues.DescriptionUpdatedMsg: m.editingDescription = false m.editingDescIssueID = "" m.descriptionInput.Blur() @@ -148,7 +49,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) - case issueStatusUpdatedMsg: + case issues.StatusUpdatedMsg: m.choosingStatus = false m.statusIssueID = "" if msg.Err != nil { @@ -156,7 +57,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) - case issuePriorityUpdatedMsg: + case issues.PriorityUpdatedMsg: m.choosingPriority = false m.priorityIssueID = "" if msg.Err != nil { @@ -164,7 +65,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) - case issueTypeUpdatedMsg: + case issues.TypeUpdatedMsg: m.choosingType = false m.typeIssueID = "" if msg.Err != nil { @@ -172,29 +73,29 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) - case selectIssueMsg: + case issues.SelectIssueMsg: m.issueList.SelectIssueID(msg.IssueID) m.closedIssueList.SelectIssueID(msg.IssueID) return m, nil - case issueCreatedMsg: + case issues.CreatedMsg: m.creatingIssue = false m.createTitleInput.Blur() m.createTitleInput.Reset() if msg.Err != nil || msg.Issue == nil { return m, nil } - issues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) + allIssues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) if err != nil { return m, nil } - setItemsCmd := m.issueList.SetIssues(OpenAndInProgressOnly(issues)) - closedSetCmd := m.closedIssueList.SetIssues(ClosedOnly(issues)) + setItemsCmd := m.issueList.SetIssues(components.OpenAndInProgressOnly(allIssues)) + closedSetCmd := m.closedIssueList.SetIssues(components.ClosedOnly(allIssues)) // Determine the created issue from the refreshed list to ensure all fields (like ID) are populated. selectedIssue := msg.Issue if selectedIssue.ID == "" { - for _, issue := range issues { + for _, issue := range allIssues { // Prefer an issue that matches the created issue's title when ID is not yet known. if issue.Title == msg.Issue.Title { selectedIssue = issue @@ -204,19 +105,19 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.issueDetail.SetIssue(*selectedIssue) - return m, tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { return selectIssueMsg{IssueID: selectedIssue.ID} }) - case issueDeletedMsg: + return m, tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { return issues.SelectIssueMsg{IssueID: selectedIssue.ID} }) + case issues.DeletedMsg: m.confirmingDelete = false m.deleteConfirmID = "" if msg.Err != nil { return m, nil } - issues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) + allIssues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) if err != nil { return m, nil } - openIssues := OpenAndInProgressOnly(issues) - closedIssues := ClosedOnly(issues) + openIssues := components.OpenAndInProgressOnly(allIssues) + closedIssues := components.ClosedOnly(allIssues) setItemsCmd := m.issueList.SetIssues(openIssues) closedSetCmd := m.closedIssueList.SetIssues(closedIssues) // If there are no issues at all, clear the detail view and return. @@ -256,7 +157,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { selectedIssue := targetIssues[newIndex] m.issueDetail.SetIssue(*selectedIssue) return m, tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { - return selectIssueMsg{IssueID: selectedIssue.ID} + return issues.SelectIssueMsg{IssueID: selectedIssue.ID} }) case tea.KeyMsg: @@ -267,7 +168,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { idx := m.deleteConfirmIndex m.confirmingDelete = false m.deleteConfirmID = "" - return m, deleteIssueCmd(m.app, issueID, idx) + return m, issues.DeleteIssueCmd(m.app, issueID, idx) case "n", "N", "esc": m.confirmingDelete = false m.deleteConfirmID = "" @@ -281,17 +182,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { issueID := m.statusIssueID m.choosingStatus = false m.statusIssueID = "" - return m, updateIssueStatusCmd(m.app, issueID, string(models.StatusOpen)) + return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusOpen)) case "i": issueID := m.statusIssueID m.choosingStatus = false m.statusIssueID = "" - return m, updateIssueStatusCmd(m.app, issueID, string(models.StatusInProgress)) + return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusInProgress)) case "c": issueID := m.statusIssueID m.choosingStatus = false m.statusIssueID = "" - return m, updateIssueStatusCmd(m.app, issueID, string(models.StatusClosed)) + return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusClosed)) case "esc": m.choosingStatus = false m.statusIssueID = "" @@ -306,7 +207,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { priority := int(msg.String()[0] - '0') m.choosingPriority = false m.priorityIssueID = "" - return m, updateIssuePriorityCmd(m.app, issueID, priority) + return m, issues.UpdateIssuePriorityCmd(m.app, issueID, priority) case "esc": m.choosingPriority = false m.priorityIssueID = "" @@ -322,27 +223,27 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { issueID := m.typeIssueID m.choosingType = false m.typeIssueID = "" - return m, updateIssueTypeCmd(m.app, issueID, models.TypeBug) + return m, issues.UpdateIssueTypeCmd(m.app, issueID, models.TypeBug) case "f": issueID := m.typeIssueID m.choosingType = false m.typeIssueID = "" - return m, updateIssueTypeCmd(m.app, issueID, models.TypeFeature) + return m, issues.UpdateIssueTypeCmd(m.app, issueID, models.TypeFeature) case "t": issueID := m.typeIssueID m.choosingType = false m.typeIssueID = "" - return m, updateIssueTypeCmd(m.app, issueID, models.TypeTask) + return m, issues.UpdateIssueTypeCmd(m.app, issueID, models.TypeTask) case "e": issueID := m.typeIssueID m.choosingType = false m.typeIssueID = "" - return m, updateIssueTypeCmd(m.app, issueID, models.TypeEpic) + return m, issues.UpdateIssueTypeCmd(m.app, issueID, models.TypeEpic) case "c": issueID := m.typeIssueID m.choosingType = false m.typeIssueID = "" - return m, updateIssueTypeCmd(m.app, issueID, models.TypeChore) + return m, issues.UpdateIssueTypeCmd(m.app, issueID, models.TypeChore) case "esc": m.choosingType = false m.typeIssueID = "" @@ -356,7 +257,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.String() == "enter" { title := m.createTitleInput.Value() if title != "" { - return m, createIssueCmd(m.app, title) + return m, issues.CreateIssueCmd(m.app, title) } } if msg.String() == "esc" { @@ -374,7 +275,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.String() == "enter" { newTitle := m.titleInput.Value() if newTitle != "" { - return m, updateIssueTitleCmd(m.app, m.editingIssueID, newTitle) + return m, issues.UpdateIssueTitleCmd(m.app, m.editingIssueID, newTitle) } } if msg.String() == "esc" { @@ -395,7 +296,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.editingDescription = false m.editingDescIssueID = "" m.descriptionInput.Blur() - return m, updateIssueDescriptionCmd(m.app, issueID, newDesc) + return m, issues.UpdateIssueDescriptionCmd(m.app, issueID, newDesc) } if msg.String() == "esc" { m.editingDescription = false @@ -423,13 +324,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd != nil { return m, cmd } - case ValidationFeedbackMsg: + case components.ValidationFeedbackMsg: m.currentFeedback = msg.Feedback if msg.Feedback.Success { m.showComplete = true return m, tea.Quit } - return m, m.listenForValidation() + return m, components.ListenForValidation(m.feedbackChan) case tea.WindowSizeMsg: m.width = msg.Width diff --git a/pkg/tui/views/dashboard/view.go b/pkg/tui/views/dashboard/view.go index 2df8781..5a5e99a 100644 --- a/pkg/tui/views/dashboard/view.go +++ b/pkg/tui/views/dashboard/view.go @@ -1,6 +1,7 @@ package dashboard import ( + "github.com/LazyBachelor/LazyPM/pkg/tui/components" "github.com/LazyBachelor/LazyPM/pkg/tui/styles" "github.com/charmbracelet/lipgloss" ) @@ -16,7 +17,7 @@ func (m *Model) View() string { header := m.header.View(m.width) headerHeight := m.header.Height() - footer := m.footer() + footer := components.RenderFooter(m.width, &m.helpBar, m.currentFeedback) footerHeight := lipgloss.Height(footer) // To avoid layer overflow or clipping, the label heights are calculated and subtracted from the available height before calculating the list heights to avoid layout overflow or clipping. @@ -39,6 +40,10 @@ func (m *Model) View() string { m.closedIssueList.SetSize(listWidth, halfHeight) m.issueDetail.SetSize(detailWidth, contentHeight) + // Only highlight the focused list; unfocused list should not show selection highlight. + m.issueList.SetHighlightSelected(m.focusedWindow == 0 && m.focusedPaneMain == 0) + m.closedIssueList.SetHighlightSelected(m.focusedWindow == 1 && m.focusedPaneClosed == 0) + listView := m.issueList.View() closedListView := m.closedIssueList.View() detailView := m.issueDetail.View() @@ -56,115 +61,29 @@ func (m *Model) View() string { mainView := lipgloss.JoinVertical(lipgloss.Left, header, content, footer) - if m.editingTitle { - editBoxWidth := min(60, m.width-4) - m.titleInput.Width = editBoxWidth - 2 - editContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("Edit title (Enter to save, Esc to cancel):"), - m.titleInput.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.editingDescription { - editBoxWidth := min(60, m.width-4) - m.descriptionInput.SetWidth(editBoxWidth - 2) - m.descriptionInput.SetHeight(10) - editContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("Edit description (Ctrl+S to save, Esc to cancel):"), - m.descriptionInput.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.creatingIssue { - createBoxWidth := min(60, m.width-4) - m.createTitleInput.Width = createBoxWidth - 2 - createContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("New issue (Enter to create, Esc to cancel):"), - m.createTitleInput.View(), - ) - createBox := styles.ContainerStyle. - Width(createBoxWidth). - BorderForeground(styles.PrimaryBorder). - Render(createContent) - return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, createBox) - } - - if m.confirmingDelete { - confirmContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("Delete issue "+m.deleteConfirmID+"?"), - lipgloss.NewStyle().Foreground(styles.FaintText).Render("Press y to delete, n or Esc to cancel"), - ) - confirmBoxWidth := min(50, m.width-4) - confirmBox := styles.ContainerStyle. - Width(confirmBoxWidth). - BorderForeground(styles.PrimaryBorder). - Render(confirmContent) - return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, confirmBox) - } - - 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"), - lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), - ) - statusBoxWidth := min(50, m.width-4) - statusBox := styles.ContainerStyle. - Width(statusBoxWidth). - BorderForeground(styles.PrimaryBorder). - Render(statusContent) - return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, statusBox) - } - - if m.choosingPriority { - priorityContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("Change priority for "+m.priorityIssueID+":"), - lipgloss.NewStyle().Foreground(styles.FaintText).Render("0 = irrelevant 1 = low 2 = normal 3 = high 4 = critical"), - lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), - ) - priorityBoxWidth := min(60, m.width-4) - priorityBox := styles.ContainerStyle. - Width(priorityBoxWidth). - BorderForeground(styles.PrimaryBorder). - Render(priorityContent) - return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, priorityBox) - } - - if m.choosingType { - typeContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("Change type for "+m.typeIssueID+":"), - lipgloss.NewStyle().Foreground(styles.FaintText).Render("b = bug f = feature t = task e = epic c = chore"), - lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), - ) - typeBoxWidth := min(65, m.width-4) - typeBox := styles.ContainerStyle. - Width(typeBoxWidth). - BorderForeground(styles.PrimaryBorder). - Render(typeContent) - return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, typeBox) - } - - return mainView + return components.RenderModals( + m.width, + m.height, + m.editingTitle, + m.titleInput.View(), + m.editingDescription, + m.descriptionInput.View(), + m.creatingIssue, + m.createTitleInput.View(), + m.confirmingDelete, + m.deleteConfirmID, + m.choosingStatus, + m.statusIssueID, + m.choosingPriority, + m.priorityIssueID, + m.choosingType, + m.typeIssueID, + mainView, + ) } func (m *Model) footer() string { - feedbackStatus := m.currentFeedback.Message - - if feedbackStatus == "" { - return m.helpBar.View() - } - - m.helpBar.SetWidth(m.width - lipgloss.Width(feedbackStatus)) - return lipgloss.JoinHorizontal(lipgloss.Left, m.helpBar.View(), feedbackStatus) + // Kept for backwards compatibility; delegate to the shared helper. + return components.RenderFooter(m.width, &m.helpBar, m.currentFeedback) } diff --git a/pkg/tui/views/dashboard2/header.go b/pkg/tui/views/dashboard2/header.go deleted file mode 100644 index 4cdfe35..0000000 --- a/pkg/tui/views/dashboard2/header.go +++ /dev/null @@ -1,32 +0,0 @@ -package dashboard2 - -import ( - "github.com/LazyBachelor/LazyPM/pkg/tui/styles" - "github.com/charmbracelet/lipgloss" -) - -type Header struct { - title string -} - -func NewHeader(title string) Header { - return Header{ - title: title, - } -} - -func (h Header) View(width int) string { - title := styles.HeaderTitleStyle.Render(h.title) - - return lipgloss.PlaceHorizontal( - width, - lipgloss.Left, - title, - lipgloss.WithWhitespaceChars("─"), - lipgloss.WithWhitespaceForeground(styles.Primary), - ) -} - -func (h Header) Height() int { - return 1 -} diff --git a/pkg/tui/views/dashboard2/help_bar.go b/pkg/tui/views/dashboard2/help_bar.go deleted file mode 100644 index 4aff189..0000000 --- a/pkg/tui/views/dashboard2/help_bar.go +++ /dev/null @@ -1,106 +0,0 @@ -package dashboard2 - -import ( - "github.com/LazyBachelor/LazyPM/pkg/tui/styles" - "github.com/charmbracelet/lipgloss" -) - -type HelpBar struct { - keyMap DashboardKeyMap - showAll bool - width int -} - -func NewHelpBar(keyMap DashboardKeyMap) HelpBar { - return HelpBar{keyMap: keyMap} -} - -func (h *HelpBar) SetWidth(width int) { - h.width = width -} - -func (h HelpBar) View() string { - if h.width == 0 { - return "" - } - if h.showAll { - return h.fullHelp() - } - return h.shortHelp() -} - -func (h HelpBar) shortHelp() string { - keys := []string{ - styles.HighlightKey("tab") + " switch", - styles.HighlightKey("1") + " dash1", - styles.HighlightKey("↑/k") + " up", - styles.HighlightKey("↓/j") + " down", - styles.HighlightKey("pgup/pgdn") + " page", - styles.HighlightKey("a") + " add", - styles.HighlightKey("e/d/s/p/t") + " edit", - styles.HighlightKey("x") + " delete", - styles.HighlightKey("q") + " quit", - styles.HighlightKey("?") + " help", - } - content := lipgloss.JoinHorizontal(lipgloss.Left, keys...) - return lipgloss.NewStyle(). - Border(lipgloss.Border{Top: "─"}, true, false, false, false). - BorderForeground(styles.SecondaryBorder). - Padding(0, 1). - Width(h.width). - Render(content) -} - -func (h HelpBar) fullHelp() string { - keyStyle := lipgloss.NewStyle().Width(8).Align(lipgloss.Right) - descStyle := lipgloss.NewStyle().Width(12) - - renderHelpItem := func(key, desc string) string { - return lipgloss.JoinHorizontal( - lipgloss.Left, - keyStyle.Render(styles.HighlightKey(key)), - " ", - descStyle.Render(desc), - ) - } - - renderRow := func(leftKey, leftDesc, rightKey, rightDesc string) string { - leftItem := renderHelpItem(leftKey, leftDesc) - rightItem := renderHelpItem(rightKey, rightDesc) - return lipgloss.JoinHorizontal(lipgloss.Left, leftItem, " ", rightItem) - } - - rows := []string{ - renderRow("tab", "switch window", "↑/k", "up"), - renderRow("enter", "view issue", "↓/j", "down"), - renderRow("pgup", "page up", "pgdn", "page down"), - renderRow("b", "back to list", "a", "add issue"), - renderRow("e", "edit title", "d", "edit description"), - renderRow("s", "change status", "p", "change priority"), - renderRow("t", "change type", "x", "delete issue"), - renderRow("ctrl+1", "dashboard 1", "q", "quit"), - renderRow("?", "help", "", ""), - } - content := lipgloss.JoinVertical(lipgloss.Left, rows...) - return lipgloss.NewStyle(). - Border(lipgloss.Border{Top: "─"}, true, false, false, false). - BorderForeground(styles.SecondaryBorder). - Padding(0, 1). - Width(h.width). - Render(content) -} - -func (h HelpBar) Height() int { - if h.width == 0 { - return 0 - } - return lipgloss.Height(h.View()) -} - -func (h HelpBar) IsExpanded() bool { - return h.showAll -} - -func (h *HelpBar) ToggleHelp() { - h.showAll = !h.showAll -} diff --git a/pkg/tui/views/dashboard2/issue_detail.go b/pkg/tui/views/dashboard2/issue_detail.go deleted file mode 100644 index 3a18022..0000000 --- a/pkg/tui/views/dashboard2/issue_detail.go +++ /dev/null @@ -1,100 +0,0 @@ -package dashboard2 - -import ( - "github.com/LazyBachelor/LazyPM/internal/models" - "github.com/LazyBachelor/LazyPM/pkg/tui/styles" - "github.com/charmbracelet/bubbles/viewport" - "github.com/charmbracelet/lipgloss" -) - -type IssueDetail struct { - viewport viewport.Model - issue models.Issue - focused bool -} - -func NewIssueDetail() IssueDetail { - vp := viewport.New(0, 0) - return IssueDetail{ - viewport: vp, - } -} - -func (i *IssueDetail) SetIssue(issue models.Issue) { - i.issue = issue - i.refreshContent() -} - -func (i *IssueDetail) SetSize(width, height int) { - i.viewport.Height = height - i.viewport.Width = width - i.refreshContent() -} - -func (i *IssueDetail) SetFocused(focused bool) { - i.focused = focused -} - -func (i *IssueDetail) refreshContent() { - - titleRow := styles.RowStyle.Render( - styles.TitleStyle.Render(i.issue.Title), - ) - - idRow := styles.RowStyle.Render( - styles.LabelStyle.Render("ID:") + styles.ValueStyle.Render(i.issue.ID), - ) - - typeRow := styles.RowStyle.Render( - styles.LabelStyle.Render("Type:") + styles.ValueStyle.Render(string(i.issue.IssueType)), - ) - - statusRow := styles.RowStyle.Render( - styles.LabelStyle.Render("Status:") + styles.StatusStyle(string(i.issue.Status)).Render(string(i.issue.Status)), - ) - - priorityRow := styles.RowStyle.Render( - styles.LabelStyle.Render("Priority:") + styles.ValueStyle.Render(priorityCodeName(i.issue.Priority)), - ) - - descLabel := styles.LabelStyle.Render("Description:") - descContent := styles.ValueStyle.Render(i.issue.Description) - - content := lipgloss.JoinVertical(lipgloss.Left, - titleRow, - idRow, - typeRow, - statusRow, - priorityRow, - descLabel, - descContent, - ) - - i.viewport.SetContent(content) -} - -func (i IssueDetail) View() string { - content := i.viewport.View() - - if i.focused { - return styles.DetailsContainerStyle. - BorderForeground(styles.PrimaryBorder). - Width(i.viewport.Width). - Height(i.viewport.Height). - MaxHeight(i.viewport.Height). - Render(content) - } - return styles.DetailsContainerStyle. - Width(i.viewport.Width). - Height(i.viewport.Height). - MaxHeight(i.viewport.Height). - Render(content) -} - -func (i *IssueDetail) ScrollUp(lines int) { - i.viewport.ScrollUp(lines) -} - -func (i *IssueDetail) ScrollDown(lines int) { - i.viewport.ScrollDown(lines) -} diff --git a/pkg/tui/views/dashboard2/keys.go b/pkg/tui/views/dashboard2/keys.go deleted file mode 100644 index 73ced55..0000000 --- a/pkg/tui/views/dashboard2/keys.go +++ /dev/null @@ -1,143 +0,0 @@ -package dashboard2 - -import ( - "github.com/LazyBachelor/LazyPM/pkg/tui/msgs" - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" -) - -type DashboardKeyMap struct { - Help key.Binding - Quit key.Binding - SelectIssue key.Binding - BackToList key.Binding - ScrollUp key.Binding - ScrollDown key.Binding - SwitchWindow key.Binding - SwitchToDashboard key.Binding - EditTitle key.Binding - EditDescription key.Binding - ChangeStatus key.Binding - ChangePriority key.Binding - ChangeType key.Binding - AddIssue key.Binding - DeleteIssue key.Binding -} - -var defaultDashboardKeyMap = DashboardKeyMap{ - Help: key.NewBinding( - key.WithKeys("?"), - key.WithHelp("?", "help"), - ), - Quit: key.NewBinding( - key.WithKeys("q", "ctrl+c"), - key.WithHelp("q", "quit"), - ), - SelectIssue: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "view issue"), - ), - BackToList: key.NewBinding( - key.WithKeys("b"), - key.WithHelp("b", "back to list"), - ), - ScrollUp: key.NewBinding( - key.WithKeys("up", "k"), - key.WithHelp("↑/k", "up"), - ), - ScrollDown: key.NewBinding( - key.WithKeys("down", "j"), - key.WithHelp("↓/j", "down"), - ), - SwitchWindow: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "switch window"), - ), - SwitchToDashboard: key.NewBinding( - key.WithKeys("1"), - key.WithHelp("1", "dashboard 1"), - ), - EditTitle: key.NewBinding( - key.WithKeys("e"), - key.WithHelp("e", "edit title"), - ), - EditDescription: key.NewBinding( - key.WithKeys("d"), - key.WithHelp("d", "edit description"), - ), - ChangeStatus: key.NewBinding( - key.WithKeys("s"), - key.WithHelp("s", "change status"), - ), - ChangePriority: key.NewBinding( - key.WithKeys("p"), - key.WithHelp("p", "change priority"), - ), - ChangeType: key.NewBinding( - key.WithKeys("t"), - key.WithHelp("t", "change type"), - ), - AddIssue: key.NewBinding( - key.WithKeys("a"), - key.WithHelp("a", "add issue"), - ), - DeleteIssue: key.NewBinding( - key.WithKeys("x"), - key.WithHelp("x", "delete issue"), - ), -} - -func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { - var cmd tea.Cmd - - switch { - case key.Matches(msg, d.keyMap.Help): - d.helpBar.ToggleHelp() - case key.Matches(msg, d.keyMap.Quit): - return tea.Quit - case key.Matches(msg, d.keyMap.SwitchWindow): - d.ToggleFocusedWindow() - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.SwitchToDashboard): - return func() tea.Msg { return msgs.SwitchToDashboardMsg{} } - case d.IsFocusedOnList() && key.Matches(msg, d.keyMap.SelectIssue): - d.FocusDetail() - case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.BackToList): - d.FocusList() - case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.ScrollUp): - d.issueDetail.ScrollUp(1) - case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.ScrollDown): - d.issueDetail.ScrollDown(1) - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.EditTitle): - if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { - d.startEditTitle(selected) - cmd = d.titleInput.Focus() - } - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.EditDescription): - if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { - d.startEditDescription(selected) - cmd = d.descriptionInput.Focus() - } - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.ChangeStatus): - if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { - d.startChooseStatus(selected) - } - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.ChangePriority): - if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { - d.startChoosePriority(selected) - } - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.ChangeType): - if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { - d.startChooseType(selected) - } - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.AddIssue): - d.startCreateIssue() - cmd = d.createTitleInput.Focus() - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.DeleteIssue): - fl := d.FocusedIssueList() - if selected := fl.SelectedItem(); selected.ID != "" { - d.startConfirmDelete(selected.ID, fl.Index()) - } - } - - return cmd -} diff --git a/pkg/tui/views/dashboard2/model.go b/pkg/tui/views/dashboard2/model.go deleted file mode 100644 index c46a29f..0000000 --- a/pkg/tui/views/dashboard2/model.go +++ /dev/null @@ -1,214 +0,0 @@ -package dashboard2 - -import ( - "context" - - "github.com/LazyBachelor/LazyPM/internal/app" - "github.com/LazyBachelor/LazyPM/internal/models" - "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" -) - -type ValidationFeedbackMsg struct { - Feedback models.ValidationFeedback -} - -type Model struct { - header Header - issueList IssueList - issueDetail IssueDetail - closedIssueList IssueList - helpBar HelpBar - keyMap DashboardKeyMap - app *app.App - width int - height int - focusedWindow int // 0 = main (display issues), 1 = closed issues - focusedPaneMain int // 0 = list, 1 = detail - focusedPaneClosed int - editingTitle bool // true while we are editing a title - titleInput textinput.Model - editingIssueID string - - editingDescription bool // true while editing a description - descriptionInput textarea.Model - editingDescIssueID string - creatingIssue bool // true while creating a new issue - createTitleInput textinput.Model - - confirmingDelete bool // true while confirming a delete - 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 - feedbackChan chan models.ValidationFeedback - quitChan chan bool - currentFeedback models.ValidationFeedback - showComplete bool -} - -func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool) *Model { - m := &Model{ - header: NewHeader("Project Manager Dashboard"), - keyMap: defaultDashboardKeyMap, - app: app, - width: 80, - height: 24, - focusedWindow: 0, - focusedPaneMain: 0, - focusedPaneClosed: 0, - feedbackChan: feedbackChan, - quitChan: quitChan, - } - - allIssues, _ := app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) - m.issueList = NewIssueListFromIssues(app, OpenAndInProgressOnly(allIssues), 0, 0) - m.issueDetail = NewIssueDetail() - m.closedIssueList = NewIssueListFromIssues(app, ClosedOnly(allIssues), 0, 0) - m.helpBar = NewHelpBar(m.keyMap) - - ti := textinput.New() - ti.Placeholder = "Issue title ..." - ti.CharLimit = 256 - m.titleInput = ti - - createTi := textinput.New() - createTi.Placeholder = "New issue title ..." - createTi.CharLimit = 256 - m.createTitleInput = createTi - - descTa := textarea.New() - descTa.Placeholder = "Issue description..." - descTa.SetWidth(56) - descTa.SetHeight(8) - m.descriptionInput = descTa - - if selected := m.issueList.SelectedItem(); selected.ID != "" { - m.issueDetail.SetIssue(selected.Issue) - } else if selected := m.closedIssueList.SelectedItem(); selected.ID != "" { - m.issueDetail.SetIssue(selected.Issue) - } - - return m -} - -func (m *Model) startEditTitle(selected ListIssue) { - m.editingTitle = true - m.editingIssueID = selected.ID - m.titleInput.SetValue(selected.Issue.Title) - m.titleInput.CursorEnd() -} - -func (m *Model) startEditDescription(selected ListIssue) { - m.editingDescription = true - m.editingDescIssueID = selected.ID - m.descriptionInput.SetValue(selected.Issue.Description) - m.descriptionInput.CursorEnd() -} - -func (m *Model) startCreateIssue() { - m.creatingIssue = true - m.createTitleInput.SetValue("") - m.createTitleInput.Reset() -} - -func (m *Model) startConfirmDelete(issueID string, index int) { - m.confirmingDelete = true - m.deleteConfirmID = issueID - m.deleteConfirmIndex = index -} - -func (m *Model) startChooseStatus(selected ListIssue) { - m.choosingStatus = true - m.statusIssueID = selected.ID -} - -func (m *Model) startChoosePriority(selected ListIssue) { - m.choosingPriority = true - m.priorityIssueID = selected.ID -} - -func (m *Model) startChooseType(selected ListIssue) { - m.choosingType = true - m.typeIssueID = selected.ID -} - -func (m *Model) Init() tea.Cmd { - return m.listenForValidation() -} - -func (m *Model) listenForValidation() tea.Cmd { - return func() tea.Msg { - feedback := <-m.feedbackChan - return ValidationFeedbackMsg{Feedback: feedback} - } -} - -func (m *Model) IsFocusedOnList() bool { - if m.focusedWindow == 0 { - return m.focusedPaneMain == 0 - } - return m.focusedPaneClosed == 0 -} - -func (m *Model) IsFocusedOnDetail() bool { - if m.focusedWindow == 0 { - return m.focusedPaneMain == 1 - } - return m.focusedPaneClosed == 1 -} - -func (m *Model) FocusList() { - if m.focusedWindow == 0 { - m.focusedPaneMain = 0 - } else { - m.focusedPaneClosed = 0 - } - m.issueDetail.SetFocused(false) -} - -func (m *Model) FocusDetail() { - if m.focusedWindow == 0 { - m.focusedPaneMain = 1 - } else { - m.focusedPaneClosed = 1 - } - m.issueDetail.SetFocused(true) -} - -func (m *Model) ToggleFocus() { - if m.IsFocusedOnList() { - m.FocusDetail() - } else { - m.FocusList() - } -} - -func (m *Model) FocusedIssueList() *IssueList { - // return the issue list of the currently focused window so we can use two tui windows for issues - if m.focusedWindow == 0 { - return &m.issueList - } - return &m.closedIssueList -} - -func (m *Model) ToggleFocusedWindow() { - // switch focus between open/in-progress and closed issues window - m.focusedWindow = 1 - m.focusedWindow - if m.focusedWindow == 0 { - if selected := m.issueList.SelectedItem(); selected.ID != "" { - m.issueDetail.SetIssue(selected.Issue) - } - } else { - if selected := m.closedIssueList.SelectedItem(); selected.ID != "" { - m.issueDetail.SetIssue(selected.Issue) - } - } - m.issueDetail.SetFocused(m.IsFocusedOnDetail()) -} diff --git a/pkg/tui/views/dashboard2/view.go b/pkg/tui/views/dashboard2/view.go deleted file mode 100644 index 8e0c485..0000000 --- a/pkg/tui/views/dashboard2/view.go +++ /dev/null @@ -1,170 +0,0 @@ -package dashboard2 - -import ( - "github.com/LazyBachelor/LazyPM/pkg/tui/styles" - "github.com/charmbracelet/lipgloss" -) - -func (m *Model) View() string { - if m.width == 0 || m.height == 0 { - // if there is no space just print a loading message - return "Loading..." - } - - m.helpBar.SetWidth(m.width) - - header := m.header.View(m.width) - headerHeight := m.header.Height() - - footer := m.footer() - footerHeight := lipgloss.Height(footer) - - // To avoid layer overflow or clipping, the label heights are calculated and subtracted from the available height before calculating the list heights to avoid layout overflow or clipping. - contentHeight := m.height - headerHeight - footerHeight - - mainLabel := styles.LabelStyle.Render("Display issues") - closedLabel := styles.LabelStyle.Render("Closed issues") - labelHeight := lipgloss.Height(mainLabel) + lipgloss.Height(closedLabel) - availableForLists := contentHeight - labelHeight - halfHeight := availableForLists / 2 - if halfHeight < 1 { - halfHeight = 1 - } - - totalContentWidth := m.width - 1 - listWidth := totalContentWidth * styles.ListViewRatio / 100 - detailWidth := totalContentWidth - listWidth - - m.issueList.SetSize(listWidth, halfHeight) - m.closedIssueList.SetSize(listWidth, halfHeight) - m.issueDetail.SetSize(detailWidth, contentHeight) - - listView := m.issueList.View() - closedListView := m.closedIssueList.View() - detailView := m.issueDetail.View() - - if m.focusedWindow == 0 { - mainLabel = lipgloss.NewStyle().Foreground(styles.Primary).Bold(true).Render("Display issues ▶") - } else { - closedLabel = lipgloss.NewStyle().Foreground(styles.Primary).Bold(true).Render("Closed issues ▶") - } - leftColumn := lipgloss.JoinVertical(lipgloss.Left, - mainLabel, listView, - closedLabel, closedListView, - ) - content := lipgloss.JoinHorizontal(lipgloss.Left, leftColumn, detailView) - - mainView := lipgloss.JoinVertical(lipgloss.Left, header, content, footer) - - if m.editingTitle { - editBoxWidth := min(60, m.width-4) - m.titleInput.Width = editBoxWidth - 2 - editContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("Edit title (Enter to save, Esc to cancel):"), - m.titleInput.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.editingDescription { - editBoxWidth := min(60, m.width-4) - m.descriptionInput.SetWidth(editBoxWidth - 2) - m.descriptionInput.SetHeight(10) - editContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("Edit description (Ctrl+S to save, Esc to cancel):"), - m.descriptionInput.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.creatingIssue { - createBoxWidth := min(60, m.width-4) - m.createTitleInput.Width = createBoxWidth - 2 - createContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("New issue (Enter to create, Esc to cancel):"), - m.createTitleInput.View(), - ) - createBox := styles.ContainerStyle. - Width(createBoxWidth). - BorderForeground(styles.PrimaryBorder). - Render(createContent) - return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, createBox) - } - - if m.confirmingDelete { - confirmContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("Delete issue "+m.deleteConfirmID+"?"), - lipgloss.NewStyle().Foreground(styles.FaintText).Render("Press y to delete, n or Esc to cancel"), - ) - confirmBoxWidth := min(50, m.width-4) - confirmBox := styles.ContainerStyle. - Width(confirmBoxWidth). - BorderForeground(styles.PrimaryBorder). - Render(confirmContent) - return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, confirmBox) - } - - 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"), - lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), - ) - statusBoxWidth := min(50, m.width-4) - statusBox := styles.ContainerStyle. - Width(statusBoxWidth). - BorderForeground(styles.PrimaryBorder). - Render(statusContent) - return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, statusBox) - } - - if m.choosingPriority { - priorityContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("Change priority for "+m.priorityIssueID+":"), - lipgloss.NewStyle().Foreground(styles.FaintText).Render("0 = irrelevant 1 = low 2 = normal 3 = high 4 = critical"), - lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), - ) - priorityBoxWidth := min(60, m.width-4) - priorityBox := styles.ContainerStyle. - Width(priorityBoxWidth). - BorderForeground(styles.PrimaryBorder). - Render(priorityContent) - return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, priorityBox) - } - - if m.choosingType { - typeContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("Change type for "+m.typeIssueID+":"), - lipgloss.NewStyle().Foreground(styles.FaintText).Render("b = bug f = feature t = task e = epic c = chore"), - lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), - ) - typeBoxWidth := min(65, m.width-4) - typeBox := styles.ContainerStyle. - Width(typeBoxWidth). - BorderForeground(styles.PrimaryBorder). - Render(typeContent) - return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, typeBox) - } - - return mainView - -} - -func (m *Model) footer() string { - feedbackStatus := m.currentFeedback.Message - - if feedbackStatus == "" { - return m.helpBar.View() - } - - m.helpBar.SetWidth(m.width - lipgloss.Width(feedbackStatus)) - return lipgloss.JoinHorizontal(lipgloss.Left, m.helpBar.View(), feedbackStatus) -} diff --git a/pkg/tui/views/kanban/keys.go b/pkg/tui/views/kanban/keys.go new file mode 100644 index 0000000..e2d2a5b --- /dev/null +++ b/pkg/tui/views/kanban/keys.go @@ -0,0 +1,110 @@ +package kanban + +import ( + "github.com/LazyBachelor/LazyPM/pkg/tui/components" + "github.com/LazyBachelor/LazyPM/pkg/tui/msgs" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" +) + +type KanbanKeyMap struct { + components.CommonKeyMap + SwitchToDashboard key.Binding + MoveColumnLeft key.Binding + MoveColumnRight key.Binding + MoveIssueLeft key.Binding + MoveIssueRight key.Binding +} + +var defaultKanbanKeyMap = KanbanKeyMap{ + CommonKeyMap: components.DefaultCommonKeyMap(), + SwitchToDashboard: key.NewBinding( + key.WithKeys("1"), + key.WithHelp("1", "dashboard 1"), + ), + MoveColumnLeft: key.NewBinding( + key.WithKeys("h"), + key.WithHelp("h", "prev column"), + ), + MoveColumnRight: key.NewBinding( + key.WithKeys("l"), + key.WithHelp("l", "next column"), + ), + MoveIssueLeft: key.NewBinding( + key.WithKeys("left", "["), + key.WithHelp("←/[", "move issue left"), + ), + MoveIssueRight: key.NewBinding( + key.WithKeys("right", "]"), + key.WithHelp("→/]", "move issue right"), + ), +} + +func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { + var cmd tea.Cmd + + switch { + case key.Matches(msg, d.keyMap.Help): + d.helpBar.ToggleHelp() + case key.Matches(msg, d.keyMap.Quit): + return tea.Quit + case !d.IsInModal() && msg.String() == " ": + return func() tea.Msg { return nil } // consume space + case !d.IsInModal() && key.Matches(msg, d.keyMap.SwitchToDashboard): + return func() tea.Msg { return msgs.SwitchToDashboardMsg{} } + case !d.IsInModal() && key.Matches(msg, d.keyMap.MoveColumnLeft): + if d.focusedColumn > 0 { + d.focusedColumn-- + d.updateDetailFromSelection() + } + case !d.IsInModal() && key.Matches(msg, d.keyMap.MoveColumnRight): + if d.focusedColumn < 2 { + d.focusedColumn++ + d.updateDetailFromSelection() + } + case !d.IsInModal() && key.Matches(msg, d.keyMap.MoveIssueRight): + cmd = d.moveIssue(+1) + case !d.IsInModal() && key.Matches(msg, d.keyMap.MoveIssueLeft): + cmd = d.moveIssue(-1) + case d.IsFocusedOnList() && key.Matches(msg, d.keyMap.SelectIssue): + d.FocusDetail() + case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.BackToList): + d.FocusList() + case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.ScrollUp): + d.issueDetail.ScrollUp(1) + case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.ScrollDown): + d.issueDetail.ScrollDown(1) + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.EditTitle): + if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { + d.startEditTitle(selected) + cmd = d.titleInput.Focus() + } + case !d.IsInModal() && key.Matches(msg, d.keyMap.EditDescription): + if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { + d.startEditDescription(selected) + cmd = d.descriptionInput.Focus() + } + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.ChangeStatus): + if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { + d.startChooseStatus(selected) + } + case !d.IsInModal() && key.Matches(msg, d.keyMap.ChangePriority): + if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { + d.startChoosePriority(selected) + } + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.ChangeType): + if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { + d.startChooseType(selected) + } + case !d.IsInModal() && key.Matches(msg, d.keyMap.AddIssue): + d.startCreateIssue() + cmd = d.createTitleInput.Focus() + case !d.IsInModal() && key.Matches(msg, d.keyMap.DeleteIssue): + fl := d.FocusedIssueList() + if selected := fl.SelectedItem(); selected.ID != "" { + d.startConfirmDelete(selected.ID, fl.Index()) + } + } + + return cmd +} diff --git a/pkg/tui/views/kanban/model.go b/pkg/tui/views/kanban/model.go new file mode 100644 index 0000000..639e432 --- /dev/null +++ b/pkg/tui/views/kanban/model.go @@ -0,0 +1,231 @@ +package kanban +import ( + "context" + + "github.com/LazyBachelor/LazyPM/internal/app" + "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/LazyBachelor/LazyPM/pkg/tui/components" + "github.com/LazyBachelor/LazyPM/pkg/tui/issues" + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type ( + Header = components.Header + IssueList = components.IssueList + IssueDetail = components.IssueDetail + ListIssue = components.ListIssue +) + +type Model struct { + header Header + todoList IssueList + inProgList IssueList + doneList IssueList + issueDetail IssueDetail + helpBar components.HelpBar + keyMap KanbanKeyMap + app *app.App + width int + height int + + focusedColumn int // 0 = To Do, 1 = In Progress, 2 = Done + focusOnDetail bool // true when detail pane is focused + + editingTitle bool // true while we are editing a title + titleInput textinput.Model + editingIssueID string + + editingDescription bool // true while editing a description + descriptionInput textarea.Model + editingDescIssueID string + creatingIssue bool // true while creating a new issue + createTitleInput textinput.Model + + confirmingDelete bool // true while confirming a delete + 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 + feedbackChan chan models.ValidationFeedback + quitChan chan bool + currentFeedback models.ValidationFeedback + showComplete bool +} + +func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool) *Model { + m := &Model{ + header: components.NewHeader("Kanban Board"), + keyMap: defaultKanbanKeyMap, + app: app, + width: 80, + height: 24, + focusedColumn: 0, + focusOnDetail: false, + feedbackChan: feedbackChan, + quitChan: quitChan, + } + + allIssues, _ := app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) + todoIssues := components.StatusOnly(allIssues, models.StatusOpen) + inProgIssues := components.StatusOnly(allIssues, models.StatusInProgress) + doneIssues := components.StatusOnly(allIssues, models.StatusClosed) + + m.todoList = components.NewIssueListFromIssues(app, todoIssues, 0, 0) + m.inProgList = components.NewIssueListFromIssues(app, inProgIssues, 0, 0) + m.doneList = components.NewIssueListFromIssues(app, doneIssues, 0, 0) + m.issueDetail = components.NewIssueDetail() + m.helpBar = components.NewHelpBar(components.ViewKanban) + + inputs := components.NewIssueInputs() + m.titleInput = inputs.Title + m.createTitleInput = inputs.CreateTitle + m.descriptionInput = inputs.Description + + if selected := m.todoList.SelectedItem(); selected.ID != "" { + m.issueDetail.SetIssue(selected.Issue) + } else if selected := m.inProgList.SelectedItem(); selected.ID != "" { + m.issueDetail.SetIssue(selected.Issue) + } else if selected := m.doneList.SelectedItem(); selected.ID != "" { + m.issueDetail.SetIssue(selected.Issue) + } + + return m +} + +func (m *Model) startEditTitle(selected ListIssue) { + m.editingTitle = true + m.editingIssueID = selected.ID + m.titleInput.SetValue(selected.Issue.Title) + m.titleInput.CursorEnd() +} + +func (m *Model) startEditDescription(selected ListIssue) { + m.editingDescription = true + m.editingDescIssueID = selected.ID + m.descriptionInput.SetValue(selected.Issue.Description) + m.descriptionInput.CursorEnd() +} + +func (m *Model) startCreateIssue() { + m.creatingIssue = true + m.createTitleInput.SetValue("") + m.createTitleInput.Reset() +} + +func (m *Model) startConfirmDelete(issueID string, index int) { + m.confirmingDelete = true + m.deleteConfirmID = issueID + m.deleteConfirmIndex = index +} + +func (m *Model) startChooseStatus(selected ListIssue) { + m.choosingStatus = true + m.statusIssueID = selected.ID +} + +func (m *Model) startChoosePriority(selected ListIssue) { + m.choosingPriority = true + m.priorityIssueID = selected.ID +} + +func (m *Model) startChooseType(selected ListIssue) { + m.choosingType = true + m.typeIssueID = selected.ID +} + +func (m *Model) Init() tea.Cmd { + return components.ListenForValidation(m.feedbackChan) +} + +// 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 +} + +func (m *Model) IsFocusedOnList() bool { + return !m.focusOnDetail +} + +func (m *Model) IsFocusedOnDetail() bool { + return m.focusOnDetail +} + +func (m *Model) FocusList() { + m.focusOnDetail = false + m.issueDetail.SetFocused(false) +} + +func (m *Model) FocusDetail() { + m.focusOnDetail = true + m.issueDetail.SetFocused(true) +} + +func (m *Model) ToggleFocus() { + if m.IsFocusedOnList() { + m.FocusDetail() + } else { + m.FocusList() + } +} + +func (m *Model) FocusedIssueList() *IssueList { + switch m.focusedColumn { + case 0: + return &m.todoList + case 1: + return &m.inProgList + case 2: + return &m.doneList + default: + return &m.todoList + } +} + +// updateDetailFromSelection updates the detail pane based on the currently +// focused column's selected issue. +func (m *Model) updateDetailFromSelection() { + selected := m.FocusedIssueList().SelectedItem() + if selected.ID != "" { + m.issueDetail.SetIssue(selected.Issue) + } +} + +// statusForColumn maps a board column index to a Status. +func statusForColumn(col int) models.Status { + switch col { + case 0: + return models.StatusOpen + case 1: + return models.StatusInProgress + case 2: + return models.StatusClosed + default: + return models.StatusOpen + } +} + +// moveIssue moves the currently selected issue in the focused column horizontally +// to an adjacent column by updating its status. +func (m *Model) moveIssue(delta int) tea.Cmd { + fl := m.FocusedIssueList() + selected := fl.SelectedItem() + if selected.ID == "" { + return nil + } + + newCol := m.focusedColumn + delta + if newCol < 0 || newCol > 2 { + return nil + } + + newStatus := statusForColumn(newCol) + return issues.UpdateIssueStatusCmd(m.app, selected.ID, string(newStatus)) +} diff --git a/pkg/tui/views/dashboard2/operations.go b/pkg/tui/views/kanban/operations.go similarity index 51% rename from pkg/tui/views/dashboard2/operations.go rename to pkg/tui/views/kanban/operations.go index a41f4d2..03a6beb 100644 --- a/pkg/tui/views/dashboard2/operations.go +++ b/pkg/tui/views/kanban/operations.go @@ -1,136 +1,61 @@ -package dashboard2 +package kanban import ( "context" - "github.com/LazyBachelor/LazyPM/internal/app" "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/LazyBachelor/LazyPM/pkg/tui/components" + "github.com/LazyBachelor/LazyPM/pkg/tui/issues" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" ) -type issueTitleUpdatedMsg struct { - IssueID string - Err error -} - -type issueDescriptionUpdatedMsg struct { - IssueID string - Err error -} - -type issueStatusUpdatedMsg struct { - IssueID string - Err error -} - -type issuePriorityUpdatedMsg struct { - IssueID string - Err error -} - -type issueTypeUpdatedMsg struct { - IssueID string - Err error -} - -type selectIssueMsg struct { - IssueID string -} - -type issueCreatedMsg struct { - Issue *models.Issue - Err error -} - -type issueDeletedMsg struct { - IssueID string - Err error - PreviousIndex int -} - -func updateIssueTitleCmd(app *app.App, issueID, newTitle string) tea.Cmd { - return func() tea.Msg { - updates := map[string]interface{}{"title": newTitle} - err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") - return issueTitleUpdatedMsg{IssueID: issueID, Err: err} - } -} - -func updateIssueDescriptionCmd(app *app.App, issueID, newDescription string) tea.Cmd { - return func() tea.Msg { - updates := map[string]interface{}{"description": newDescription} - err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") - return issueDescriptionUpdatedMsg{IssueID: issueID, Err: err} - } -} - -func updateIssueStatusCmd(app *app.App, issueID, status string) tea.Cmd { - return func() tea.Msg { - updates := map[string]interface{}{"status": status} - err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") - return issueStatusUpdatedMsg{IssueID: issueID, Err: err} - } -} - -func updateIssuePriorityCmd(app *app.App, issueID string, priority int) tea.Cmd { - return func() tea.Msg { - updates := map[string]interface{}{"priority": priority} - err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") - return issuePriorityUpdatedMsg{IssueID: issueID, Err: err} - } -} - -func updateIssueTypeCmd(app *app.App, issueID string, issueType models.IssueType) tea.Cmd { - return func() tea.Msg { - updates := map[string]interface{}{"issue_type": string(issueType)} - err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") - return issueTypeUpdatedMsg{IssueID: issueID, Err: err} - } -} - -func createIssueCmd(app *app.App, title string) tea.Cmd { - return func() tea.Msg { - issue := &models.Issue{ - Title: title, - Status: models.StatusOpen, - IssueType: models.TypeTask, - Priority: 2, - } - err := app.Issues.CreateIssue(context.Background(), issue, "tui") - return issueCreatedMsg{Issue: issue, Err: err} - } -} - -func deleteIssueCmd(app *app.App, issueID string, currentIndex int) tea.Cmd { - return func() tea.Msg { - err := app.Issues.DeleteIssue(context.Background(), issueID) - return issueDeletedMsg{IssueID: issueID, Err: err, PreviousIndex: currentIndex} - } -} - func (m *Model) refreshIssueListsAndSelectIssue(issueID string) tea.Cmd { /* update handler for issueTitleUpdatedMsg, issueDescriptionUpdatedMsg, and issueStatusUpdatedMsg to avoid using nearly identical code for refreshing the issue lists and updating the detail view Fetch all issues, update both lists, set the detail view for the given issue, and return a command to select that issue. Returns nil if fetch fails. */ - issues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) + allIssues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) if err != nil { return nil } - setItemsCmd := m.issueList.SetIssues(OpenAndInProgressOnly(issues)) - closedSetCmd := m.closedIssueList.SetIssues(ClosedOnly(issues)) - for _, issue := range issues { + + todoIssues := components.StatusOnly(allIssues, models.StatusOpen) + inProgIssues := components.StatusOnly(allIssues, models.StatusInProgress) + doneIssues := components.StatusOnly(allIssues, models.StatusClosed) + + todoCmd := m.todoList.SetIssues(todoIssues) + inProgCmd := m.inProgList.SetIssues(inProgIssues) + doneCmd := m.doneList.SetIssues(doneIssues) + + var targetStatus models.Status + for _, issue := range allIssues { if issue.ID == issueID { m.issueDetail.SetIssue(*issue) + targetStatus = issue.Status break } } - return tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { return selectIssueMsg{IssueID: issueID} }) + + switch targetStatus { + case models.StatusOpen: + m.focusedColumn = 0 + case models.StatusInProgress: + m.focusedColumn = 1 + case models.StatusClosed: + m.focusedColumn = 2 + } + + // Select the moved issue in its new column immediately so the highlight follows it. + m.todoList.SelectIssueID(issueID) + m.inProgList.SelectIssueID(issueID) + m.doneList.SelectIssueID(issueID) + + return tea.Sequence(todoCmd, inProgCmd, doneCmd) } func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case issueTitleUpdatedMsg: + case issues.TitleUpdatedMsg: m.editingTitle = false m.editingIssueID = "" m.titleInput.Blur() @@ -139,7 +64,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) - case issueDescriptionUpdatedMsg: + case issues.DescriptionUpdatedMsg: m.editingDescription = false m.editingDescIssueID = "" m.descriptionInput.Blur() @@ -148,7 +73,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) - case issueStatusUpdatedMsg: + case issues.StatusUpdatedMsg: m.choosingStatus = false m.statusIssueID = "" if msg.Err != nil { @@ -156,7 +81,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) - case issuePriorityUpdatedMsg: + case issues.PriorityUpdatedMsg: m.choosingPriority = false m.priorityIssueID = "" if msg.Err != nil { @@ -164,7 +89,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) - case issueTypeUpdatedMsg: + case issues.TypeUpdatedMsg: m.choosingType = false m.typeIssueID = "" if msg.Err != nil { @@ -172,29 +97,36 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) - case selectIssueMsg: - m.issueList.SelectIssueID(msg.IssueID) - m.closedIssueList.SelectIssueID(msg.IssueID) + case issues.SelectIssueMsg: + m.todoList.SelectIssueID(msg.IssueID) + m.inProgList.SelectIssueID(msg.IssueID) + m.doneList.SelectIssueID(msg.IssueID) return m, nil - case issueCreatedMsg: + case issues.CreatedMsg: m.creatingIssue = false m.createTitleInput.Blur() m.createTitleInput.Reset() if msg.Err != nil || msg.Issue == nil { return m, nil } - issues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) + allIssues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) if err != nil { return m, nil } - setItemsCmd := m.issueList.SetIssues(OpenAndInProgressOnly(issues)) - closedSetCmd := m.closedIssueList.SetIssues(ClosedOnly(issues)) + + todoIssues := components.StatusOnly(allIssues, models.StatusOpen) + inProgIssues := components.StatusOnly(allIssues, models.StatusInProgress) + doneIssues := components.StatusOnly(allIssues, models.StatusClosed) + + todoCmd := m.todoList.SetIssues(todoIssues) + inProgCmd := m.inProgList.SetIssues(inProgIssues) + doneCmd := m.doneList.SetIssues(doneIssues) // Determine the created issue from the refreshed list to ensure all fields (like ID) are populated. selectedIssue := msg.Issue if selectedIssue.ID == "" { - for _, issue := range issues { + for _, issue := range allIssues { // Prefer an issue that matches the created issue's title when ID is not yet known. if issue.Title == msg.Issue.Title { selectedIssue = issue @@ -204,49 +136,74 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.issueDetail.SetIssue(*selectedIssue) - return m, tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { return selectIssueMsg{IssueID: selectedIssue.ID} }) - case issueDeletedMsg: + return m, tea.Sequence(todoCmd, inProgCmd, doneCmd, func() tea.Msg { return issues.SelectIssueMsg{IssueID: selectedIssue.ID} }) + case issues.DeletedMsg: m.confirmingDelete = false m.deleteConfirmID = "" if msg.Err != nil { return m, nil } - issues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) + allIssues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) if err != nil { return m, nil } - openIssues := OpenAndInProgressOnly(issues) - closedIssues := ClosedOnly(issues) - setItemsCmd := m.issueList.SetIssues(openIssues) - closedSetCmd := m.closedIssueList.SetIssues(closedIssues) + + todoIssues := components.StatusOnly(allIssues, models.StatusOpen) + inProgIssues := components.StatusOnly(allIssues, models.StatusInProgress) + doneIssues := components.StatusOnly(allIssues, models.StatusClosed) + + todoCmd := m.todoList.SetIssues(todoIssues) + inProgCmd := m.inProgList.SetIssues(inProgIssues) + doneCmd := m.doneList.SetIssues(doneIssues) + // If there are no issues at all, clear the detail view and return. - if len(openIssues) == 0 && len(closedIssues) == 0 { + if len(todoIssues) == 0 && len(inProgIssues) == 0 && len(doneIssues) == 0 { m.issueDetail.SetIssue(models.Issue{}) - return m, tea.Sequence(setItemsCmd, closedSetCmd) + return m, tea.Sequence(todoCmd, inProgCmd, doneCmd) } - // Determine which list to use for the next selection. + // Determine which column to use for the next selection based on the current focus. var targetIssues []*models.Issue - if m.focusedWindow == 0 { - targetIssues = openIssues - if len(targetIssues) == 0 && len(closedIssues) > 0 { - // The open list became empty; fall back to closed issues. - targetIssues = closedIssues - m.focusedWindow = 1 + switch m.focusedColumn { + case 0: + targetIssues = todoIssues + if len(targetIssues) == 0 { + if len(inProgIssues) > 0 { + targetIssues = inProgIssues + m.focusedColumn = 1 + } else if len(doneIssues) > 0 { + targetIssues = doneIssues + m.focusedColumn = 2 + } } - } else { - targetIssues = closedIssues - if len(targetIssues) == 0 && len(openIssues) > 0 { - // The closed list became empty; fall back to open/in-progress issues. - targetIssues = openIssues - m.focusedWindow = 0 + case 1: + targetIssues = inProgIssues + if len(targetIssues) == 0 { + if len(todoIssues) > 0 { + targetIssues = todoIssues + m.focusedColumn = 0 + } else if len(doneIssues) > 0 { + targetIssues = doneIssues + m.focusedColumn = 2 + } + } + case 2: + targetIssues = doneIssues + if len(targetIssues) == 0 { + if len(inProgIssues) > 0 { + targetIssues = inProgIssues + m.focusedColumn = 1 + } else if len(todoIssues) > 0 { + targetIssues = todoIssues + m.focusedColumn = 0 + } } } // 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(setItemsCmd, closedSetCmd) + return m, tea.Sequence(todoCmd, inProgCmd, doneCmd) } newIndex := msg.PreviousIndex @@ -255,8 +212,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } selectedIssue := targetIssues[newIndex] m.issueDetail.SetIssue(*selectedIssue) - return m, tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { - return selectIssueMsg{IssueID: selectedIssue.ID} + return m, tea.Sequence(todoCmd, inProgCmd, doneCmd, func() tea.Msg { + return issues.SelectIssueMsg{IssueID: selectedIssue.ID} }) case tea.KeyMsg: @@ -267,7 +224,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { idx := m.deleteConfirmIndex m.confirmingDelete = false m.deleteConfirmID = "" - return m, deleteIssueCmd(m.app, issueID, idx) + return m, issues.DeleteIssueCmd(m.app, issueID, idx) case "n", "N", "esc": m.confirmingDelete = false m.deleteConfirmID = "" @@ -281,17 +238,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { issueID := m.statusIssueID m.choosingStatus = false m.statusIssueID = "" - return m, updateIssueStatusCmd(m.app, issueID, string(models.StatusOpen)) + return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusOpen)) case "i": issueID := m.statusIssueID m.choosingStatus = false m.statusIssueID = "" - return m, updateIssueStatusCmd(m.app, issueID, string(models.StatusInProgress)) + return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusInProgress)) case "c": issueID := m.statusIssueID m.choosingStatus = false m.statusIssueID = "" - return m, updateIssueStatusCmd(m.app, issueID, string(models.StatusClosed)) + return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusClosed)) case "esc": m.choosingStatus = false m.statusIssueID = "" @@ -306,7 +263,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { priority := int(msg.String()[0] - '0') m.choosingPriority = false m.priorityIssueID = "" - return m, updateIssuePriorityCmd(m.app, issueID, priority) + return m, issues.UpdateIssuePriorityCmd(m.app, issueID, priority) case "esc": m.choosingPriority = false m.priorityIssueID = "" @@ -322,27 +279,27 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { issueID := m.typeIssueID m.choosingType = false m.typeIssueID = "" - return m, updateIssueTypeCmd(m.app, issueID, models.TypeBug) + return m, issues.UpdateIssueTypeCmd(m.app, issueID, models.TypeBug) case "f": issueID := m.typeIssueID m.choosingType = false m.typeIssueID = "" - return m, updateIssueTypeCmd(m.app, issueID, models.TypeFeature) + return m, issues.UpdateIssueTypeCmd(m.app, issueID, models.TypeFeature) case "t": issueID := m.typeIssueID m.choosingType = false m.typeIssueID = "" - return m, updateIssueTypeCmd(m.app, issueID, models.TypeTask) + return m, issues.UpdateIssueTypeCmd(m.app, issueID, models.TypeTask) case "e": issueID := m.typeIssueID m.choosingType = false m.typeIssueID = "" - return m, updateIssueTypeCmd(m.app, issueID, models.TypeEpic) + return m, issues.UpdateIssueTypeCmd(m.app, issueID, models.TypeEpic) case "c": issueID := m.typeIssueID m.choosingType = false m.typeIssueID = "" - return m, updateIssueTypeCmd(m.app, issueID, models.TypeChore) + return m, issues.UpdateIssueTypeCmd(m.app, issueID, models.TypeChore) case "esc": m.choosingType = false m.typeIssueID = "" @@ -356,7 +313,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.String() == "enter" { title := m.createTitleInput.Value() if title != "" { - return m, createIssueCmd(m.app, title) + return m, issues.CreateIssueCmd(m.app, title) } } if msg.String() == "esc" { @@ -374,7 +331,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.String() == "enter" { newTitle := m.titleInput.Value() if newTitle != "" { - return m, updateIssueTitleCmd(m.app, m.editingIssueID, newTitle) + return m, issues.UpdateIssueTitleCmd(m.app, m.editingIssueID, newTitle) } } if msg.String() == "esc" { @@ -395,7 +352,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.editingDescription = false m.editingDescIssueID = "" m.descriptionInput.Blur() - return m, updateIssueDescriptionCmd(m.app, issueID, newDesc) + return m, issues.UpdateIssueDescriptionCmd(m.app, issueID, newDesc) } if msg.String() == "esc" { m.editingDescription = false @@ -423,13 +380,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd != nil { return m, cmd } - case ValidationFeedbackMsg: + case components.ValidationFeedbackMsg: m.currentFeedback = msg.Feedback if msg.Feedback.Success { m.showComplete = true return m, tea.Quit } - return m, m.listenForValidation() + return m, components.ListenForValidation(m.feedbackChan) case tea.WindowSizeMsg: m.width = msg.Width @@ -437,18 +394,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - if m.focusedWindow == 0 { - cmd, changed := m.issueList.Update(msg) - if changed { - if selected := m.issueList.SelectedItem(); selected.ID != "" { - m.issueDetail.SetIssue(selected.Issue) - } - } - return m, cmd - } - cmd, changed := m.closedIssueList.Update(msg) + fl := m.FocusedIssueList() + cmd, changed := fl.Update(msg) if changed { - if selected := m.closedIssueList.SelectedItem(); selected.ID != "" { + if selected := fl.SelectedItem(); selected.ID != "" { m.issueDetail.SetIssue(selected.Issue) } } diff --git a/pkg/tui/views/kanban/view.go b/pkg/tui/views/kanban/view.go new file mode 100644 index 0000000..045541b --- /dev/null +++ b/pkg/tui/views/kanban/view.go @@ -0,0 +1,96 @@ +package kanban + +import ( + "github.com/LazyBachelor/LazyPM/pkg/tui/components" + "github.com/LazyBachelor/LazyPM/pkg/tui/styles" + "github.com/charmbracelet/lipgloss" +) + +func (m *Model) View() string { + if m.width == 0 || m.height == 0 { + // if there is no space just print a loading message + return "Loading..." + } + + m.helpBar.SetWidth(m.width) + + header := m.header.View(m.width) + headerHeight := m.header.Height() + + footer := components.RenderFooter(m.width, &m.helpBar, m.currentFeedback) + footerHeight := lipgloss.Height(footer) + + contentHeight := m.height - headerHeight - footerHeight + totalContentWidth := m.width - 1 + colWidth := totalContentWidth / 3 + if colWidth < 20 { + colWidth = 20 + } + + // Leave some space for the detail view below the board. + boardHeight := contentHeight / 2 + if boardHeight < 5 { + boardHeight = contentHeight + } + + m.todoList.SetSize(colWidth, boardHeight-1) + m.inProgList.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) + + // 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") + doneLabel := styles.LabelStyle.Render("Done") + + highlight := lipgloss.NewStyle().Foreground(styles.Primary).Bold(true) + switch m.focusedColumn { + case 0: + todoLabel = highlight.Render("To Do ▶") + case 1: + inProgLabel = highlight.Render("In Progress ▶") + case 2: + doneLabel = highlight.Render("Done ▶") + } + + todoCol := lipgloss.JoinVertical(lipgloss.Left, todoLabel, m.todoList.View()) + inProgCol := lipgloss.JoinVertical(lipgloss.Left, inProgLabel, m.inProgList.View()) + doneCol := lipgloss.JoinVertical(lipgloss.Left, doneLabel, m.doneList.View()) + + board := lipgloss.JoinHorizontal(lipgloss.Left, todoCol, inProgCol, doneCol) + content := lipgloss.JoinVertical(lipgloss.Left, board, m.issueDetail.View()) + + mainView := lipgloss.JoinVertical(lipgloss.Left, header, content, footer) + + return components.RenderModals( + m.width, + m.height, + m.editingTitle, + m.titleInput.View(), + m.editingDescription, + m.descriptionInput.View(), + m.creatingIssue, + m.createTitleInput.View(), + m.confirmingDelete, + m.deleteConfirmID, + m.choosingStatus, + m.statusIssueID, + m.choosingPriority, + m.priorityIssueID, + m.choosingType, + m.typeIssueID, + mainView, + ) + +} + +func (m *Model) footer() string { + // Kept for backwards compatibility; delegate to the shared helper. + return components.RenderFooter(m.width, &m.helpBar, m.currentFeedback) +} diff --git a/pkg/tui/views/root.go b/pkg/tui/views/root.go index b05b78c..cbee006 100644 --- a/pkg/tui/views/root.go +++ b/pkg/tui/views/root.go @@ -5,7 +5,7 @@ import ( "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/pkg/tui/msgs" "github.com/LazyBachelor/LazyPM/pkg/tui/views/dashboard" - "github.com/LazyBachelor/LazyPM/pkg/tui/views/dashboard2" + "github.com/LazyBachelor/LazyPM/pkg/tui/views/kanban" tea "github.com/charmbracelet/bubbletea" ) @@ -57,14 +57,14 @@ func (r *RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } cmds = append(cmds, tea.ClearScreen, r.currentView.Init()) return r, tea.Batch(cmds...) - case msgs.SwitchToDashboard2Msg: - // switch to dashboard 2 and apply the last known size. - r.currentView = dashboard2.NewDashboard(r.app, r.feedbackChan, r.quitChan) + case msgs.SwitchToKanbanBoardMsg: + // switch to kanban board and apply the last known size. + r.currentView = kanban.NewDashboard(r.app, r.feedbackChan, r.quitChan) var cmds []tea.Cmd if r.hasSize { // check if there is a size, and then update it var sizeCmd tea.Cmd - // set size of dashboard2 to the size before switching + // set size of kanban board to the size before switching r.currentView, sizeCmd = r.currentView.Update(r.lastSize) if sizeCmd != nil { cmds = append(cmds, sizeCmd) diff --git a/tui.exe b/tui.exe new file mode 100644 index 0000000..ebdd93f Binary files /dev/null and b/tui.exe differ