Add local JSON daemon and route TUI/CLI through it.
A per-DataDir background process owns ticket JSON, history, and sync so multiple TUI instances stay consistent; MCP token APIs are ready for phase 2. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+70
-26
@@ -19,11 +19,23 @@ import (
|
||||
"task/internal/ticket"
|
||||
"task/internal/ui/views"
|
||||
"task/internal/version"
|
||||
"task/internal/tmsync"
|
||||
"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
|
||||
|
||||
@@ -106,10 +118,11 @@ type Model struct {
|
||||
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) 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()
|
||||
@@ -121,6 +134,7 @@ func New(cfg *config.Config, store ticket.Store, projects project.Store, timeSto
|
||||
timeStore: timeStore,
|
||||
projects: projects,
|
||||
exec: exec,
|
||||
remote: remote,
|
||||
mode: ModeList,
|
||||
boardFilter: ticket.ParseBoardLists(cfg.ActiveBoardLists(cfg.ActiveProject)),
|
||||
reportGroupBy: ticket.GroupByProject,
|
||||
@@ -167,6 +181,15 @@ func timerTickCmd() tea.Cmd {
|
||||
// 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
|
||||
@@ -920,6 +943,10 @@ func (m Model) startSyncAction(projectID string, action views.SyncAction) (tea.M
|
||||
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 {
|
||||
@@ -928,33 +955,15 @@ func (m Model) startSyncAction(projectID string, action views.SyncAction) (tea.M
|
||||
default:
|
||||
m.setStatus(fmt.Sprintf("pulling %s…", projectID))
|
||||
}
|
||||
store := m.store
|
||||
timeStore := m.timeStore
|
||||
projects := m.projects
|
||||
remote := m.remote
|
||||
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)
|
||||
}
|
||||
}
|
||||
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}
|
||||
}
|
||||
@@ -1031,6 +1040,41 @@ func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
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)
|
||||
|
||||
+11
-1
@@ -25,6 +25,8 @@ type KeyMap struct {
|
||||
TimeEntries key.Binding // m — manage time entries
|
||||
Report key.Binding // r — time report
|
||||
Sync key.Binding // Y — pull YouTrack issues
|
||||
MCPToken key.Binding // g — create MCP token for project
|
||||
MCPTokenRevoke key.Binding // G — revoke MCP token
|
||||
Undo key.Binding
|
||||
Redo key.Binding
|
||||
Quit key.Binding
|
||||
@@ -121,6 +123,14 @@ func DefaultKeyMap() KeyMap {
|
||||
key.WithKeys("Y"),
|
||||
key.WithHelp("Y", "YouTrack pull/push"),
|
||||
),
|
||||
MCPToken: key.NewBinding(
|
||||
key.WithKeys("g"),
|
||||
key.WithHelp("g", "MCP token"),
|
||||
),
|
||||
MCPTokenRevoke: key.NewBinding(
|
||||
key.WithKeys("G"),
|
||||
key.WithHelp("G", "revoke MCP token"),
|
||||
),
|
||||
Undo: key.NewBinding(
|
||||
key.WithKeys("u", "ctrl+z"),
|
||||
key.WithHelp("u", "undo"),
|
||||
@@ -154,7 +164,7 @@ func DefaultKeyMap() KeyMap {
|
||||
|
||||
// ShortHelp implements help.KeyMap.
|
||||
func (k KeyMap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{k.Up, k.New, k.Search, k.Project, k.MoveTicket, k.Board, k.Status, k.Edit, k.Timer, k.TimeManual, k.TimeEntries, k.Report, k.Sync, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit}
|
||||
return []key.Binding{k.Up, k.New, k.Search, k.Project, k.MoveTicket, k.Board, k.Status, k.Edit, k.Timer, k.TimeManual, k.TimeEntries, k.Report, k.Sync, k.MCPToken, k.MCPTokenRevoke, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit}
|
||||
}
|
||||
|
||||
// FullHelp implements help.KeyMap.
|
||||
|
||||
Reference in New Issue
Block a user