Implemented add comments for tui

This commit is contained in:
Moira Daniella A Sebastian
2026-03-02 20:17:08 +01:00
parent cb10aeff6b
commit 63631fc42f
6 changed files with 165 additions and 28 deletions

View File

@@ -36,7 +36,7 @@ func (h HelpBar) shortHelp() string {
styles.HighlightKey("↓/j") + " down",
styles.HighlightKey("pgup/pgdn") + " page",
styles.HighlightKey("a") + " add",
styles.HighlightKey("e/d/s/p/t") + " edit",
styles.HighlightKey("e/d/s/p/t/c") + " edit",
styles.HighlightKey("x") + " delete",
styles.HighlightKey("q") + " quit",
styles.HighlightKey("?") + " help",
@@ -75,6 +75,7 @@ func (h HelpBar) fullHelp() string {
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("?", "help", "q", "quit"),

View File

@@ -1,6 +1,8 @@
package dashboard
import (
"time"
"github.com/LazyBachelor/LazyPM/internal/models"
"github.com/LazyBachelor/LazyPM/pkg/tui/styles"
"github.com/charmbracelet/bubbles/viewport"
@@ -10,6 +12,7 @@ import (
type IssueDetail struct {
viewport viewport.Model
issue models.Issue
comments []*models.Comment
focused bool
}
@@ -25,6 +28,12 @@ func (i *IssueDetail) SetIssue(issue models.Issue) {
i.refreshContent()
}
// SetComments updates the list of comments displayed for the current issue.
func (i *IssueDetail) SetComments(comments []*models.Comment) {
i.comments = comments
i.refreshContent()
}
func (i *IssueDetail) SetSize(width, height int) {
i.viewport.Height = height
i.viewport.Width = width
@@ -60,19 +69,34 @@ func (i *IssueDetail) refreshContent() {
descLabel := styles.LabelStyle.Render("Description:")
descContent := styles.ValueStyle.Render(i.issue.Description)
content := lipgloss.JoinVertical(lipgloss.Left,
titleRow,
idRow,
typeRow,
statusRow,
priorityRow,
descLabel,
descContent,
)
var parts []string
parts = append(parts, titleRow, idRow, typeRow, statusRow, priorityRow, descLabel, descContent)
// Comments section
commentsLabel := styles.LabelStyle.Render("Comments:")
parts = append(parts, commentsLabel)
if len(i.comments) == 0 {
parts = append(parts, lipgloss.NewStyle().Foreground(styles.FaintText).Render(" No comments yet."))
} else {
for _, c := range i.comments {
authorDate := lipgloss.NewStyle().Foreground(styles.Primary).Render(c.Author) + " " +
lipgloss.NewStyle().Foreground(styles.FaintText).Render(formatCommentTime(c.CreatedAt))
commentRow := lipgloss.JoinVertical(lipgloss.Left,
authorDate,
styles.ValueStyle.Render(c.Text),
)
parts = append(parts, commentRow)
}
}
content := lipgloss.JoinVertical(lipgloss.Left, parts...)
i.viewport.SetContent(content)
}
func formatCommentTime(t time.Time) string {
return t.Format("Jan 2, 15:04")
}
func (i IssueDetail) View() string {
content := i.viewport.View()

View File

@@ -18,6 +18,7 @@ type DashboardKeyMap struct {
ChangeStatus key.Binding
ChangePriority key.Binding
ChangeType key.Binding
AddComment key.Binding
AddIssue key.Binding
DeleteIssue key.Binding
}
@@ -71,6 +72,10 @@ var defaultDashboardKeyMap = DashboardKeyMap{
key.WithKeys("t"),
key.WithHelp("t", "change type"),
),
AddComment: key.NewBinding(
key.WithKeys("c"),
key.WithHelp("c", "add comment"),
),
AddIssue: key.NewBinding(
key.WithKeys("a"),
key.WithHelp("a", "add issue"),
@@ -99,32 +104,37 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd {
d.issueDetail.ScrollUp(1)
case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.ScrollDown):
d.issueDetail.ScrollDown(1)
case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.EditTitle):
case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.EditTitle):
if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" {
d.startEditTitle(selected)
cmd = d.titleInput.Focus()
}
case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.EditDescription):
case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.EditDescription):
if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" {
d.startEditDescription(selected)
cmd = d.descriptionInput.Focus()
}
case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.ChangeStatus):
case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.ChangeStatus):
if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" {
d.startChooseStatus(selected)
}
case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.ChangePriority):
case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.ChangePriority):
if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" {
d.startChoosePriority(selected)
}
case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.ChangeType):
case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.ChangeType):
if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" {
d.startChooseType(selected)
}
case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.AddIssue):
case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.AddComment):
if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" {
d.startAddComment(selected)
cmd = d.commentInput.Focus()
}
case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.AddIssue):
d.startCreateIssue()
cmd = d.createTitleInput.Focus()
case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.DeleteIssue):
case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.DeleteIssue):
fl := d.FocusedIssueList()
if selected := fl.SelectedItem(); selected.ID != "" {
d.startConfirmDelete(selected.ID, fl.Index())

View File

@@ -47,6 +47,9 @@ type Model struct {
priorityIssueID string
choosingType bool // true while choosing a type
typeIssueID string
addingComment bool // true while adding a comment
commentInput textarea.Model
commentIssueID string
feedbackChan chan models.ValidationFeedback
quitChan chan bool
currentFeedback models.ValidationFeedback
@@ -89,15 +92,39 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui
descTa.SetHeight(8)
m.descriptionInput = descTa
commentTa := textarea.New()
commentTa.Placeholder = "Write your comment..."
commentTa.SetWidth(56)
commentTa.SetHeight(6)
m.commentInput = commentTa
if selected := m.issueList.SelectedItem(); selected.ID != "" {
m.issueDetail.SetIssue(selected.Issue)
m.setDetailIssueWithComments(selected.Issue)
} else if selected := m.closedIssueList.SelectedItem(); selected.ID != "" {
m.issueDetail.SetIssue(selected.Issue)
m.setDetailIssueWithComments(selected.Issue)
}
return m
}
// setDetailIssueWithComments sets the issue in the detail pane and loads its comments.
func (m *Model) setDetailIssueWithComments(issue models.Issue) {
m.issueDetail.SetIssue(issue)
if issue.ID == "" {
m.issueDetail.SetComments(nil)
return
}
comments, _ := m.app.Issues.GetIssueComments(context.Background(), issue.ID)
m.issueDetail.SetComments(comments)
}
func (m *Model) startAddComment(selected ListIssue) {
m.addingComment = true
m.commentIssueID = selected.ID
m.commentInput.SetValue("")
m.commentInput.Reset()
}
func (m *Model) startEditTitle(selected ListIssue) {
m.editingTitle = true
m.editingIssueID = selected.ID
@@ -203,11 +230,11 @@ func (m *Model) ToggleFocusedWindow() {
m.focusedWindow = 1 - m.focusedWindow
if m.focusedWindow == 0 {
if selected := m.issueList.SelectedItem(); selected.ID != "" {
m.issueDetail.SetIssue(selected.Issue)
m.setDetailIssueWithComments(selected.Issue)
}
} else {
if selected := m.closedIssueList.SelectedItem(); selected.ID != "" {
m.issueDetail.SetIssue(selected.Issue)
m.setDetailIssueWithComments(selected.Issue)
}
}
m.issueDetail.SetFocused(m.IsFocusedOnDetail())

View File

@@ -2,6 +2,8 @@ package dashboard
import (
"context"
"os"
"os/user"
"github.com/LazyBachelor/LazyPM/internal/app"
"github.com/LazyBachelor/LazyPM/internal/models"
@@ -9,6 +11,19 @@ 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
@@ -49,6 +64,18 @@ type issueDeletedMsg struct {
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}
@@ -121,7 +148,7 @@ func (m *Model) refreshIssueListsAndSelectIssue(issueID string) tea.Cmd {
closedSetCmd := m.closedIssueList.SetIssues(ClosedOnly(issues))
for _, issue := range issues {
if issue.ID == issueID {
m.issueDetail.SetIssue(*issue)
m.setDetailIssueWithComments(*issue)
break
}
}
@@ -203,8 +230,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
m.issueDetail.SetIssue(*selectedIssue)
m.setDetailIssueWithComments(*selectedIssue)
return m, tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { return 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 issueDeletedMsg:
m.confirmingDelete = false
m.deleteConfirmID = ""
@@ -221,7 +257,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)
}
@@ -245,7 +281,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)
}
@@ -254,7 +290,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 selectIssueMsg{IssueID: selectedIssue.ID}
})
@@ -388,6 +424,30 @@ 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" {
issueID := m.editingDescIssueID
@@ -441,7 +501,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
@@ -449,7 +509,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

View File

@@ -70,6 +70,21 @@ func (m *Model) View() string {
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, editBox)
}
if m.addingComment {
editBoxWidth := min(60, m.width-4)
m.commentInput.SetWidth(editBoxWidth - 2)
m.commentInput.SetHeight(8)
editContent := lipgloss.JoinVertical(lipgloss.Left,
styles.LabelStyle.Render("Add comment for "+m.commentIssueID+" (Ctrl+S or Enter to save, Esc to cancel):"),
m.commentInput.View(),
)
editBox := styles.ContainerStyle.
Width(editBoxWidth).
BorderForeground(styles.PrimaryBorder).
Render(editContent)
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, editBox)
}
if m.editingDescription {
editBoxWidth := min(60, m.width-4)
m.descriptionInput.SetWidth(editBoxWidth - 2)