diff --git a/.gitignore b/.gitignore index 5f4ecc7..c7d63c5 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ package.json # Added by goreleaser init: dist/ + +# Dolt database files (added by bd init) +.dolt/ 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..28cb441 --- /dev/null +++ b/pkg/tui/components/helpbar.go @@ -0,0 +1,179 @@ +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: "v", 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: "S", Desc: "submit"}, + {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: "v", LeftDesc: "kanban", RightKey: "q", RightDesc: "quit"}, + {LeftKey: "S", LeftDesc: "submit", RightKey: "?", RightDesc: "help"}, + }, + } + case ViewKanban: + return HelpBarConfig{ + ShortItems: []ShortItem{ + {Key: "v", Desc: "list view"}, + {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: "S", Desc: "submit"}, + {Key: "?", Desc: "help"}, + }, + FullRows: []FullRow{ + {LeftKey: "v", LeftDesc: "list view", 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: "S", RightDesc: "submit"}, + {LeftKey: "?", LeftDesc: "help", RightKey: "", RightDesc: ""}, + }, + } + default: + return HelpBarConfig{} + } +} diff --git a/pkg/tui/views/dashboard/issue_detail.go b/pkg/tui/components/issue_detail.go similarity index 98% rename from pkg/tui/views/dashboard/issue_detail.go rename to pkg/tui/components/issue_detail.go index 2899bca..d01bde7 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 ( "time" @@ -23,6 +23,7 @@ func NewIssueDetail() IssueDetail { } } + func (i *IssueDetail) SetIssue(issue models.Issue) { i.issue = issue i.refreshContent() @@ -34,6 +35,7 @@ func (i *IssueDetail) SetComments(comments []*models.Comment) { i.refreshContent() } + func (i *IssueDetail) SetSize(width, height int) { i.viewport.Height = height i.viewport.Width = width @@ -45,7 +47,6 @@ func (i *IssueDetail) SetFocused(focused bool) { } func (i *IssueDetail) refreshContent() { - titleRow := styles.RowStyle.Render( styles.TitleStyle.Render(i.issue.Title), ) @@ -63,7 +64,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/dashboard/issue_list.go b/pkg/tui/components/issue_list.go similarity index 66% rename from pkg/tui/views/dashboard/issue_list.go rename to pkg/tui/components/issue_list.go index c2eb4cb..1667e34 100644 --- a/pkg/tui/views/dashboard/issue_list.go +++ b/pkg/tui/components/issue_list.go @@ -1,4 +1,4 @@ -package dashboard +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..b41e3bb --- /dev/null +++ b/pkg/tui/components/modals.go @@ -0,0 +1,237 @@ +package components + +import ( + "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/LazyBachelor/LazyPM/pkg/tui/styles" + "github.com/charmbracelet/lipgloss" +) + +// modalBoxWidth returns a clamped width for modal content. Never returns a value < 1. +func modalBoxWidth(maxWidth, width int) int { + if width < 5 { + return 1 + } + w := min(maxWidth, width-4) + if w < 1 { + return 1 + } + return w +} + +// components contains reusable TUI modal renderers for issue actions. + +func RenderEditTitle(width, height int, inputView string) string { + if width < 5 || height < 5 { + return "" + } + editBoxWidth := modalBoxWidth(60, width) + 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 { + if width < 5 || height < 5 { + return "" + } + editBoxWidth := modalBoxWidth(60, width) + 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 { + if width < 5 || height < 5 { + return "" + } + createBoxWidth := modalBoxWidth(60, width) + 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 { + if width < 5 || height < 5 { + return "" + } + 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 := modalBoxWidth(50, width) + 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 { + if width < 5 || height < 5 { + return "" + } + 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 := modalBoxWidth(50, width) + 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 { + if width < 5 || height < 5 { + return "" + } + 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 := modalBoxWidth(60, width) + 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 { + if width < 5 || height < 5 { + return "" + } + 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 := modalBoxWidth(65, width) + 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. + +// truncateToWidth trims the given text so that its rendered width does not +// exceed maxWidth. If truncation occurs and there is room, an ellipsis is +// appended to indicate that the text was shortened. +func truncateToWidth(text string, maxWidth int) string { + if maxWidth <= 0 { + return "" + } + + if lipgloss.Width(text) <= maxWidth { + return text + } + + const ellipsis = "…" + ellipsisWidth := lipgloss.Width(ellipsis) + if ellipsisWidth > maxWidth { + // Not enough space even for an ellipsis; return empty. + return "" + } + + runes := []rune(text) + current := "" + for _, r := range runes { + next := current + string(r) + if lipgloss.Width(next)+ellipsisWidth > maxWidth { + break + } + current = next + } + + return current + ellipsis +} + +func RenderFooter(width int, helpBar *HelpBar, feedback models.ValidationFeedback) string { + feedbackStatus := feedback.Message + + // Ensure the feedback message does not exceed the total available width. + feedbackStatus = truncateToWidth(feedbackStatus, width) + + if feedbackStatus == "" { + return helpBar.View() + } + + helpWidth := width - lipgloss.Width(feedbackStatus) + if helpWidth < 0 { + helpWidth = 0 + } + + helpBar.SetWidth(helpWidth) + 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 new file mode 100644 index 0000000..047787f --- /dev/null +++ b/pkg/tui/msgs/msgs.go @@ -0,0 +1,7 @@ +package msgs + +// SwitchToDashboardMsg signals to switch to the main dashboard view. +type SwitchToDashboardMsg struct{} + +// SwitchToKanbanBoardMsg signals to switch to the kanban board view. +type SwitchToKanbanBoardMsg struct{} diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 92c3a87..f7df2e8 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -30,7 +30,7 @@ func (t *Tui) Run(ctx context.Context, config Config) error { defer cleanup() - p := tea.NewProgram(views.NewDashboardView(app, t.feedbackChan, t.quitChan, t.submitChan), + p := tea.NewProgram(views.NewRootView(app, t.feedbackChan, t.quitChan, t.submitChan), tea.WithAltScreen(), tea.WithMouseAllMotion()) if t.quitChan != nil { diff --git a/pkg/tui/views/dashboard/help_bar.go b/pkg/tui/views/dashboard/help_bar.go deleted file mode 100644 index 39a1949..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("↑/k") + "up ", - styles.HighlightKey("↓/j") + "down ", - styles.HighlightKey("pgup/pgdn") + "page ", - styles.HighlightKey("a") + "add ", - styles.HighlightKey("e/d/s/p/t/c") + "edit ", - styles.HighlightKey("x") + "delete ", - styles.HighlightKey("q") + "quit ", - styles.HighlightKey("S") + "submit ", - 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 desc"), - renderRow("c", "add comment", "", ""), - renderRow("s", "change status", "p", "change priority"), - renderRow("t", "change type", "x", "delete issue"), - renderRow("S", "submit task", "q", "quit"), - } - 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/keys.go b/pkg/tui/views/dashboard/keys.go index dd0981b..b2d830f 100644 --- a/pkg/tui/views/dashboard/keys.go +++ b/pkg/tui/views/dashboard/keys.go @@ -1,58 +1,41 @@ 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 - EditTitle key.Binding - EditDescription key.Binding - ChangeStatus key.Binding - ChangePriority key.Binding - ChangeType key.Binding - AddComment key.Binding - AddIssue key.Binding - DeleteIssue key.Binding - SubmitValidation key.Binding + components.CommonKeyMap + SwitchWindow key.Binding + SwitchToKanbanBoard 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 + AddComment key.Binding + AddIssue key.Binding + DeleteIssue key.Binding + SubmitValidation 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"), ), + SwitchToKanbanBoard: key.NewBinding( + key.WithKeys("v"), + key.WithHelp("v", "switch to kanban")), EditTitle: key.NewBinding( key.WithKeys("e"), key.WithHelp("e", "edit title"), @@ -103,11 +86,12 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { return tea.Quit case key.Matches(msg, d.keyMap.SwitchWindow): d.ToggleFocusedWindow() - d.logAction("tui switched issue window") + 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() d.logAction("tui opened issue detail") - case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.BackToList): + case d.IsFocusedOnDetail() && (key.Matches(msg, d.keyMap.BackToList) || key.Matches(msg, d.keyMap.SelectIssue)): d.FocusList() d.logAction("tui returned to issue list") case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.ScrollUp): @@ -116,34 +100,34 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.ScrollDown): d.issueDetail.ScrollDown(1) d.logAction("tui scrolled issue detail down") - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.EditTitle): + case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.EditTitle): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startEditTitle(selected) cmd = d.titleInput.Focus() d.logAction("tui started editing issue title") } - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.EditDescription): + case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.EditDescription): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startEditDescription(selected) cmd = d.descriptionInput.Focus() d.logAction("tui started editing issue description") } - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.ChangeStatus): + case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.ChangeStatus): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startChooseStatus(selected) d.logAction("tui opened status picker") } - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.ChangePriority): + case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.ChangePriority): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startChoosePriority(selected) d.logAction("tui opened priority picker") } - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.ChangeType): + case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.ChangeType): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startChooseType(selected) d.logAction("tui opened type picker") } - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.AddComment): + case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.AddComment): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startAddComment(selected) cmd = d.commentInput.Focus() @@ -152,7 +136,7 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { d.startCreateIssue() cmd = d.createTitleInput.Focus() d.logAction("tui started creating issue") - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.DeleteIssue): + case !d.IsInModal() && !d.addingComment && 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 de1ce4b..84962d5 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 @@ -59,7 +64,7 @@ type Model struct { func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool, submitChan chan<- struct{}) *Model { m := &Model{ - header: NewHeader("Project Manager Dashboard"), + header: components.NewHeader("Project Manager Dashboard"), keyMap: defaultDashboardKeyMap, app: app, width: 80, @@ -73,26 +78,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 commentTa := textarea.New() commentTa.Placeholder = "Write your comment..." @@ -178,14 +172,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 566692e..305f24f 100644 --- a/pkg/tui/views/dashboard/operations.go +++ b/pkg/tui/views/dashboard/operations.go @@ -7,6 +7,8 @@ import ( "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" ) @@ -140,24 +142,24 @@ 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.setDetailIssueWithComments(*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() @@ -168,7 +170,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.logAction("tui updated issue title") return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) - case issueDescriptionUpdatedMsg: + case issues.DescriptionUpdatedMsg: m.editingDescription = false m.editingDescIssueID = "" m.descriptionInput.Blur() @@ -179,7 +181,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.logAction("tui updated issue description") return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) - case issueStatusUpdatedMsg: + case issues.StatusUpdatedMsg: m.choosingStatus = false m.statusIssueID = "" if msg.Err != nil { @@ -189,7 +191,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.logAction("tui updated issue status") return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) - case issuePriorityUpdatedMsg: + case issues.PriorityUpdatedMsg: m.choosingPriority = false m.priorityIssueID = "" if msg.Err != nil { @@ -199,7 +201,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.logAction("tui updated issue priority") return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) - case issueTypeUpdatedMsg: + case issues.TypeUpdatedMsg: m.choosingType = false m.typeIssueID = "" if msg.Err != nil { @@ -209,12 +211,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.logAction("tui updated issue type") 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() @@ -222,17 +224,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.logAction("tui failed to create issue") 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 @@ -243,7 +245,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.setDetailIssueWithComments(*selectedIssue) m.logAction("tui created issue") - return m, tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { return selectIssueMsg{IssueID: selectedIssue.ID} }) + return m, tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { return issues.SelectIssueMsg{IssueID: selectedIssue.ID} }) case issueCommentAddedMsg: m.addingComment = false m.commentIssueID = "" @@ -253,7 +255,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) - case issueDeletedMsg: + case issues.DeletedMsg: m.confirmingDelete = false m.deleteConfirmID = "" if msg.Err != nil { @@ -261,12 +263,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.logAction("tui deleted issue") - 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. @@ -306,7 +308,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { selectedIssue := targetIssues[newIndex] m.setDetailIssueWithComments(*selectedIssue) return m, tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { - return selectIssueMsg{IssueID: selectedIssue.ID} + return issues.SelectIssueMsg{IssueID: selectedIssue.ID} }) case tea.KeyMsg: @@ -318,7 +320,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.logAction("tui canceled issue deletion") m.confirmingDelete = false @@ -334,19 +336,19 @@ 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": m.logAction("tui selected issue status in_progress") 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": m.logAction("tui selected issue status closed") 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.logAction("tui canceled status picker") m.choosingStatus = false @@ -363,7 +365,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.logAction("tui canceled priority picker") m.choosingPriority = false @@ -381,31 +383,31 @@ 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": m.logAction("tui selected issue type feature") 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": m.logAction("tui selected issue type task") 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": m.logAction("tui selected issue type epic") 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": m.logAction("tui selected issue type chore") 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.logAction("tui canceled type picker") m.choosingType = false @@ -421,7 +423,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { title := m.createTitleInput.Value() if title != "" { m.logAction("tui submitted new issue") - return m, createIssueCmd(m.app, title) + return m, issues.CreateIssueCmd(m.app, title) } } if msg.String() == "esc" { @@ -441,7 +443,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { newTitle := m.titleInput.Value() if newTitle != "" { m.logAction("tui submitted issue title edit") - return m, updateIssueTitleCmd(m.app, m.editingIssueID, newTitle) + return m, issues.UpdateIssueTitleCmd(m.app, m.editingIssueID, newTitle) } } if msg.String() == "esc" { @@ -488,7 +490,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.logAction("tui canceled issue description edit") @@ -517,13 +519,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 74aca51..b395f51 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() @@ -172,14 +177,3 @@ func (m *Model) View() string { 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..bcaa08c --- /dev/null +++ b/pkg/tui/views/kanban/keys.go @@ -0,0 +1,122 @@ +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 + SubmitValidation key.Binding +} + +var defaultKanbanKeyMap = KanbanKeyMap{ + CommonKeyMap: components.DefaultCommonKeyMap(), + SubmitValidation: key.NewBinding( + key.WithKeys("S"), + key.WithHelp("S", "submit validation"), + ), + SwitchToDashboard: key.NewBinding( + key.WithKeys("v"), + key.WithHelp("v", "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.SubmitValidation): + if d.submitChan != nil { + select { + case d.submitChan <- struct{}{}: + default: + } + } + 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) || key.Matches(msg, d.keyMap.SelectIssue)): + 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..6ead0ec --- /dev/null +++ b/pkg/tui/views/kanban/model.go @@ -0,0 +1,233 @@ +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 + submitChan chan<- struct{} + currentFeedback models.ValidationFeedback + showComplete bool +} + +func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool, submitChan chan<- struct{}) *Model { + m := &Model{ + header: components.NewHeader("Kanban Board"), + keyMap: defaultKanbanKeyMap, + app: app, + width: 80, + height: 24, + focusedColumn: 0, + focusOnDetail: false, + feedbackChan: feedbackChan, + quitChan: quitChan, + submitChan: submitChan, + } + + 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/kanban/operations.go b/pkg/tui/views/kanban/operations.go new file mode 100644 index 0000000..03a6beb --- /dev/null +++ b/pkg/tui/views/kanban/operations.go @@ -0,0 +1,405 @@ +package kanban + +import ( + "context" + + "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" +) + +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. + */ + allIssues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) + if err != nil { + return nil + } + + 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 + } + } + + 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 issues.TitleUpdatedMsg: + m.editingTitle = false + m.editingIssueID = "" + m.titleInput.Blur() + if msg.Err != nil { + return m, nil + } + return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + + case issues.DescriptionUpdatedMsg: + m.editingDescription = false + m.editingDescIssueID = "" + m.descriptionInput.Blur() + if msg.Err != nil { + return m, nil + } + return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + + case issues.StatusUpdatedMsg: + m.choosingStatus = false + m.statusIssueID = "" + if msg.Err != nil { + return m, nil + } + return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + + case issues.PriorityUpdatedMsg: + m.choosingPriority = false + m.priorityIssueID = "" + if msg.Err != nil { + return m, nil + } + return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + + case issues.TypeUpdatedMsg: + m.choosingType = false + m.typeIssueID = "" + if msg.Err != nil { + return m, nil + } + return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + + case issues.SelectIssueMsg: + m.todoList.SelectIssueID(msg.IssueID) + m.inProgList.SelectIssueID(msg.IssueID) + m.doneList.SelectIssueID(msg.IssueID) + return m, nil + + case issues.CreatedMsg: + m.creatingIssue = false + m.createTitleInput.Blur() + m.createTitleInput.Reset() + if msg.Err != nil || msg.Issue == nil { + return m, nil + } + allIssues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) + if err != nil { + return m, nil + } + + todoIssues := components.StatusOnly(allIssues, models.StatusOpen) + inProgIssues := components.StatusOnly(allIssues, models.StatusInProgress) + 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 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 + break + } + } + } + + m.issueDetail.SetIssue(*selectedIssue) + 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 + } + allIssues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) + if err != nil { + return m, nil + } + + todoIssues := components.StatusOnly(allIssues, models.StatusOpen) + inProgIssues := components.StatusOnly(allIssues, models.StatusInProgress) + 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(todoIssues) == 0 && len(inProgIssues) == 0 && len(doneIssues) == 0 { + m.issueDetail.SetIssue(models.Issue{}) + return m, tea.Sequence(todoCmd, inProgCmd, doneCmd) + } + + // Determine which column to use for the next selection based on the current focus. + var targetIssues []*models.Issue + 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 + } + } + 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(todoCmd, inProgCmd, doneCmd) + } + + newIndex := msg.PreviousIndex + if newIndex >= len(targetIssues) { + newIndex = len(targetIssues) - 1 + } + selectedIssue := targetIssues[newIndex] + m.issueDetail.SetIssue(*selectedIssue) + return m, tea.Sequence(todoCmd, inProgCmd, doneCmd, func() tea.Msg { + return issues.SelectIssueMsg{IssueID: selectedIssue.ID} + }) + + case tea.KeyMsg: + if m.confirmingDelete { + switch msg.String() { + case "y", "Y": + issueID := m.deleteConfirmID + idx := m.deleteConfirmIndex + m.confirmingDelete = false + m.deleteConfirmID = "" + return m, issues.DeleteIssueCmd(m.app, issueID, idx) + case "n", "N", "esc": + m.confirmingDelete = false + m.deleteConfirmID = "" + return m, nil + } + } + + if m.choosingStatus { + switch msg.String() { + case "o": + issueID := m.statusIssueID + m.choosingStatus = false + m.statusIssueID = "" + return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusOpen)) + case "i": + issueID := m.statusIssueID + m.choosingStatus = false + m.statusIssueID = "" + return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusInProgress)) + case "c": + issueID := m.statusIssueID + m.choosingStatus = false + m.statusIssueID = "" + return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusClosed)) + case "esc": + m.choosingStatus = false + m.statusIssueID = "" + return m, nil + } + } + + if m.choosingPriority { + switch msg.String() { + case "0", "1", "2", "3", "4": + issueID := m.priorityIssueID + priority := int(msg.String()[0] - '0') + m.choosingPriority = false + m.priorityIssueID = "" + return m, issues.UpdateIssuePriorityCmd(m.app, issueID, priority) + case "esc": + m.choosingPriority = false + m.priorityIssueID = "" + return m, nil + default: + return m, nil + } + } + + if m.choosingType { + switch msg.String() { + case "b": + issueID := m.typeIssueID + m.choosingType = false + m.typeIssueID = "" + return m, issues.UpdateIssueTypeCmd(m.app, issueID, models.TypeBug) + case "f": + issueID := m.typeIssueID + m.choosingType = false + m.typeIssueID = "" + return m, issues.UpdateIssueTypeCmd(m.app, issueID, models.TypeFeature) + case "t": + issueID := m.typeIssueID + m.choosingType = false + m.typeIssueID = "" + return m, issues.UpdateIssueTypeCmd(m.app, issueID, models.TypeTask) + case "e": + issueID := m.typeIssueID + m.choosingType = false + m.typeIssueID = "" + return m, issues.UpdateIssueTypeCmd(m.app, issueID, models.TypeEpic) + case "c": + issueID := m.typeIssueID + m.choosingType = false + m.typeIssueID = "" + return m, issues.UpdateIssueTypeCmd(m.app, issueID, models.TypeChore) + case "esc": + m.choosingType = false + m.typeIssueID = "" + return m, nil + default: + return m, nil + } + } + + if m.creatingIssue { + if msg.String() == "enter" { + title := m.createTitleInput.Value() + if title != "" { + return m, issues.CreateIssueCmd(m.app, title) + } + } + if msg.String() == "esc" { + m.creatingIssue = false + m.createTitleInput.Blur() + m.createTitleInput.Reset() + return m, nil + } + var cmd tea.Cmd + m.createTitleInput, cmd = m.createTitleInput.Update(msg) + return m, cmd + } + + if m.editingTitle { + if msg.String() == "enter" { + newTitle := m.titleInput.Value() + if newTitle != "" { + return m, issues.UpdateIssueTitleCmd(m.app, m.editingIssueID, newTitle) + } + } + if msg.String() == "esc" { + m.editingTitle = false + m.editingIssueID = "" + m.titleInput.Blur() + return m, nil + } + var cmd tea.Cmd + m.titleInput, cmd = m.titleInput.Update(msg) + return m, cmd + } + + if m.editingDescription { + if msg.String() == "ctrl+s" { + issueID := m.editingDescIssueID + newDesc := m.descriptionInput.Value() + m.editingDescription = false + m.editingDescIssueID = "" + m.descriptionInput.Blur() + return m, issues.UpdateIssueDescriptionCmd(m.app, issueID, newDesc) + } + if msg.String() == "esc" { + m.editingDescription = false + m.editingDescIssueID = "" + m.descriptionInput.Blur() + return m, nil + } + var cmd tea.Cmd + m.descriptionInput, cmd = m.descriptionInput.Update(msg) + return m, cmd + } + + focusedList := m.FocusedIssueList() + if focusedList.FilterState() == list.Filtering { + cmd, _ := focusedList.Update(msg) + return m, cmd + } + + // On main dashboard, ESC does nothing; only q quits; like in lazybeads. + if msg.String() == "esc" { + return m, nil + } + + cmd := m.handleKeyMsg(msg) + if cmd != nil { + return m, cmd + } + case components.ValidationFeedbackMsg: + m.currentFeedback = msg.Feedback + if msg.Feedback.Success { + m.showComplete = true + return m, tea.Quit + } + return m, components.ListenForValidation(m.feedbackChan) + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + } + + fl := m.FocusedIssueList() + cmd, changed := fl.Update(msg) + if changed { + if selected := fl.SelectedItem(); selected.ID != "" { + m.issueDetail.SetIssue(selected.Issue) + } + } + return m, cmd +} diff --git a/pkg/tui/views/kanban/view.go b/pkg/tui/views/kanban/view.go new file mode 100644 index 0000000..34fb0da --- /dev/null +++ b/pkg/tui/views/kanban/view.go @@ -0,0 +1,104 @@ +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()) + + // Add spacer to lock footer to bottom of screen when content is shorter than available space + // This is to avoid having the footer floating above the bottom of the screen + mainView := lipgloss.JoinVertical(lipgloss.Left, header, content, footer) + mainViewHeight := lipgloss.Height(mainView) + if mainViewHeight < m.height { + spacerHeight := m.height - mainViewHeight + spacer := lipgloss.NewStyle().Height(spacerHeight).Width(m.width).Render("") + mainView = lipgloss.JoinVertical(lipgloss.Left, header, content, spacer, 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 new file mode 100644 index 0000000..4920610 --- /dev/null +++ b/pkg/tui/views/root.go @@ -0,0 +1,86 @@ +package views + +import ( + "github.com/LazyBachelor/LazyPM/internal/app" + "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/kanban" + tea "github.com/charmbracelet/bubbletea" +) + +type RootModel struct { + currentView tea.Model + app *app.App + feedbackChan chan models.ValidationFeedback + quitChan chan bool + submitChan chan<- struct{} + lastSize tea.WindowSizeMsg + hasSize bool +} + +func NewRootView(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool, submitChan chan<- struct{}) *RootModel { + initialView := dashboard.NewDashboard(app, feedbackChan, quitChan, submitChan) + return &RootModel{ + currentView: initialView, + app: app, + feedbackChan: feedbackChan, + quitChan: quitChan, + submitChan: submitChan, + } +} + +func (r *RootModel) Init() tea.Cmd { + return r.currentView.Init() +} + +func (r *RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // switch dashboards based on msg + switch m := msg.(type) { + case tea.WindowSizeMsg: + // remember the latest window size and forward it to the current view. + r.hasSize = true + r.lastSize = m + var cmd tea.Cmd + r.currentView, cmd = r.currentView.Update(msg) + return r, cmd + case msgs.SwitchToDashboardMsg: + // switch back to dashboard 1 and apply the last known size. + r.currentView = dashboard.NewDashboard(r.app, r.feedbackChan, r.quitChan, r.submitChan) + var cmds []tea.Cmd + if r.hasSize { + // check if there is a size, and then update it + var sizeCmd tea.Cmd + // set size of dashboard1 to the size before switching + r.currentView, sizeCmd = r.currentView.Update(r.lastSize) + if sizeCmd != nil { + cmds = append(cmds, sizeCmd) + } + } + cmds = append(cmds, tea.ClearScreen, r.currentView.Init()) + return r, tea.Batch(cmds...) + case msgs.SwitchToKanbanBoardMsg: + // switch to kanban board and apply the last known size. + r.currentView = kanban.NewDashboard(r.app, r.feedbackChan, r.quitChan, r.submitChan) + var cmds []tea.Cmd + if r.hasSize { + // check if there is a size, and then update it + var sizeCmd tea.Cmd + // 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) + } + } + cmds = append(cmds, tea.ClearScreen, r.currentView.Init()) + return r, tea.Batch(cmds...) + } + + var cmd tea.Cmd + r.currentView, cmd = r.currentView.Update(msg) + return r, cmd +} + +func (r *RootModel) View() string { + return r.currentView.View() +} diff --git a/pkg/tui/views/views.go b/pkg/tui/views/views.go index 1ba16e7..e26dbd7 100644 --- a/pkg/tui/views/views.go +++ b/pkg/tui/views/views.go @@ -9,3 +9,4 @@ import ( func NewDashboardView(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool, submitChan chan<- struct{}) *dashboard.Model { return dashboard.NewDashboard(app, feedbackChan, quitChan, submitChan) } +