Files
task-master/internal/ui/app.go
T
SkrueckenandCursor 6527d91b4f Allow opening the editor while creating a ticket with Ctrl+e.
Draft headline from the create input is passed into $EDITOR so title and description can be written before the ticket is saved.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 10:35:34 +02:00

772 lines
16 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 // empty when create==true
path string
err error
create bool // true: create ticket from edited file
}
// Mode is the active UI mode.
type Mode int
const (
ModeList Mode = iota
ModeCreate
ModeSearch
ModeProjects
ModeStatus
ModeBoard
ModeConfirmDelete
)
// 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
pendingDeleteTitle string
}
// 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)
case ModeConfirmDelete:
return m.updateConfirmDelete(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.DeleteForce):
m.clearStatus()
if err := m.deleteSelected(); err != nil {
m.setError(err)
} else {
m.setStatus("deleted")
}
return m, nil
case key.Matches(msg, m.keys.Delete):
m.clearStatus()
t, ok := m.tickets.Selected()
if !ok {
m.setError(fmt.Errorf("no ticket selected"))
return m, nil
}
m.pendingDeleteTitle = strings.TrimSpace(t.Title)
m.mode = ModeConfirmDelete
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) updateConfirmDelete(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Escape):
m.pendingDeleteTitle = ""
m.mode = ModeList
m.clearStatus()
return m, nil
case key.Matches(msg, m.keys.Enter), key.Matches(msg, m.keys.ConfirmYes):
m.pendingDeleteTitle = ""
m.mode = ModeList
if err := m.deleteSelected(); err != nil {
m.setError(err)
} else {
m.setStatus("deleted")
}
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.EditInEditor):
if m.mode != ModeCreate || m.creatingProject {
break
}
m.clearStatus()
cmd, err := m.openEditorForNewTicket()
if err != nil {
m.setError(err)
return m, nil
}
m.input.Blur()
m.mode = ModeList
return m, cmd
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) openEditorForNewTicket() (tea.Cmd, error) {
title := strings.TrimSpace(m.input.Value())
if title == "" {
title = "New ticket"
}
path, err := ticket.WriteEditFile(ticket.Ticket{Title: title})
if err != nil {
return nil, err
}
c := ticket.EditorCmd(path)
return tea.ExecProcess(c, func(err error) tea.Msg {
return editorFinishedMsg{path: path, err: err, create: true}
}), 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
}
if msg.create {
if err := m.createTicket(title, description); err != nil {
m.setError(err)
return m, nil
}
m.mode = ModeList
m.setStatus("created")
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, description string) error {
cmd := &command.CreateTicket{
Store: m.store,
Ticket: ticket.Ticket{
Title: title,
Description: description,
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
}