create, update, and delete functionalities
This commit is contained in:
1
.beads/issues.jsonl
Normal file
1
.beads/issues.jsonl
Normal file
@@ -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"}
|
||||
@@ -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().
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user