Files
task-master/internal/ui/app.go
T
SkrueckenandCursor 24c1e146ec Add offline-first task-master TUI with tickets, board lists, and undo.
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>
2026-10-19 10:21:48 +02:00

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
}