Files
task-master/internal/ui/app.go
T
skrueckenandCursor 79b0821837 Allow editing time entry start and end in TUI and web.
Duration is derived from the range so timer sessions can be corrected after the fact.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 22:59:40 +01:00

1894 lines
43 KiB
Go

package ui
import (
"context"
"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"
"task/internal/tmsync"
"task/internal/youtrack"
"task/internal/ytsync"
)
// 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
}
// syncFinishedMsg is sent after a manual YouTrack pull or push completes.
type syncFinishedMsg struct {
projectID string
action views.SyncAction
pull ytsync.Result
push ytsync.Result
err error
}
// 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
ModeEditProject
ModeSync
)
// 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
syncMenu views.SyncMenu
syncProjectID string // project targeted by ModeSync
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
timeEdit views.TimeEditForm
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
projectEdit views.ProjectEditForm
projectEditMeta project.Meta // snapshot when opening the form
syncing bool
}
// 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 syncFinishedMsg:
m.syncing = false
if msg.err != nil {
m.setError(msg.err)
return m, nil
}
if err := m.reloadTickets(); err != nil {
m.setError(err)
return m, nil
}
switch msg.action {
case views.SyncActionPush:
m.setStatus(fmt.Sprintf(
"pushed %s: +%d ~%d · time %d",
msg.projectID, msg.push.Created, msg.push.Updated, msg.push.TimePushed,
))
default:
m.setStatus(fmt.Sprintf(
"pulled %s: +%d ~%d · time %d",
msg.projectID, msg.pull.Created, msg.pull.Updated, msg.pull.TimePulled,
))
}
return m, nil
case tea.KeyMsg:
switch m.mode {
case ModeCreate, ModeSearch, ModeTimeManual:
return m.updateInput(msg)
case ModeTimeEdit:
return m.updateTimeEdit(msg)
case ModeEditProject:
return m.updateEditProject(msg)
case ModeProjects:
return m.updateProjects(msg)
case ModeMoveTicket:
return m.updateMoveTicket(msg)
case ModeStatus:
return m.updateStatus(msg)
case ModeSync:
return m.updateSync(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:
m.input, cmd = m.input.Update(msg)
case ModeTimeEdit:
cmd = m.timeEdit.Update(msg)
case ModeEditProject:
cmd = m.projectEdit.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.Sync):
m.clearStatus()
m.openSyncMenu(m.cfg.ActiveProject)
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
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 == 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.timeEdit = views.NewTimeEditForm(e, m.width)
m.mode = ModeTimeEdit
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) updateEditProject(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Escape):
m.mode = ModeProjects
m.clearStatus()
return m, nil
case key.Matches(msg, m.keys.Tab):
m.projectEdit.NextField()
return m, textinput.Blink
case key.Matches(msg, m.keys.ShiftTab):
m.projectEdit.PrevField()
return m, textinput.Blink
case msg.Type == tea.KeyDown:
m.projectEdit.NextField()
return m, textinput.Blink
case msg.Type == tea.KeyUp:
m.projectEdit.PrevField()
return m, textinput.Blink
case msg.Type == tea.KeyLeft:
m.projectEdit.CycleBackend(-1)
return m, nil
case msg.Type == tea.KeyRight:
m.projectEdit.CycleBackend(1)
return m, nil
case key.Matches(msg, m.keys.Enter):
if err := m.projectEdit.Validate(m.projectEditMeta); err != nil {
m.setError(err)
return m, nil
}
meta := m.projectEdit.Meta(m.projectEditMeta)
id := m.projectEdit.ProjectID
if err := m.projects.SaveMeta(id, meta); err != nil {
m.setError(err)
return m, nil
}
if err := m.openProjects(); err != nil {
m.setError(err)
return m, nil
}
m.mode = ModeProjects
if meta.SyncConfigured() {
m.setStatus(fmt.Sprintf("saved %s sync for %s", meta.EffectiveBackend(), id))
} else {
m.setStatus(fmt.Sprintf("cleared sync settings for %s", id))
}
return m, nil
}
cmd := m.projectEdit.Update(msg)
return m, cmd
}
func (m *Model) openProjectEdit(projectID string) error {
meta, err := m.projects.LoadMeta(projectID)
if err != nil {
return err
}
m.projectEditMeta = meta
m.projectEdit = views.NewProjectEditForm(projectID, meta, m.width)
return nil
}
func (m *Model) openSyncMenu(projectID string) {
m.syncProjectID = projectID
label := "Sync"
if meta, err := m.projects.LoadMeta(projectID); err == nil {
switch meta.EffectiveBackend() {
case project.BackendTaskMaster:
label = "task-master"
case project.BackendYouTrack:
label = "YouTrack"
}
}
m.syncMenu = views.NewSyncMenu(m.width, views.StatusMenuStyles{
Box: m.styles.StatusMenuBox,
Title: m.styles.StatusMenuTitle,
Selected: m.styles.StatusMenuSelected,
Normal: m.styles.StatusMenuNormal,
}, label)
m.mode = ModeSync
}
func (m Model) updateSync(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):
action := m.syncMenu.Selected()
m.mode = ModeList
return m.startSyncAction(m.syncProjectID, action)
case key.Matches(msg, m.keys.Down):
m.syncMenu.MoveDown()
return m, nil
case key.Matches(msg, m.keys.Up):
m.syncMenu.MoveUp()
return m, nil
}
if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 {
if m.syncMenu.JumpToShortcut(msg.Runes[0]) {
action := m.syncMenu.Selected()
m.mode = ModeList
return m.startSyncAction(m.syncProjectID, action)
}
}
return m, nil
}
func (m Model) startSyncAction(projectID string, action views.SyncAction) (tea.Model, tea.Cmd) {
if m.syncing {
m.setStatus("sync already running…")
return m, nil
}
m.clearStatus()
m.syncing = true
switch action {
case views.SyncActionPush:
m.setStatus(fmt.Sprintf("pushing %s…", projectID))
default:
m.setStatus(fmt.Sprintf("pulling %s…", projectID))
}
store := m.store
timeStore := m.timeStore
projects := m.projects
return m, func() tea.Msg {
var pull, push ytsync.Result
var err error
meta, merr := projects.LoadMeta(projectID)
if merr != nil {
err = merr
} else {
switch meta.EffectiveBackend() {
case project.BackendTaskMaster:
syncer := &tmsync.Syncer{Store: store, TimeStore: timeStore}
switch action {
case views.SyncActionPush:
push, err = syncer.Push(context.Background(), projectID, *meta.TaskMaster)
default:
pull, err = syncer.Pull(context.Background(), projectID, *meta.TaskMaster)
}
default:
switch action {
case views.SyncActionPush:
push, err = ytsync.PushProject(context.Background(), projects, store, timeStore, projectID)
default:
pull, err = ytsync.PullProject(context.Background(), projects, store, timeStore, projectID)
}
}
}
return syncFinishedMsg{projectID: projectID, action: action, pull: pull, push: push, err: err}
}
}
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.Edit):
m.clearStatus()
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
if !ok {
m.setError(fmt.Errorf("no project selected"))
return m, nil
}
if err := m.openProjectEdit(item.Project.ID); err != nil {
m.setError(err)
return m, nil
}
m.mode = ModeEditProject
return m, textinput.Blink
case key.Matches(msg, m.keys.Sync):
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
if !ok {
m.setError(fmt.Errorf("no project selected"))
return m, nil
}
m.clearStatus()
m.openSyncMenu(item.Project.ID)
return m, nil
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) (ticket.Ticket, 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 ticket.Ticket{}, err
}
if err := m.reloadTickets(); err != nil {
return ticket.Ticket{}, err
}
return cmd.Ticket, nil
}
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
}
yt := make(map[string]bool, len(projects))
for _, p := range projects {
meta, err := m.projects.LoadMeta(p.ID)
if err != nil {
return err
}
yt[p.ID] = meta.SyncConfigured()
}
m.projectList = views.NewProjectListWithYT(projects, m.cfg.ActiveProject, yt, 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 synced to YouTrack, remove the remote work item first so the next
// pull does not resurrect it.
if e.ExternalID != "" {
if err := m.deleteRemoteWorkItem(e); err != nil {
return err
}
}
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) deleteRemoteWorkItem(e ticket.TimeEntry) error {
tk, err := m.store.Get(e.TicketID)
if err != nil {
return fmt.Errorf("resolve ticket for work item: %w", err)
}
if tk.ExternalID == "" {
return nil
}
meta, err := m.projects.LoadMeta(tk.ProjectID)
if err != nil {
return err
}
if meta.YouTrack == nil || !meta.YouTrack.Configured() {
return nil
}
client := &youtrack.Client{BaseURL: meta.YouTrack.BaseURL, Token: meta.YouTrack.Token}
if err := client.DeleteWorkItem(context.Background(), tk.ExternalID, e.ExternalID); err != nil {
return fmt.Errorf("delete YouTrack work item: %w", err)
}
return nil
}
func (m Model) updateTimeEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Escape):
m.mode = ModeTimeEntries
m.clearStatus()
return m, nil
case key.Matches(msg, m.keys.Tab), msg.Type == tea.KeyDown:
m.timeEdit.NextField()
return m, textinput.Blink
case key.Matches(msg, m.keys.ShiftTab), msg.Type == tea.KeyUp:
m.timeEdit.PrevField()
return m, textinput.Blink
case key.Matches(msg, m.keys.Enter):
m.clearStatus()
if err := m.saveEditedTime(); err != nil {
m.setError(err)
return m, nil
}
m.mode = ModeTimeEntries
m.setStatus("time updated")
return m, nil
}
cmd := m.timeEdit.Update(msg)
return m, cmd
}
func (m *Model) saveEditedTime() error {
if m.timeStore == nil {
return fmt.Errorf("time store not configured")
}
start, end, err := m.timeEdit.Parse()
if err != nil {
return err
}
before := m.editingTimeEntry
after := before
after.Start = start
after.End = end
after.Duration = end.Sub(start)
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
}