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 }