1588 lines
36 KiB
Go
1588 lines
36 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
// timerTickMsg refreshes the header while a timer is running.
|
|
type timerTickMsg time.Time
|
|
|
|
// 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
|
|
ModeMoveTicket
|
|
ModeStatus
|
|
ModeBoard
|
|
ModeConfirmDelete
|
|
ModeTimeManual
|
|
ModeTimeEntries
|
|
ModeTimeEdit
|
|
ModeReport
|
|
ModeReportGroup
|
|
)
|
|
|
|
// Model is the root Bubble Tea model.
|
|
type Model struct {
|
|
cfg *config.Config
|
|
store ticket.Store
|
|
timeStore ticket.TimeStore
|
|
projects project.Store
|
|
exec command.Executor
|
|
|
|
mode Mode
|
|
tickets views.TicketList
|
|
projectList list.Model
|
|
statusMenu views.StatusMenu
|
|
boardMenu views.BoardMenu
|
|
boardFilter ticket.BoardFilter
|
|
input textinput.Model
|
|
report views.ReportView
|
|
reportGroupBy ticket.GroupBy
|
|
groupByMenu views.GroupByMenu
|
|
timeEntries views.TimeEntriesView
|
|
timeEntriesTicket string // ticket ID for ModeTimeEntries
|
|
editingTimeEntry ticket.TimeEntry
|
|
activeTimer *ticket.ActiveTimer
|
|
spentByID map[string]time.Duration
|
|
width int
|
|
height int
|
|
status string
|
|
statusIsError bool
|
|
filter string
|
|
keys KeyMap
|
|
styles Styles
|
|
version string
|
|
allTickets []ticket.Ticket
|
|
creatingProject bool
|
|
pendingDeleteTitle string
|
|
pendingDeleteProject string // non-empty → confirm deletes a project, not a ticket
|
|
}
|
|
|
|
// New creates the root UI model.
|
|
func New(cfg *config.Config, store ticket.Store, projects project.Store, timeStore ticket.TimeStore, exec command.Executor) Model {
|
|
styles := DefaultStyles()
|
|
if exec == nil {
|
|
exec = command.NewHistory()
|
|
}
|
|
|
|
m := Model{
|
|
cfg: cfg,
|
|
store: store,
|
|
timeStore: timeStore,
|
|
projects: projects,
|
|
exec: exec,
|
|
mode: ModeList,
|
|
boardFilter: ticket.ParseBoardLists(cfg.ActiveBoardLists(cfg.ActiveProject)),
|
|
reportGroupBy: ticket.GroupByProject,
|
|
spentByID: map[string]time.Duration{},
|
|
tickets: views.TicketList{
|
|
Styles: views.TicketListStyles{
|
|
Card: styles.Card,
|
|
CardSelected: styles.CardSelected,
|
|
StatusOPEN: styles.StatusTodo,
|
|
StatusWIP: styles.StatusWIP,
|
|
StatusDONE: styles.StatusDone,
|
|
Headline: styles.Headline,
|
|
},
|
|
SpentByID: map[string]time.Duration{},
|
|
},
|
|
input: views.NewCreateInput(),
|
|
keys: DefaultKeyMap(),
|
|
styles: styles,
|
|
version: version.BuildVersion,
|
|
}
|
|
if timeStore != nil {
|
|
if t, err := timeStore.LoadActiveTimer(); err == nil {
|
|
m.activeTimer = t
|
|
}
|
|
}
|
|
_ = m.reloadTickets()
|
|
return m
|
|
}
|
|
|
|
// Init implements tea.Model.
|
|
func (m Model) Init() tea.Cmd {
|
|
if m.activeTimer != nil {
|
|
return timerTickCmd()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func timerTickCmd() tea.Cmd {
|
|
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
|
return timerTickMsg(t)
|
|
})
|
|
}
|
|
|
|
// 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 timerTickMsg:
|
|
if m.activeTimer != nil {
|
|
return m, timerTickCmd()
|
|
}
|
|
return m, nil
|
|
|
|
case editorFinishedMsg:
|
|
return m.handleEditorFinished(msg)
|
|
|
|
case tea.KeyMsg:
|
|
switch m.mode {
|
|
case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit:
|
|
return m.updateInput(msg)
|
|
case ModeProjects:
|
|
return m.updateProjects(msg)
|
|
case ModeMoveTicket:
|
|
return m.updateMoveTicket(msg)
|
|
case ModeStatus:
|
|
return m.updateStatus(msg)
|
|
case ModeBoard:
|
|
return m.updateBoard(msg)
|
|
case ModeConfirmDelete:
|
|
return m.updateConfirmDelete(msg)
|
|
case ModeTimeEntries:
|
|
return m.updateTimeEntries(msg)
|
|
case ModeReport:
|
|
return m.updateReport(msg)
|
|
case ModeReportGroup:
|
|
return m.updateReportGroup(msg)
|
|
default:
|
|
return m.updateList(msg)
|
|
}
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
switch m.mode {
|
|
case ModeProjects, ModeMoveTicket:
|
|
m.projectList, cmd = m.projectList.Update(msg)
|
|
case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit:
|
|
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.mode == ModeMoveTicket {
|
|
m.projectList.SetSize(m.width, bodyH)
|
|
}
|
|
m.input.Width = max(10, m.width-4)
|
|
m.statusMenu.Width = m.width
|
|
m.boardMenu.Width = m.width
|
|
m.groupByMenu.Width = m.width
|
|
m.report.SetSize(m.width, bodyH)
|
|
m.timeEntries.SetSize(m.width, bodyH)
|
|
}
|
|
|
|
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.MoveTicket):
|
|
m.clearStatus()
|
|
if _, ok := m.tickets.Selected(); !ok {
|
|
m.setError(fmt.Errorf("no ticket selected"))
|
|
return m, nil
|
|
}
|
|
if err := m.openProjects(); err != nil {
|
|
m.setError(err)
|
|
return m, nil
|
|
}
|
|
m.mode = ModeMoveTicket
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Board):
|
|
m.clearStatus()
|
|
m.boardMenu = views.NewBoardMenu(m.boardFilter, 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.Timer):
|
|
m.clearStatus()
|
|
cmd, err := m.toggleTimer()
|
|
if err != nil {
|
|
m.setError(err)
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
|
|
case key.Matches(msg, m.keys.TimeManual):
|
|
m.clearStatus()
|
|
if _, ok := m.tickets.Selected(); !ok {
|
|
m.setError(fmt.Errorf("no ticket selected"))
|
|
return m, nil
|
|
}
|
|
m.mode = ModeTimeManual
|
|
m.input = views.NewTimeManualInput()
|
|
m.input.Focus()
|
|
return m, textinput.Blink
|
|
|
|
case key.Matches(msg, m.keys.Report):
|
|
m.clearStatus()
|
|
if err := m.openReport(); err != nil {
|
|
m.setError(err)
|
|
return m, nil
|
|
}
|
|
m.mode = ModeReport
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.TimeEntries):
|
|
m.clearStatus()
|
|
if err := m.openTimeEntries(); err != nil {
|
|
m.setError(err)
|
|
return m, nil
|
|
}
|
|
m.mode = ModeTimeEntries
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.ChecklistNext):
|
|
m.clearStatus()
|
|
m.tickets.MoveChecklistDown()
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.ChecklistPrev):
|
|
m.clearStatus()
|
|
m.tickets.MoveChecklistUp()
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Toggle):
|
|
m.clearStatus()
|
|
if err := m.toggleSelectedCheckbox(); err != nil {
|
|
m.setError(err)
|
|
}
|
|
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
|
|
}
|
|
|
|
// Jump to option by first letter of its label (o/w/d).
|
|
if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 {
|
|
if m.statusMenu.JumpToShortcut(msg.Runes[0]) {
|
|
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.applyBoardFilter(m.boardMenu.Selected()); err != nil {
|
|
m.setError(err)
|
|
return m, nil
|
|
}
|
|
m.mode = ModeList
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Toggle):
|
|
m.boardMenu.Toggle()
|
|
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
|
|
}
|
|
|
|
if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 {
|
|
if m.boardMenu.JumpToShortcut(msg.Runes[0]) {
|
|
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):
|
|
wasProject := m.pendingDeleteProject != ""
|
|
m.pendingDeleteTitle = ""
|
|
m.pendingDeleteProject = ""
|
|
m.clearStatus()
|
|
if wasProject {
|
|
m.mode = ModeProjects
|
|
} else {
|
|
m.mode = ModeList
|
|
}
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Enter), key.Matches(msg, m.keys.ConfirmYes):
|
|
if m.pendingDeleteProject != "" {
|
|
id := m.pendingDeleteProject
|
|
m.pendingDeleteTitle = ""
|
|
m.pendingDeleteProject = ""
|
|
if err := m.deleteProject(id); err != nil {
|
|
m.setError(err)
|
|
m.mode = ModeProjects
|
|
return m, nil
|
|
}
|
|
m.mode = ModeList
|
|
m.setStatus(fmt.Sprintf("deleted project %s", id))
|
|
return m, nil
|
|
}
|
|
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()
|
|
switch {
|
|
case wasProject:
|
|
m.mode = ModeProjects
|
|
case m.mode == ModeTimeEdit:
|
|
m.mode = ModeTimeEntries
|
|
default:
|
|
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 == ModeTimeEdit {
|
|
if err := m.saveEditedTime(value); err != nil {
|
|
m.setError(err)
|
|
return m, nil
|
|
}
|
|
m.input.Blur()
|
|
m.mode = ModeTimeEntries
|
|
m.setStatus("time updated")
|
|
return m, nil
|
|
}
|
|
if m.mode == ModeTimeManual {
|
|
if err := m.addManualTime(value); err != nil {
|
|
m.setError(err)
|
|
return m, nil
|
|
}
|
|
m.mode = ModeList
|
|
m.input.Blur()
|
|
m.setStatus("time logged")
|
|
return m, nil
|
|
}
|
|
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) updateTimeEntries(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch {
|
|
case key.Matches(msg, m.keys.Escape):
|
|
m.timeEntriesTicket = ""
|
|
m.mode = ModeList
|
|
m.clearStatus()
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Down):
|
|
m.timeEntries.MoveDown()
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Up):
|
|
m.timeEntries.MoveUp()
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Delete), key.Matches(msg, m.keys.DeleteForce):
|
|
m.clearStatus()
|
|
if err := m.deleteSelectedTimeEntry(); err != nil {
|
|
m.setError(err)
|
|
} else {
|
|
m.setStatus("time entry deleted")
|
|
}
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Edit):
|
|
m.clearStatus()
|
|
e, ok := m.timeEntries.Selected()
|
|
if !ok {
|
|
m.setError(fmt.Errorf("no entry selected"))
|
|
return m, nil
|
|
}
|
|
m.editingTimeEntry = e
|
|
m.mode = ModeTimeEdit
|
|
m.input = views.NewTimeManualInput()
|
|
m.input.SetValue(ticket.FormatDurationHuman(e.Duration))
|
|
m.input.Focus()
|
|
return m, textinput.Blink
|
|
|
|
case key.Matches(msg, m.keys.Undo):
|
|
m.clearStatus()
|
|
if err := m.undo(); err != nil {
|
|
m.setError(err)
|
|
return m, nil
|
|
}
|
|
if err := m.openTimeEntriesFor(m.timeEntriesTicket); 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
|
|
}
|
|
if err := m.openTimeEntriesFor(m.timeEntriesTicket); err != nil {
|
|
m.setError(err)
|
|
}
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) updateReport(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.Down):
|
|
m.report.MoveDown()
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Up):
|
|
m.report.MoveUp()
|
|
return m, nil
|
|
}
|
|
|
|
if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 {
|
|
switch msg.Runes[0] {
|
|
case 'g', 'G':
|
|
m.groupByMenu = views.NewGroupByMenu(m.reportGroupBy, m.width, views.StatusMenuStyles{
|
|
Box: m.styles.StatusMenuBox,
|
|
Title: m.styles.StatusMenuTitle,
|
|
Selected: m.styles.StatusMenuSelected,
|
|
Normal: m.styles.StatusMenuNormal,
|
|
})
|
|
m.mode = ModeReportGroup
|
|
return m, nil
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) updateReportGroup(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch {
|
|
case key.Matches(msg, m.keys.Escape):
|
|
m.mode = ModeReport
|
|
m.clearStatus()
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Enter):
|
|
m.reportGroupBy = m.groupByMenu.Selected()
|
|
if err := m.openReport(); err != nil {
|
|
m.setError(err)
|
|
return m, nil
|
|
}
|
|
m.mode = ModeReport
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Down):
|
|
m.groupByMenu.MoveDown()
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Up):
|
|
m.groupByMenu.MoveUp()
|
|
return m, nil
|
|
}
|
|
|
|
if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 {
|
|
if m.groupByMenu.JumpToShortcut(msg.Runes[0]) {
|
|
return m, nil
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) updateMoveTicket(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):
|
|
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
|
|
if !ok {
|
|
return m, nil
|
|
}
|
|
if err := m.moveSelectedToProject(item.Project.ID); err != nil {
|
|
m.setError(err)
|
|
return m, nil
|
|
}
|
|
m.mode = ModeList
|
|
return m, nil
|
|
}
|
|
|
|
if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 {
|
|
if views.JumpProjectByLetter(&m.projectList, msg.Runes[0]) {
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
m.projectList, cmd = m.projectList.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.DeleteForce):
|
|
m.clearStatus()
|
|
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
|
|
if !ok {
|
|
m.setError(fmt.Errorf("no project selected"))
|
|
return m, nil
|
|
}
|
|
if err := m.deleteProject(item.Project.ID); err != nil {
|
|
m.setError(err)
|
|
return m, nil
|
|
}
|
|
m.mode = ModeList
|
|
m.setStatus(fmt.Sprintf("deleted project %s", item.Project.ID))
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Delete):
|
|
m.clearStatus()
|
|
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
|
|
if !ok {
|
|
m.setError(fmt.Errorf("no project selected"))
|
|
return m, nil
|
|
}
|
|
n, err := m.countTickets(item.Project.ID)
|
|
if err != nil {
|
|
m.setError(err)
|
|
return m, nil
|
|
}
|
|
m.pendingDeleteProject = item.Project.ID
|
|
m.pendingDeleteTitle = fmt.Sprintf("project %q (%d tickets)", item.Project.ID, n)
|
|
m.mode = ModeConfirmDelete
|
|
return m, nil
|
|
|
|
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
|
|
}
|
|
|
|
if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 {
|
|
if views.JumpProjectByLetter(&m.projectList, msg.Runes[0]) {
|
|
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.boardFilter.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) toggleSelectedCheckbox() error {
|
|
t, ok := m.tickets.Selected()
|
|
if !ok {
|
|
return fmt.Errorf("no ticket selected")
|
|
}
|
|
boxes := ticket.ListCheckboxes(t.Description)
|
|
if len(boxes) == 0 {
|
|
return fmt.Errorf("no checkboxes in description")
|
|
}
|
|
checkIdx := m.tickets.ChecklistCursor
|
|
if checkIdx < 0 || checkIdx >= len(boxes) {
|
|
checkIdx = 0
|
|
}
|
|
newDesc, err := ticket.ToggleCheckbox(t.Description, checkIdx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
after := t
|
|
after.Description = newDesc
|
|
if err := m.exec.Do(&command.UpdateTicket{
|
|
Store: m.store,
|
|
Before: t,
|
|
After: after,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
ticketIdx := m.tickets.CursorIndex()
|
|
if err := m.reloadTickets(); err != nil {
|
|
return err
|
|
}
|
|
if ticketIdx < m.tickets.Len() {
|
|
m.tickets.Select(ticketIdx)
|
|
}
|
|
m.tickets.SetChecklistCursor(checkIdx)
|
|
state := "unchecked"
|
|
if !boxes[checkIdx].Checked {
|
|
state = "checked"
|
|
}
|
|
label := strings.TrimSpace(boxes[checkIdx].Text)
|
|
if label == "" {
|
|
label = fmt.Sprintf("#%d", checkIdx+1)
|
|
}
|
|
m.setStatus(fmt.Sprintf("%s %s", state, 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) applyBoardFilter(f ticket.BoardFilter) error {
|
|
m.boardFilter = ticket.NormalizeFilter(f)
|
|
m.cfg.SetActiveBoardLists(m.cfg.ActiveProject, ticket.FormatBoardLists(m.boardFilter))
|
|
if err := config.Save(m.cfg); err != nil {
|
|
return err
|
|
}
|
|
m.applyFilter()
|
|
m.setStatus(fmt.Sprintf("list → %s", m.boardFilter.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.boardFilter = ticket.ParseBoardLists(m.cfg.ActiveBoardLists(id))
|
|
if err := config.Save(m.cfg); err != nil {
|
|
return err
|
|
}
|
|
m.filter = ""
|
|
return m.reloadTickets()
|
|
}
|
|
|
|
func (m *Model) moveSelectedToProject(toProjectID string) error {
|
|
t, ok := m.tickets.Selected()
|
|
if !ok {
|
|
return fmt.Errorf("no ticket selected")
|
|
}
|
|
if toProjectID == t.ProjectID {
|
|
m.setStatus("already there")
|
|
return nil
|
|
}
|
|
if err := m.projects.Ensure(toProjectID); err != nil {
|
|
return err
|
|
}
|
|
after := t
|
|
after.ProjectID = toProjectID
|
|
if err := m.exec.Do(&command.MoveTicket{
|
|
Store: m.store,
|
|
TimeStore: m.timeStore,
|
|
Before: t,
|
|
After: after,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if m.activeTimer != nil && m.activeTimer.TicketID == t.ID && m.timeStore != nil {
|
|
m.activeTimer.ProjectID = toProjectID
|
|
if err := m.timeStore.SaveActiveTimer(m.activeTimer); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := m.reloadTickets(); err != nil {
|
|
return err
|
|
}
|
|
m.setStatus(fmt.Sprintf("moved → %s", toProjectID))
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) countTickets(projectID string) (int, error) {
|
|
tickets, err := m.store.List(projectID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return len(tickets), nil
|
|
}
|
|
|
|
func (m *Model) deleteProject(id string) error {
|
|
if id == "" {
|
|
return fmt.Errorf("no project selected")
|
|
}
|
|
all, err := m.projects.List()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(all) <= 1 {
|
|
return fmt.Errorf("cannot delete the last project")
|
|
}
|
|
|
|
wasActive := m.cfg.ActiveProject == id
|
|
|
|
// Clear running timer if it belongs to this project.
|
|
if m.activeTimer != nil && m.activeTimer.ProjectID == id && m.timeStore != nil {
|
|
if err := m.timeStore.SaveActiveTimer(nil); err != nil {
|
|
return err
|
|
}
|
|
m.activeTimer = nil
|
|
}
|
|
|
|
if m.timeStore != nil {
|
|
if err := m.timeStore.DeleteByProject(id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := m.projects.Delete(id); err != nil {
|
|
return err
|
|
}
|
|
|
|
m.cfg.ClearProjectLists(id)
|
|
if wasActive {
|
|
next := pickFallbackProject(all, id)
|
|
m.cfg.ActiveProject = next
|
|
m.boardFilter = ticket.ParseBoardLists(m.cfg.ActiveBoardLists(next))
|
|
m.filter = ""
|
|
}
|
|
if err := config.Save(m.cfg); err != nil {
|
|
return err
|
|
}
|
|
if err := m.openProjects(); err != nil {
|
|
return err
|
|
}
|
|
return m.reloadTickets()
|
|
}
|
|
|
|
func pickFallbackProject(all []project.Project, deletedID string) string {
|
|
var rest []string
|
|
for _, p := range all {
|
|
if p.ID == deletedID {
|
|
continue
|
|
}
|
|
rest = append(rest, p.ID)
|
|
}
|
|
for _, id := range rest {
|
|
if id == config.DefaultProject {
|
|
return id
|
|
}
|
|
}
|
|
if len(rest) > 0 {
|
|
return rest[0]
|
|
}
|
|
return config.DefaultProject
|
|
}
|
|
|
|
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
|
|
if err := m.reloadSpent(); err != nil {
|
|
return err
|
|
}
|
|
m.applyFilter()
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) reloadSpent() error {
|
|
m.spentByID = map[string]time.Duration{}
|
|
if m.timeStore == nil {
|
|
m.tickets.SpentByID = m.spentByID
|
|
return nil
|
|
}
|
|
for _, t := range m.allTickets {
|
|
sum, err := m.timeStore.SumByTicket(t.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if sum > 0 {
|
|
m.spentByID[t.ID] = sum
|
|
}
|
|
}
|
|
m.tickets.SpentByID = m.spentByID
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) toggleTimer() (tea.Cmd, error) {
|
|
if m.timeStore == nil {
|
|
return nil, fmt.Errorf("time store not configured")
|
|
}
|
|
sel, ok := m.tickets.Selected()
|
|
if !ok {
|
|
return nil, fmt.Errorf("no ticket selected")
|
|
}
|
|
|
|
if m.activeTimer == nil {
|
|
t := &ticket.ActiveTimer{
|
|
TicketID: sel.ID,
|
|
ProjectID: sel.ProjectID,
|
|
StartedAt: time.Now().UTC(),
|
|
}
|
|
if err := m.timeStore.SaveActiveTimer(t); err != nil {
|
|
return nil, err
|
|
}
|
|
m.activeTimer = t
|
|
m.setStatus("timer started")
|
|
return timerTickCmd(), nil
|
|
}
|
|
|
|
// Stop current timer (same or other ticket).
|
|
stoppedID := m.activeTimer.TicketID
|
|
if err := m.stopActiveTimer(); err != nil {
|
|
return nil, err
|
|
}
|
|
m.setStatus("timer stopped")
|
|
|
|
// If selection is a different ticket, start a new timer there.
|
|
if sel.ID != stoppedID {
|
|
t := &ticket.ActiveTimer{
|
|
TicketID: sel.ID,
|
|
ProjectID: sel.ProjectID,
|
|
StartedAt: time.Now().UTC(),
|
|
}
|
|
if err := m.timeStore.SaveActiveTimer(t); err != nil {
|
|
return nil, err
|
|
}
|
|
m.activeTimer = t
|
|
m.setStatus("timer switched")
|
|
return timerTickCmd(), nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *Model) stopActiveTimer() error {
|
|
if m.activeTimer == nil || m.timeStore == nil {
|
|
return nil
|
|
}
|
|
now := time.Now().UTC()
|
|
start := m.activeTimer.StartedAt
|
|
dur := now.Sub(start)
|
|
if dur < time.Second {
|
|
dur = time.Second
|
|
}
|
|
entry := ticket.TimeEntry{
|
|
TicketID: m.activeTimer.TicketID,
|
|
ProjectID: m.activeTimer.ProjectID,
|
|
Start: start,
|
|
End: now,
|
|
Duration: dur,
|
|
Source: ticket.SourceTimer,
|
|
}
|
|
if err := m.exec.Do(&command.AddTimeEntry{Store: m.timeStore, Entry: entry}); err != nil {
|
|
return err
|
|
}
|
|
if err := m.timeStore.SaveActiveTimer(nil); err != nil {
|
|
return err
|
|
}
|
|
m.activeTimer = nil
|
|
return m.reloadSpent()
|
|
}
|
|
|
|
func (m *Model) addManualTime(raw string) error {
|
|
if m.timeStore == nil {
|
|
return fmt.Errorf("time store not configured")
|
|
}
|
|
sel, ok := m.tickets.Selected()
|
|
if !ok {
|
|
return fmt.Errorf("no ticket selected")
|
|
}
|
|
dur, err := ticket.ParseDurationFlexible(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
end := time.Now().UTC()
|
|
start := end.Add(-dur)
|
|
entry := ticket.TimeEntry{
|
|
TicketID: sel.ID,
|
|
ProjectID: sel.ProjectID,
|
|
Start: start,
|
|
End: end,
|
|
Duration: dur,
|
|
Source: ticket.SourceManual,
|
|
}
|
|
if err := m.exec.Do(&command.AddTimeEntry{Store: m.timeStore, Entry: entry}); err != nil {
|
|
return err
|
|
}
|
|
return m.reloadSpent()
|
|
}
|
|
|
|
func (m *Model) openTimeEntries() error {
|
|
sel, ok := m.tickets.Selected()
|
|
if !ok {
|
|
return fmt.Errorf("no ticket selected")
|
|
}
|
|
return m.openTimeEntriesFor(sel.ID)
|
|
}
|
|
|
|
func (m *Model) openTimeEntriesFor(ticketID string) error {
|
|
if m.timeStore == nil {
|
|
return fmt.Errorf("time store not configured")
|
|
}
|
|
if ticketID == "" {
|
|
return fmt.Errorf("no ticket selected")
|
|
}
|
|
entries, err := m.timeStore.ListByTicket(ticketID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
title := ticketID
|
|
if t, err := m.store.Get(ticketID); err == nil {
|
|
title = strings.TrimSpace(t.Title)
|
|
title = strings.TrimLeft(title, "#")
|
|
title = strings.TrimSpace(title)
|
|
}
|
|
headerH, footerH := 1, 2
|
|
bodyH := m.height - headerH - footerH
|
|
if bodyH < 1 {
|
|
bodyH = 1
|
|
}
|
|
prevCursor := m.timeEntries.Cursor
|
|
m.timeEntriesTicket = ticketID
|
|
m.timeEntries = views.NewTimeEntriesView(title, entries, m.width, bodyH, views.TimeEntriesStyles{
|
|
Title: m.styles.StatusMenuTitle,
|
|
Row: m.styles.StatusMenuNormal,
|
|
Selected: m.styles.StatusMenuSelected,
|
|
Meta: m.styles.HeaderMeta,
|
|
Total: m.styles.Headline,
|
|
Hint: m.styles.HelpDesc,
|
|
Empty: m.styles.HelpDesc,
|
|
})
|
|
if prevCursor < m.timeEntries.Len() {
|
|
m.timeEntries.Cursor = prevCursor
|
|
m.timeEntries.SetSize(m.width, bodyH)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) deleteSelectedTimeEntry() error {
|
|
e, ok := m.timeEntries.Selected()
|
|
if !ok {
|
|
return fmt.Errorf("no entry selected")
|
|
}
|
|
if err := m.exec.Do(&command.RemoveTimeEntry{Store: m.timeStore, Snapshot: e}); err != nil {
|
|
return err
|
|
}
|
|
if err := m.reloadSpent(); err != nil {
|
|
return err
|
|
}
|
|
return m.openTimeEntriesFor(m.timeEntriesTicket)
|
|
}
|
|
|
|
func (m *Model) saveEditedTime(raw string) error {
|
|
if m.timeStore == nil {
|
|
return fmt.Errorf("time store not configured")
|
|
}
|
|
dur, err := ticket.ParseDurationFlexible(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
before := m.editingTimeEntry
|
|
after := before
|
|
after.Duration = dur
|
|
after.End = after.Start.Add(dur)
|
|
if err := m.exec.Do(&command.UpdateTimeEntry{
|
|
Store: m.timeStore,
|
|
Before: before,
|
|
After: after,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if err := m.reloadSpent(); err != nil {
|
|
return err
|
|
}
|
|
return m.openTimeEntriesFor(m.timeEntriesTicket)
|
|
}
|
|
|
|
func (m *Model) openReport() error {
|
|
if m.timeStore == nil {
|
|
return fmt.Errorf("time store not configured")
|
|
}
|
|
entries, err := m.timeStore.List()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
byID, err := m.allTicketsByID()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows := ticket.Aggregate(entries, byID, m.reportGroupBy)
|
|
total := ticket.TotalDuration(entries)
|
|
headerH, footerH := 1, 2
|
|
bodyH := m.height - headerH - footerH
|
|
if bodyH < 1 {
|
|
bodyH = 1
|
|
}
|
|
m.report = views.NewReportView(rows, total, m.reportGroupBy, m.width, bodyH, views.ReportStyles{
|
|
Title: m.styles.StatusMenuTitle,
|
|
Group: m.styles.StatusMenuNormal,
|
|
Duration: m.styles.HeaderMeta,
|
|
Total: m.styles.Headline,
|
|
Hint: m.styles.HelpDesc,
|
|
Selected: m.styles.StatusMenuSelected,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) allTicketsByID() (map[string]ticket.Ticket, error) {
|
|
out := map[string]ticket.Ticket{}
|
|
projects, err := m.projects.List()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, p := range projects {
|
|
list, err := m.store.List(p.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, t := range list {
|
|
out[t.ID] = t
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (m Model) timerHeaderLabel() string {
|
|
if m.activeTimer == nil {
|
|
return ""
|
|
}
|
|
title := m.activeTimer.TicketID
|
|
if t, err := m.store.Get(m.activeTimer.TicketID); err == nil {
|
|
title = strings.TrimSpace(t.Title)
|
|
title = strings.TrimLeft(title, "#")
|
|
title = strings.TrimSpace(title)
|
|
if lipgloss.Width(title) > 24 {
|
|
runes := []rune(title)
|
|
for len(runes) > 0 && lipgloss.Width(string(runes)) > 23 {
|
|
runes = runes[:len(runes)-1]
|
|
}
|
|
title = string(runes) + "…"
|
|
}
|
|
}
|
|
elapsed := time.Since(m.activeTimer.StartedAt)
|
|
return fmt.Sprintf("timer: %s %s", title, ticket.FormatDurationHuman(elapsed))
|
|
}
|
|
|
|
func (m *Model) applyFilter() {
|
|
filtered := make([]ticket.Ticket, 0, len(m.allTickets))
|
|
for _, t := range m.allTickets {
|
|
if m.boardFilter.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
|
|
}
|