Charm-based CLI with JSON persistence, status/list filters, external editor editing, command-history undo/redo, and glamour markdown without AutoStyle OSC hangs. Co-authored-by: Cursor <cursoragent@cursor.com>
695 lines
14 KiB
Go
695 lines
14 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/bubbles/key"
|
|
"github.com/charmbracelet/bubbles/list"
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"task/internal/command"
|
|
"task/internal/config"
|
|
"task/internal/project"
|
|
"task/internal/ticket"
|
|
"task/internal/ui/views"
|
|
"task/internal/version"
|
|
)
|
|
|
|
// editorFinishedMsg is sent after the external $EDITOR exits.
|
|
type editorFinishedMsg struct {
|
|
ticketID string
|
|
path string
|
|
err error
|
|
}
|
|
|
|
// Mode is the active UI mode.
|
|
type Mode int
|
|
|
|
const (
|
|
ModeList Mode = iota
|
|
ModeCreate
|
|
ModeSearch
|
|
ModeProjects
|
|
ModeStatus
|
|
ModeBoard
|
|
)
|
|
|
|
// Model is the root Bubble Tea model.
|
|
type Model struct {
|
|
cfg *config.Config
|
|
store ticket.Store
|
|
projects project.Store
|
|
exec command.Executor
|
|
|
|
mode Mode
|
|
tickets views.TicketList
|
|
projectList list.Model
|
|
statusMenu views.StatusMenu
|
|
boardMenu views.BoardMenu
|
|
boardList ticket.BoardList
|
|
input textinput.Model
|
|
width int
|
|
height int
|
|
status string
|
|
statusIsError bool
|
|
filter string
|
|
keys KeyMap
|
|
styles Styles
|
|
version string
|
|
allTickets []ticket.Ticket
|
|
creatingProject bool
|
|
}
|
|
|
|
// New creates the root UI model.
|
|
func New(cfg *config.Config, store ticket.Store, projects project.Store, exec command.Executor) Model {
|
|
styles := DefaultStyles()
|
|
if exec == nil {
|
|
exec = command.NewHistory()
|
|
}
|
|
|
|
m := Model{
|
|
cfg: cfg,
|
|
store: store,
|
|
projects: projects,
|
|
exec: exec,
|
|
mode: ModeList,
|
|
boardList: ticket.ParseBoardList(cfg.ActiveBoardList(cfg.ActiveProject)),
|
|
tickets: views.TicketList{
|
|
Styles: views.TicketListStyles{
|
|
Card: styles.Card,
|
|
CardSelected: styles.CardSelected,
|
|
StatusOPEN: styles.StatusTodo,
|
|
StatusWIP: styles.StatusWIP,
|
|
StatusDONE: styles.StatusDone,
|
|
Headline: styles.Headline,
|
|
},
|
|
},
|
|
input: views.NewCreateInput(),
|
|
keys: DefaultKeyMap(),
|
|
styles: styles,
|
|
version: version.BuildVersion,
|
|
}
|
|
_ = m.reloadTickets()
|
|
return m
|
|
}
|
|
|
|
// Init implements tea.Model.
|
|
func (m Model) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
// Update implements tea.Model.
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
m.resizeLists()
|
|
return m, nil
|
|
|
|
case editorFinishedMsg:
|
|
return m.handleEditorFinished(msg)
|
|
|
|
case tea.KeyMsg:
|
|
switch m.mode {
|
|
case ModeCreate, ModeSearch:
|
|
return m.updateInput(msg)
|
|
case ModeProjects:
|
|
return m.updateProjects(msg)
|
|
case ModeStatus:
|
|
return m.updateStatus(msg)
|
|
case ModeBoard:
|
|
return m.updateBoard(msg)
|
|
default:
|
|
return m.updateList(msg)
|
|
}
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
switch m.mode {
|
|
case ModeProjects:
|
|
m.projectList, cmd = m.projectList.Update(msg)
|
|
case ModeCreate, ModeSearch:
|
|
m.input, cmd = m.input.Update(msg)
|
|
}
|
|
return m, cmd
|
|
}
|
|
|
|
// View implements tea.Model.
|
|
func (m Model) View() string {
|
|
if m.width == 0 {
|
|
return "loading…"
|
|
}
|
|
header := m.renderHeader()
|
|
footer := m.renderFooter()
|
|
bodyH := m.height - lipgloss.Height(header) - lipgloss.Height(footer)
|
|
if bodyH < 1 {
|
|
bodyH = 1
|
|
}
|
|
body := m.renderBody(bodyH)
|
|
return lipgloss.JoinVertical(lipgloss.Left, header, body, footer)
|
|
}
|
|
|
|
func (m *Model) resizeLists() {
|
|
headerH := 1
|
|
footerH := 1
|
|
bodyH := m.height - headerH - footerH
|
|
if bodyH < 1 {
|
|
bodyH = 1
|
|
}
|
|
m.tickets.SetSize(m.width, bodyH)
|
|
if m.mode == ModeProjects {
|
|
m.projectList.SetSize(m.width, bodyH)
|
|
}
|
|
m.input.Width = max(10, m.width-4)
|
|
m.statusMenu.Width = m.width
|
|
m.boardMenu.Width = m.width
|
|
}
|
|
|
|
func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch {
|
|
case key.Matches(msg, m.keys.Quit):
|
|
return m, tea.Quit
|
|
|
|
case key.Matches(msg, m.keys.New):
|
|
m.clearStatus()
|
|
m.creatingProject = false
|
|
m.mode = ModeCreate
|
|
m.input = views.NewCreateInput()
|
|
m.input.Focus()
|
|
return m, textinput.Blink
|
|
|
|
case key.Matches(msg, m.keys.Search):
|
|
m.clearStatus()
|
|
m.creatingProject = false
|
|
m.mode = ModeSearch
|
|
m.input = views.NewSearchInput()
|
|
m.input.SetValue(m.filter)
|
|
m.input.Focus()
|
|
return m, textinput.Blink
|
|
|
|
case key.Matches(msg, m.keys.Project):
|
|
m.clearStatus()
|
|
if err := m.openProjects(); err != nil {
|
|
m.setError(err)
|
|
return m, nil
|
|
}
|
|
m.mode = ModeProjects
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Board):
|
|
m.clearStatus()
|
|
m.boardMenu = views.NewBoardMenu(m.boardList, m.width, views.StatusMenuStyles{
|
|
Box: m.styles.StatusMenuBox,
|
|
Title: m.styles.StatusMenuTitle,
|
|
Selected: m.styles.StatusMenuSelected,
|
|
Normal: m.styles.StatusMenuNormal,
|
|
})
|
|
m.mode = ModeBoard
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Delete):
|
|
m.clearStatus()
|
|
if err := m.deleteSelected(); err != nil {
|
|
m.setError(err)
|
|
} else {
|
|
m.setStatus("deleted")
|
|
}
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Undo):
|
|
m.clearStatus()
|
|
if err := m.undo(); err != nil {
|
|
m.setError(err)
|
|
}
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Redo):
|
|
m.clearStatus()
|
|
if err := m.redo(); err != nil {
|
|
m.setError(err)
|
|
}
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Edit):
|
|
m.clearStatus()
|
|
cmd, err := m.openEditorForSelected()
|
|
if err != nil {
|
|
m.setError(err)
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
|
|
case key.Matches(msg, m.keys.Status):
|
|
m.clearStatus()
|
|
t, ok := m.tickets.Selected()
|
|
if !ok {
|
|
m.setError(fmt.Errorf("no ticket selected"))
|
|
return m, nil
|
|
}
|
|
m.statusMenu = views.NewStatusMenu(t.Status, m.width, views.StatusMenuStyles{
|
|
Box: m.styles.StatusMenuBox,
|
|
Title: m.styles.StatusMenuTitle,
|
|
Selected: m.styles.StatusMenuSelected,
|
|
Normal: m.styles.StatusMenuNormal,
|
|
})
|
|
m.mode = ModeStatus
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Down):
|
|
m.tickets.MoveDown()
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Up):
|
|
m.tickets.MoveUp()
|
|
return m, nil
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) updateStatus(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch {
|
|
case key.Matches(msg, m.keys.Escape):
|
|
m.mode = ModeList
|
|
m.clearStatus()
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Enter):
|
|
if err := m.applyStatus(m.statusMenu.Selected()); err != nil {
|
|
m.setError(err)
|
|
return m, nil
|
|
}
|
|
m.mode = ModeList
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Down):
|
|
m.statusMenu.MoveDown()
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Up):
|
|
m.statusMenu.MoveUp()
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) updateBoard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch {
|
|
case key.Matches(msg, m.keys.Escape):
|
|
m.mode = ModeList
|
|
m.clearStatus()
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Enter):
|
|
if err := m.applyBoardList(m.boardMenu.Selected()); err != nil {
|
|
m.setError(err)
|
|
return m, nil
|
|
}
|
|
m.mode = ModeList
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Down):
|
|
m.boardMenu.MoveDown()
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Up):
|
|
m.boardMenu.MoveUp()
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch {
|
|
case key.Matches(msg, m.keys.Escape):
|
|
wasProject := m.creatingProject
|
|
m.creatingProject = false
|
|
m.input.Blur()
|
|
m.clearStatus()
|
|
if wasProject {
|
|
m.mode = ModeProjects
|
|
} else {
|
|
m.mode = ModeList
|
|
}
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Enter):
|
|
value := strings.TrimSpace(m.input.Value())
|
|
if m.mode == ModeCreate {
|
|
if value == "" {
|
|
m.setError(fmt.Errorf("name required"))
|
|
return m, nil
|
|
}
|
|
if m.creatingProject {
|
|
id := sanitizeProjectID(value)
|
|
if err := m.projects.Ensure(id); err != nil {
|
|
m.setError(err)
|
|
return m, nil
|
|
}
|
|
if err := m.switchProject(id); err != nil {
|
|
m.setError(err)
|
|
return m, nil
|
|
}
|
|
m.creatingProject = false
|
|
m.mode = ModeList
|
|
m.input.Blur()
|
|
m.setStatus(fmt.Sprintf("created project %s", id))
|
|
return m, nil
|
|
}
|
|
if err := m.createTicket(value); err != nil {
|
|
m.setError(err)
|
|
return m, nil
|
|
}
|
|
m.mode = ModeList
|
|
m.input.Blur()
|
|
m.setStatus("created")
|
|
return m, nil
|
|
}
|
|
m.filter = value
|
|
m.applyFilter()
|
|
m.mode = ModeList
|
|
m.input.Blur()
|
|
if value == "" {
|
|
m.setStatus("filter cleared")
|
|
} else {
|
|
m.setStatus(fmt.Sprintf("filter: %q", value))
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
m.input, cmd = m.input.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch {
|
|
case key.Matches(msg, m.keys.Escape):
|
|
m.mode = ModeList
|
|
m.clearStatus()
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.New):
|
|
m.clearStatus()
|
|
m.creatingProject = true
|
|
m.mode = ModeCreate
|
|
m.input = views.NewCreateInput()
|
|
m.input.Placeholder = "Project name…"
|
|
m.input.Focus()
|
|
return m, textinput.Blink
|
|
|
|
case key.Matches(msg, m.keys.Enter):
|
|
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
|
|
if !ok {
|
|
return m, nil
|
|
}
|
|
if err := m.switchProject(item.Project.ID); err != nil {
|
|
m.setError(err)
|
|
return m, nil
|
|
}
|
|
m.mode = ModeList
|
|
m.setStatus(fmt.Sprintf("switched to %s", item.Project.ID))
|
|
return m, nil
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
m.projectList, cmd = m.projectList.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
func (m Model) openEditorForSelected() (tea.Cmd, error) {
|
|
t, ok := m.tickets.Selected()
|
|
if !ok {
|
|
return nil, fmt.Errorf("no ticket selected")
|
|
}
|
|
path, err := ticket.WriteEditFile(t)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c := ticket.EditorCmd(path)
|
|
ticketID := t.ID
|
|
return tea.ExecProcess(c, func(err error) tea.Msg {
|
|
return editorFinishedMsg{ticketID: ticketID, path: path, err: err}
|
|
}), nil
|
|
}
|
|
|
|
func (m Model) handleEditorFinished(msg editorFinishedMsg) (tea.Model, tea.Cmd) {
|
|
defer func() { _ = os.Remove(msg.path) }()
|
|
|
|
if msg.err != nil {
|
|
m.setError(fmt.Errorf("editor: %w", msg.err))
|
|
return m, nil
|
|
}
|
|
|
|
data, err := os.ReadFile(msg.path)
|
|
if err != nil {
|
|
m.setError(fmt.Errorf("read edited file: %w", err))
|
|
return m, nil
|
|
}
|
|
title, description, err := ticket.ParseEditDocument(string(data))
|
|
if err != nil {
|
|
m.setError(err)
|
|
return m, nil
|
|
}
|
|
|
|
before, err := m.store.Get(msg.ticketID)
|
|
if err != nil {
|
|
m.setError(err)
|
|
return m, nil
|
|
}
|
|
after := before
|
|
after.Title = title
|
|
after.Description = description
|
|
if err := m.exec.Do(&command.UpdateTicket{
|
|
Store: m.store,
|
|
Before: before,
|
|
After: after,
|
|
}); err != nil {
|
|
m.setError(err)
|
|
return m, nil
|
|
}
|
|
idx := m.tickets.CursorIndex()
|
|
if err := m.reloadTickets(); err != nil {
|
|
m.setError(err)
|
|
return m, nil
|
|
}
|
|
if idx < m.tickets.Len() {
|
|
m.tickets.Select(idx)
|
|
}
|
|
m.setStatus("saved")
|
|
return m, nil
|
|
}
|
|
|
|
func (m *Model) createTicket(title string) error {
|
|
cmd := &command.CreateTicket{
|
|
Store: m.store,
|
|
Ticket: ticket.Ticket{
|
|
Title: title,
|
|
ProjectID: m.cfg.ActiveProject,
|
|
Status: m.boardList.DefaultStatus(),
|
|
},
|
|
}
|
|
if err := m.exec.Do(cmd); err != nil {
|
|
return err
|
|
}
|
|
return m.reloadTickets()
|
|
}
|
|
|
|
func (m *Model) deleteSelected() error {
|
|
t, ok := m.tickets.Selected()
|
|
if !ok {
|
|
return fmt.Errorf("no ticket selected")
|
|
}
|
|
idx := m.tickets.CursorIndex()
|
|
if err := m.exec.Do(&command.DeleteTicket{Store: m.store, Snapshot: t}); err != nil {
|
|
return err
|
|
}
|
|
if err := m.reloadTickets(); err != nil {
|
|
return err
|
|
}
|
|
if idx >= m.tickets.Len() && m.tickets.Len() > 0 {
|
|
m.tickets.Select(m.tickets.Len() - 1)
|
|
} else if idx < m.tickets.Len() {
|
|
m.tickets.Select(idx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) applyStatus(st ticket.Status) error {
|
|
t, ok := m.tickets.Selected()
|
|
if !ok {
|
|
return fmt.Errorf("no ticket selected")
|
|
}
|
|
after := t
|
|
after.Status = st
|
|
if err := m.exec.Do(&command.UpdateTicket{
|
|
Store: m.store,
|
|
Before: t,
|
|
After: after,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
idx := m.tickets.CursorIndex()
|
|
if err := m.reloadTickets(); err != nil {
|
|
return err
|
|
}
|
|
if idx < m.tickets.Len() {
|
|
m.tickets.Select(idx)
|
|
}
|
|
m.setStatus(fmt.Sprintf("status → %s", st.Label()))
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) undo() error {
|
|
name := m.exec.LastUndoName()
|
|
if err := m.exec.Undo(); err != nil {
|
|
return err
|
|
}
|
|
if err := m.reloadTickets(); err != nil {
|
|
return err
|
|
}
|
|
if name != "" {
|
|
m.setStatus("undo: " + name)
|
|
} else {
|
|
m.setStatus("undo")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) redo() error {
|
|
name := m.exec.LastRedoName()
|
|
if err := m.exec.Redo(); err != nil {
|
|
return err
|
|
}
|
|
if err := m.reloadTickets(); err != nil {
|
|
return err
|
|
}
|
|
if name != "" {
|
|
m.setStatus("redo: " + name)
|
|
} else {
|
|
m.setStatus("redo")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) applyBoardList(bl ticket.BoardList) error {
|
|
m.boardList = bl
|
|
m.cfg.SetActiveBoardList(m.cfg.ActiveProject, string(bl))
|
|
if err := config.Save(m.cfg); err != nil {
|
|
return err
|
|
}
|
|
m.applyFilter()
|
|
m.setStatus(fmt.Sprintf("list → %s", bl.Label()))
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) switchProject(id string) error {
|
|
if err := m.projects.Ensure(id); err != nil {
|
|
return err
|
|
}
|
|
m.cfg.ActiveProject = id
|
|
m.boardList = ticket.ParseBoardList(m.cfg.ActiveBoardList(id))
|
|
if err := config.Save(m.cfg); err != nil {
|
|
return err
|
|
}
|
|
m.filter = ""
|
|
return m.reloadTickets()
|
|
}
|
|
|
|
func (m *Model) openProjects() error {
|
|
projects, err := m.projects.List()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(projects) == 0 {
|
|
if err := m.projects.Ensure(m.cfg.ActiveProject); err != nil {
|
|
return err
|
|
}
|
|
projects, err = m.projects.List()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
headerH, footerH := 1, 1
|
|
bodyH := m.height - headerH - footerH
|
|
if bodyH < 1 {
|
|
bodyH = 1
|
|
}
|
|
m.projectList = views.NewProjectList(projects, m.cfg.ActiveProject, m.width, bodyH)
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) reloadTickets() error {
|
|
tickets, err := m.store.List(m.cfg.ActiveProject)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.allTickets = tickets
|
|
m.applyFilter()
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) applyFilter() {
|
|
filtered := make([]ticket.Ticket, 0, len(m.allTickets))
|
|
for _, t := range m.allTickets {
|
|
if m.boardList.Matches(t.Status) {
|
|
filtered = append(filtered, t)
|
|
}
|
|
}
|
|
filtered = views.FilterTickets(filtered, m.filter)
|
|
cursor := m.tickets.CursorIndex()
|
|
m.tickets.SetItems(filtered)
|
|
if cursor < m.tickets.Len() {
|
|
m.tickets.Select(cursor)
|
|
}
|
|
}
|
|
|
|
func (m Model) ticketCount() int {
|
|
return m.tickets.Len()
|
|
}
|
|
|
|
func (m *Model) setStatus(s string) {
|
|
m.status = s
|
|
m.statusIsError = false
|
|
}
|
|
|
|
func (m *Model) setError(err error) {
|
|
m.status = err.Error()
|
|
m.statusIsError = true
|
|
}
|
|
|
|
func (m *Model) clearStatus() {
|
|
m.status = ""
|
|
m.statusIsError = false
|
|
}
|
|
|
|
func sanitizeProjectID(name string) string {
|
|
name = strings.TrimSpace(strings.ToLower(name))
|
|
var b strings.Builder
|
|
for _, r := range name {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
|
|
b.WriteRune(r)
|
|
case r == ' ':
|
|
b.WriteRune('-')
|
|
}
|
|
}
|
|
id := b.String()
|
|
if id == "" {
|
|
return "project"
|
|
}
|
|
return id
|
|
}
|
|
|
|
func max(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|