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/youtrack" "task/internal/ytsync" ) // DaemonChangedMsg is sent when the daemon publishes a change event (SSE). type DaemonChangedMsg struct { ProjectID string } // RemoteOps is the optional daemon client surface for sync and MCP tokens. type RemoteOps interface { SyncPull(projectID string) (ytsync.Result, error) SyncPush(projectID string) (ytsync.Result, error) CreateMCPToken(projectID string) (token, createdAt string, err error) RevokeMCPToken(projectID string) error } // 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 remote RemoteOps } // New creates the root UI model. func New(cfg *config.Config, store ticket.Store, projects project.Store, timeStore ticket.TimeStore, exec command.Executor, remote RemoteOps) Model { styles := DefaultStyles() if exec == nil { exec = command.NewHistory() } m := Model{ cfg: cfg, store: store, timeStore: timeStore, projects: projects, exec: exec, remote: remote, 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 DaemonChangedMsg: _ = m.reloadTickets() if m.activeTimer != nil && m.timeStore != nil { if t, err := m.timeStore.LoadActiveTimer(); err == nil { m.activeTimer = t } } return m, nil 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 } if m.remote == nil { m.setError(fmt.Errorf("daemon client not available")) 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)) } remote := m.remote return m, func() tea.Msg { var pull, push ytsync.Result var err error switch action { case views.SyncActionPush: push, err = remote.SyncPush(projectID) default: pull, err = remote.SyncPull(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.MCPToken): item, ok := m.projectList.SelectedItem().(views.ProjectItem) if !ok { m.setError(fmt.Errorf("no project selected")) return m, nil } if m.remote == nil { m.setError(fmt.Errorf("daemon client not available")) return m, nil } token, _, err := m.remote.CreateMCPToken(item.Project.ID) if err != nil { m.setError(err) return m, nil } m.setStatus(fmt.Sprintf("MCP token for %s (save as .tm_token): project=%s token=%s", item.Project.ID, item.Project.ID, token)) return m, nil case key.Matches(msg, m.keys.MCPTokenRevoke): item, ok := m.projectList.SelectedItem().(views.ProjectItem) if !ok { m.setError(fmt.Errorf("no project selected")) return m, nil } if m.remote == nil { m.setError(fmt.Errorf("daemon client not available")) return m, nil } if err := m.remote.RevokeMCPToken(item.Project.ID); err != nil { m.setError(err) return m, nil } m.setStatus(fmt.Sprintf("revoked MCP token for %s", 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 }