Merge remote-tracking branch 'origin/main' into WIP-LPM-79

This commit is contained in:
Robin Olsen
2026-03-06 16:02:50 +01:00
45 changed files with 2385 additions and 212 deletions

View File

@@ -2,7 +2,10 @@ package dashboard
import (
"context"
"os"
"os/user"
"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"
@@ -10,6 +13,131 @@ import (
tea "github.com/charmbracelet/bubbletea"
)
func defaultCommentAuthor() string {
if u, err := user.Current(); err == nil && u.Username != "" {
return u.Username
}
if s := os.Getenv("USER"); s != "" {
return s
}
if s := os.Getenv("USERNAME"); s != "" {
return s
}
return "user"
}
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
}
type issueCommentAddedMsg struct {
IssueID string
Err error
}
func addIssueCommentCmd(app *app.App, issueID, author, text string) tea.Cmd {
return func() tea.Msg {
_, err := app.Issues.AddIssueComment(context.Background(), issueID, author, text)
return issueCommentAddedMsg{IssueID: issueID, Err: err}
}
}
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.
@@ -22,7 +150,7 @@ func (m *Model) refreshIssueListsAndSelectIssue(issueID string) tea.Cmd {
closedSetCmd := m.closedIssueList.SetIssues(components.ClosedOnly(allIssues))
for _, issue := range allIssues {
if issue.ID == issueID {
m.issueDetail.SetIssue(*issue)
m.setDetailIssueWithComments(*issue)
break
}
}
@@ -36,8 +164,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.editingIssueID = ""
m.titleInput.Blur()
if msg.Err != nil {
m.logAction("tui failed to update issue title")
return m, nil
}
m.logAction("tui updated issue title")
return m, m.refreshIssueListsAndSelectIssue(msg.IssueID)
case issues.DescriptionUpdatedMsg:
@@ -45,32 +175,40 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.editingDescIssueID = ""
m.descriptionInput.Blur()
if msg.Err != nil {
m.logAction("tui failed to update issue description")
return m, nil
}
m.logAction("tui updated issue description")
return m, m.refreshIssueListsAndSelectIssue(msg.IssueID)
case issues.StatusUpdatedMsg:
m.choosingStatus = false
m.statusIssueID = ""
if msg.Err != nil {
m.logAction("tui failed to update issue status")
return m, nil
}
m.logAction("tui updated issue status")
return m, m.refreshIssueListsAndSelectIssue(msg.IssueID)
case issues.PriorityUpdatedMsg:
m.choosingPriority = false
m.priorityIssueID = ""
if msg.Err != nil {
m.logAction("tui failed to update issue priority")
return m, nil
}
m.logAction("tui updated issue priority")
return m, m.refreshIssueListsAndSelectIssue(msg.IssueID)
case issues.TypeUpdatedMsg:
m.choosingType = false
m.typeIssueID = ""
if msg.Err != nil {
m.logAction("tui failed to update issue type")
return m, nil
}
m.logAction("tui updated issue type")
return m, m.refreshIssueListsAndSelectIssue(msg.IssueID)
case issues.SelectIssueMsg:
@@ -83,6 +221,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.createTitleInput.Blur()
m.createTitleInput.Reset()
if msg.Err != nil || msg.Issue == nil {
m.logAction("tui failed to create issue")
return m, nil
}
allIssues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{})
@@ -104,14 +243,26 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
m.issueDetail.SetIssue(*selectedIssue)
m.setDetailIssueWithComments(*selectedIssue)
m.logAction("tui created issue")
return m, tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { return issues.SelectIssueMsg{IssueID: selectedIssue.ID} })
case issueCommentAddedMsg:
m.addingComment = false
m.commentIssueID = ""
m.commentInput.Blur()
m.commentInput.Reset()
if msg.Err != nil {
return m, nil
}
return m, m.refreshIssueListsAndSelectIssue(msg.IssueID)
case issues.DeletedMsg:
m.confirmingDelete = false
m.deleteConfirmID = ""
if msg.Err != nil {
m.logAction("tui failed to delete issue")
return m, nil
}
m.logAction("tui deleted issue")
allIssues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{})
if err != nil {
return m, nil
@@ -122,7 +273,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
closedSetCmd := m.closedIssueList.SetIssues(closedIssues)
// If there are no issues at all, clear the detail view and return.
if len(openIssues) == 0 && len(closedIssues) == 0 {
m.issueDetail.SetIssue(models.Issue{})
m.setDetailIssueWithComments(models.Issue{})
return m, tea.Sequence(setItemsCmd, closedSetCmd)
}
@@ -146,7 +297,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Safety: if targetIssues is still empty here, just clear detail and return.
if len(targetIssues) == 0 {
m.issueDetail.SetIssue(models.Issue{})
m.setDetailIssueWithComments(models.Issue{})
return m, tea.Sequence(setItemsCmd, closedSetCmd)
}
@@ -155,7 +306,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
newIndex = len(targetIssues) - 1
}
selectedIssue := targetIssues[newIndex]
m.issueDetail.SetIssue(*selectedIssue)
m.setDetailIssueWithComments(*selectedIssue)
return m, tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg {
return issues.SelectIssueMsg{IssueID: selectedIssue.ID}
})
@@ -164,12 +315,14 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.confirmingDelete {
switch msg.String() {
case "y", "Y":
m.logAction("tui confirmed issue deletion")
issueID := m.deleteConfirmID
idx := m.deleteConfirmIndex
m.confirmingDelete = false
m.deleteConfirmID = ""
return m, issues.DeleteIssueCmd(m.app, issueID, idx)
case "n", "N", "esc":
m.logAction("tui canceled issue deletion")
m.confirmingDelete = false
m.deleteConfirmID = ""
return m, nil
@@ -179,21 +332,25 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.choosingStatus {
switch msg.String() {
case "o":
m.logAction("tui selected issue status open")
issueID := m.statusIssueID
m.choosingStatus = false
m.statusIssueID = ""
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, 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, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusClosed))
case "esc":
m.logAction("tui canceled status picker")
m.choosingStatus = false
m.statusIssueID = ""
return m, nil
@@ -203,12 +360,14 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.choosingPriority {
switch msg.String() {
case "0", "1", "2", "3", "4":
m.logAction("tui selected issue priority")
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.logAction("tui canceled priority picker")
m.choosingPriority = false
m.priorityIssueID = ""
return m, nil
@@ -220,31 +379,37 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.choosingType {
switch msg.String() {
case "b":
m.logAction("tui selected issue type bug")
issueID := m.typeIssueID
m.choosingType = false
m.typeIssueID = ""
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, 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, 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, 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, issues.UpdateIssueTypeCmd(m.app, issueID, models.TypeChore)
case "esc":
m.logAction("tui canceled type picker")
m.choosingType = false
m.typeIssueID = ""
return m, nil
@@ -257,10 +422,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.String() == "enter" {
title := m.createTitleInput.Value()
if title != "" {
m.logAction("tui submitted new issue")
return m, issues.CreateIssueCmd(m.app, title)
}
}
if msg.String() == "esc" {
m.logAction("tui canceled issue creation")
m.creatingIssue = false
m.createTitleInput.Blur()
m.createTitleInput.Reset()
@@ -275,10 +442,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.String() == "enter" {
newTitle := m.titleInput.Value()
if newTitle != "" {
m.logAction("tui submitted issue title edit")
return m, issues.UpdateIssueTitleCmd(m.app, m.editingIssueID, newTitle)
}
}
if msg.String() == "esc" {
m.logAction("tui canceled issue title edit")
m.editingTitle = false
m.editingIssueID = ""
m.titleInput.Blur()
@@ -289,8 +458,33 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
}
if m.addingComment {
if msg.String() == "ctrl+s" || msg.String() == "enter" {
text := m.commentInput.Value()
if text != "" {
issueID := m.commentIssueID
m.addingComment = false
m.commentIssueID = ""
m.commentInput.Blur()
m.commentInput.Reset()
return m, addIssueCommentCmd(m.app, issueID, defaultCommentAuthor(), text)
}
}
if msg.String() == "esc" {
m.addingComment = false
m.commentIssueID = ""
m.commentInput.Blur()
m.commentInput.Reset()
return m, nil
}
var cmd tea.Cmd
m.commentInput, cmd = m.commentInput.Update(msg)
return m, cmd
}
if m.editingDescription {
if msg.String() == "ctrl+s" {
m.logAction("tui submitted issue description edit")
issueID := m.editingDescIssueID
newDesc := m.descriptionInput.Value()
m.editingDescription = false
@@ -299,6 +493,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, issues.UpdateIssueDescriptionCmd(m.app, issueID, newDesc)
}
if msg.String() == "esc" {
m.logAction("tui canceled issue description edit")
m.editingDescription = false
m.editingDescIssueID = ""
m.descriptionInput.Blur()
@@ -342,7 +537,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmd, changed := m.issueList.Update(msg)
if changed {
if selected := m.issueList.SelectedItem(); selected.ID != "" {
m.issueDetail.SetIssue(selected.Issue)
m.setDetailIssueWithComments(selected.Issue)
}
}
return m, cmd
@@ -350,7 +545,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmd, changed := m.closedIssueList.Update(msg)
if changed {
if selected := m.closedIssueList.SelectedItem(); selected.ID != "" {
m.issueDetail.SetIssue(selected.Issue)
m.setDetailIssueWithComments(selected.Issue)
}
}
return m, cmd