From cf4f62cd7e638cd35cb4c7764938cf54a6ea06d9 Mon Sep 17 00:00:00 2001 From: vb Date: Fri, 13 Feb 2026 15:06:56 +0100 Subject: [PATCH] create, update, and delete functionalities --- .beads/issues.jsonl | 1 + pkg/tui/views/dashboard/help_bar.go | 7 +- pkg/tui/views/dashboard/issue_list.go | 19 +++ pkg/tui/views/dashboard/keys.go | 28 +++++ pkg/tui/views/dashboard/model.go | 58 +++++++-- pkg/tui/views/dashboard/update.go | 169 +++++++++++++++++++++++++- pkg/tui/views/dashboard/view.go | 46 ++++++- 7 files changed, 316 insertions(+), 12 deletions(-) create mode 100644 .beads/issues.jsonl diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl new file mode 100644 index 0000000..6448261 --- /dev/null +++ b/.beads/issues.jsonl @@ -0,0 +1 @@ +{"id":"LazyPM-LPM-32-dko","title":"231","status":"open","priority":2,"issue_type":"feature","owner":"viljarb@tutanota.com","created_at":"2026-02-13T12:35:13.465758325+01:00","created_by":"vb","updated_at":"2026-02-13T12:35:13.465758325+01:00"} diff --git a/pkg/tui/views/dashboard/help_bar.go b/pkg/tui/views/dashboard/help_bar.go index c030ab1..7c85500 100644 --- a/pkg/tui/views/dashboard/help_bar.go +++ b/pkg/tui/views/dashboard/help_bar.go @@ -33,6 +33,9 @@ func (h HelpBar) shortHelp() string { keys := []string{ styles.HighlightKey("↑/k") + " up", styles.HighlightKey("↓/j") + " down", + styles.HighlightKey("a") + " add", + styles.HighlightKey("e") + " edit", + styles.HighlightKey("x") + " delete", styles.HighlightKey("q") + " quit", styles.HighlightKey("?") + " help", } @@ -67,7 +70,9 @@ func (h HelpBar) fullHelp() string { rows := []string{ renderRow("↑/k", "up", "enter", "view issue"), renderRow("↓/j", "down", "b", "back to list"), - renderRow("?", "help", "q", "quit"), + renderRow("a", "add issue", "e", "edit title"), + renderRow("x", "delete issue", "?", "help"), + renderRow("q", "quit", "", ""), } content := lipgloss.JoinVertical(lipgloss.Left, rows...) return lipgloss.NewStyle(). diff --git a/pkg/tui/views/dashboard/issue_list.go b/pkg/tui/views/dashboard/issue_list.go index 18504c1..9730e95 100644 --- a/pkg/tui/views/dashboard/issue_list.go +++ b/pkg/tui/views/dashboard/issue_list.go @@ -193,6 +193,25 @@ func (l IssueList) FilterState() list.FilterState { return l.list.FilterState() } +func (l *IssueList) SetIssues(issues []models.Issue) tea.Cmd { + listIssues := make([]list.Item, len(issues)) + for i, issue := range issues { + listIssues[i] = ListIssue{Issue: issue} + } + return l.list.SetItems(listIssues) +} + +// SelectIssueID sets the list selection to the issue with the given ID, if present. +func (l *IssueList) SelectIssueID(issueID string) { + items := l.list.Items() + for i := 0; i < len(items); i++ { + if item, ok := items[i].(ListIssue); ok && item.ID == issueID { + l.list.Select(i) + return + } + } +} + type IssueListDelegate struct { width int } diff --git a/pkg/tui/views/dashboard/keys.go b/pkg/tui/views/dashboard/keys.go index dfdeb03..3dd21f6 100644 --- a/pkg/tui/views/dashboard/keys.go +++ b/pkg/tui/views/dashboard/keys.go @@ -12,6 +12,9 @@ type DashboardKeyMap struct { BackToList key.Binding ScrollUp key.Binding ScrollDown key.Binding + EditTitle key.Binding + AddIssue key.Binding + DeleteIssue key.Binding } var defaultDashboardKeyMap = DashboardKeyMap{ @@ -39,6 +42,18 @@ var defaultDashboardKeyMap = DashboardKeyMap{ key.WithKeys("down", "j"), key.WithHelp("↓/j", "down"), ), + EditTitle: key.NewBinding( + key.WithKeys("e"), + key.WithHelp("e", "edit title"), + ), + AddIssue: key.NewBinding( + key.WithKeys("a"), + key.WithHelp("a", "add issue"), + ), + DeleteIssue: key.NewBinding( + key.WithKeys("x"), + key.WithHelp("x", "delete issue"), + ), } func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { @@ -57,6 +72,19 @@ 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 && key.Matches(msg, d.keyMap.EditTitle): + // just to check if an issue is selected before trying to edit + if selected := d.issueList.SelectedItem(); selected.ID != "" { + d.startEditTitle(selected) + cmd = d.titleInput.Focus() + } + case !d.editingTitle && !d.creatingIssue && key.Matches(msg, d.keyMap.AddIssue): + d.startCreateIssue() + cmd = d.createTitleInput.Focus() + case !d.editingTitle && !d.creatingIssue && !d.confirmingDelete && key.Matches(msg, d.keyMap.DeleteIssue): + if selected := d.issueList.SelectedItem(); selected.ID != "" { + d.startConfirmDelete(selected.ID, d.issueList.Index()) + } } return cmd diff --git a/pkg/tui/views/dashboard/model.go b/pkg/tui/views/dashboard/model.go index 702cb8f..5664a3f 100644 --- a/pkg/tui/views/dashboard/model.go +++ b/pkg/tui/views/dashboard/model.go @@ -2,19 +2,30 @@ package dashboard import ( "github.com/LazyBachelor/LazyPM/internal/service" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" ) type Model struct { - header Header - issueList IssueList - issueDetail IssueDetail - helpBar HelpBar - keyMap DashboardKeyMap - svc *service.Services - width int - height int - focusedPane int // 0 = list, 1 = detail + header Header + issueList IssueList + issueDetail IssueDetail + helpBar HelpBar + keyMap DashboardKeyMap + svc *service.Services + width int + height int + focusedPane int // 0 = list, 1 = detail + + editingTitle bool // should be true while we are editing a title + titleInput textinput.Model + editingIssueID string + creatingIssue bool // should be true while we are creating a new issue + createTitleInput textinput.Model + + confirmingDelete bool + deleteConfirmID string + deleteConfirmIndex int } func NewDashboard(svc *service.Services) *Model { @@ -31,6 +42,16 @@ func NewDashboard(svc *service.Services) *Model { m.issueDetail = NewIssueDetail() m.helpBar = NewHelpBar(m.keyMap) + ti := textinput.New() + ti.Placeholder = "Issue title ..." + ti.CharLimit = 256 + m.titleInput = ti + + createTi := textinput.New() + createTi.Placeholder = "New issue title ..." + createTi.CharLimit = 256 + m.createTitleInput = createTi + if selected := m.issueList.SelectedItem(); selected.ID != "" { m.issueDetail.SetIssue(selected.Issue) } @@ -38,6 +59,25 @@ func NewDashboard(svc *service.Services) *Model { 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) 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) Init() tea.Cmd { return nil } diff --git a/pkg/tui/views/dashboard/update.go b/pkg/tui/views/dashboard/update.go index 7fe15cc..22b1f51 100644 --- a/pkg/tui/views/dashboard/update.go +++ b/pkg/tui/views/dashboard/update.go @@ -1,13 +1,180 @@ package dashboard import ( - tea "github.com/charmbracelet/bubbletea" + "context" + + "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/LazyBachelor/LazyPM/internal/service" "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" ) +type issueTitleUpdatedMsg struct { + IssueID string + Err error +} + +type selectIssueMsg struct { + IssueID string +} + +type issueCreatedMsg struct { + Issue *models.Issue + Err error +} + +type issueDeletedMsg struct { + IssueID string + Err error + PreviousIndex int +} + +func updateIssueTitleCmd(svc *service.Services, issueID, newTitle string) tea.Cmd { + return func() tea.Msg { + updates := map[string]interface{}{"title": newTitle} + err := svc.Beads.UpdateIssue(context.Background(), issueID, updates, "tui") + return issueTitleUpdatedMsg{IssueID: issueID, Err: err} + } +} + +func createIssueCmd(svc *service.Services, title string) tea.Cmd { + return func() tea.Msg { + issue := &models.Issue{ + Title: title, + Status: models.StatusOpen, + IssueType: models.TypeTask, + } + err := svc.Beads.CreateIssue(context.Background(), issue, "tui") + return issueCreatedMsg{Issue: issue, Err: err} + } +} + +func deleteIssueCmd(svc *service.Services, issueID string, currentIndex int) tea.Cmd { + return func() tea.Msg { + err := svc.Beads.DeleteIssue(context.Background(), issueID) + return issueDeletedMsg{IssueID: issueID, Err: err, PreviousIndex: currentIndex} + } +} + func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case issueTitleUpdatedMsg: + m.editingTitle = false + m.editingIssueID = "" + m.titleInput.Blur() + if msg.Err != nil { + return m, nil + } + // reload issues and keep selection on the updated issue + issues, err := m.svc.Beads.AllIssues(context.Background()) + if err != nil { + return m, nil + } + setItemsCmd := m.issueList.SetIssues(issues) + for _, issue := range issues { + if issue.ID == msg.IssueID { + m.issueDetail.SetIssue(issue) + break + } + } + // after list is updated, select the edited issue again + return m, tea.Sequence(setItemsCmd, func() tea.Msg { return selectIssueMsg{IssueID: msg.IssueID} }) + + case selectIssueMsg: + m.issueList.SelectIssueID(msg.IssueID) + return m, nil + + case issueCreatedMsg: + m.creatingIssue = false + m.createTitleInput.Blur() + m.createTitleInput.Reset() + if msg.Err != nil || msg.Issue == nil { + return m, nil + } + issues, err := m.svc.Beads.AllIssues(context.Background()) + if err != nil { + return m, nil + } + setItemsCmd := m.issueList.SetIssues(issues) + m.issueDetail.SetIssue(*msg.Issue) + return m, tea.Sequence(setItemsCmd, func() tea.Msg { return selectIssueMsg{IssueID: msg.Issue.ID} }) + + case issueDeletedMsg: + m.confirmingDelete = false + m.deleteConfirmID = "" + if msg.Err != nil { + return m, nil + } + issues, err := m.svc.Beads.AllIssues(context.Background()) + if err != nil { + return m, nil + } + setItemsCmd := m.issueList.SetIssues(issues) + if len(issues) == 0 { + m.issueDetail.SetIssue(models.Issue{}) + return m, setItemsCmd + } + newIndex := msg.PreviousIndex + if newIndex >= len(issues) { + newIndex = len(issues) - 1 + } + selectID := issues[newIndex].ID + m.issueDetail.SetIssue(issues[newIndex]) + return m, tea.Sequence(setItemsCmd, func() tea.Msg { return selectIssueMsg{IssueID: selectID} }) + 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, deleteIssueCmd(m.svc, issueID, idx) + case "n", "N", "esc": + m.confirmingDelete = false + m.deleteConfirmID = "" + return m, nil + } + } + + if m.creatingIssue { + if msg.String() == "enter" { + title := m.createTitleInput.Value() + if title != "" { + return m, createIssueCmd(m.svc, 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() + // dont update if its empty or same as the original + if newTitle != "" { + return m, updateIssueTitleCmd(m.svc, 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.issueList.FilterState() == list.Filtering { cmd, _ := m.issueList.Update(msg) return m, cmd diff --git a/pkg/tui/views/dashboard/view.go b/pkg/tui/views/dashboard/view.go index 18d207e..96132c1 100644 --- a/pkg/tui/views/dashboard/view.go +++ b/pkg/tui/views/dashboard/view.go @@ -32,5 +32,49 @@ func (m *Model) View() string { content := lipgloss.JoinHorizontal(lipgloss.Left, listView, detailView) - return lipgloss.JoinVertical(lipgloss.Left, header, content, bottomView) + // return lipgloss.JoinVertical(lipgloss.Left, header, content, bottomView) + mainView := lipgloss.JoinVertical(lipgloss.Left, header, content, bottomView) + + if m.editingTitle { + editBoxWidth := min(60, m.width-4) + m.titleInput.Width = editBoxWidth - 2 + editContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Edit title (Enter to save, Esc to cancel):"), + m.titleInput.View(), + ) + editBox := styles.ContainerStyle. + Width(editBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(editContent) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, editBox) + } + + if m.creatingIssue { + createBoxWidth := min(60, m.width-4) + m.createTitleInput.Width = createBoxWidth - 2 + createContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("New issue (Enter to create, Esc to cancel):"), + m.createTitleInput.View(), + ) + createBox := styles.ContainerStyle. + Width(createBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(createContent) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, createBox) + } + + if m.confirmingDelete { + confirmContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Delete issue "+m.deleteConfirmID+"?"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("Press y to delete, n or Esc to cancel"), + ) + confirmBoxWidth := min(50, m.width-4) + confirmBox := styles.ContainerStyle. + Width(confirmBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(confirmContent) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, confirmBox) + } + + return mainView }