Add YouTrack pull/push sync with project settings and time tracking.
Wire manual Y menu and CLI sync for issues and work items, persist per-project credentials, and keep local edits until an explicit push. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+256
-6
@@ -1,6 +1,7 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -18,6 +19,8 @@ import (
|
||||
"task/internal/ticket"
|
||||
"task/internal/ui/views"
|
||||
"task/internal/version"
|
||||
"task/internal/youtrack"
|
||||
"task/internal/ytsync"
|
||||
)
|
||||
|
||||
// timerTickMsg refreshes the header while a timer is running.
|
||||
@@ -31,6 +34,15 @@ type editorFinishedMsg struct {
|
||||
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
|
||||
|
||||
@@ -48,6 +60,8 @@ const (
|
||||
ModeTimeEdit
|
||||
ModeReport
|
||||
ModeReportGroup
|
||||
ModeEditProject
|
||||
ModeSync
|
||||
)
|
||||
|
||||
// Model is the root Bubble Tea model.
|
||||
@@ -62,6 +76,8 @@ type Model struct {
|
||||
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
|
||||
@@ -85,6 +101,9 @@ type Model struct {
|
||||
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.
|
||||
@@ -161,16 +180,44 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
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, ModeTimeEdit:
|
||||
return m.updateInput(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:
|
||||
@@ -192,6 +239,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.projectList, cmd = m.projectList.Update(msg)
|
||||
case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit:
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
case ModeEditProject:
|
||||
cmd = m.projectEdit.Update(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
@@ -373,6 +422,11 @@ func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
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 {
|
||||
@@ -594,7 +648,7 @@ func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.setStatus(fmt.Sprintf("created project %s", id))
|
||||
return m, nil
|
||||
}
|
||||
if err := m.createTicket(value, ""); err != nil {
|
||||
if _, err := m.createTicket(value, ""); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
@@ -749,6 +803,138 @@ func (m Model) updateReportGroup(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
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 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.YouTrack != nil && meta.YouTrack.Configured() {
|
||||
m.setStatus(fmt.Sprintf("saved YouTrack settings for %s", id))
|
||||
} else {
|
||||
m.setStatus(fmt.Sprintf("cleared YouTrack 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
|
||||
m.syncMenu = views.NewSyncMenu(m.width, views.StatusMenuStyles{
|
||||
Box: m.styles.StatusMenuBox,
|
||||
Title: m.styles.StatusMenuTitle,
|
||||
Selected: m.styles.StatusMenuSelected,
|
||||
Normal: m.styles.StatusMenuNormal,
|
||||
})
|
||||
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
|
||||
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):
|
||||
@@ -796,6 +982,30 @@ func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
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)
|
||||
@@ -904,7 +1114,7 @@ func (m Model) handleEditorFinished(msg editorFinishedMsg) (tea.Model, tea.Cmd)
|
||||
}
|
||||
|
||||
if msg.create {
|
||||
if err := m.createTicket(title, description); err != nil {
|
||||
if _, err := m.createTicket(title, description); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
@@ -941,7 +1151,7 @@ func (m Model) handleEditorFinished(msg editorFinishedMsg) (tea.Model, tea.Cmd)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) createTicket(title, description string) error {
|
||||
func (m *Model) createTicket(title, description string) (ticket.Ticket, error) {
|
||||
cmd := &command.CreateTicket{
|
||||
Store: m.store,
|
||||
Ticket: ticket.Ticket{
|
||||
@@ -952,9 +1162,12 @@ func (m *Model) createTicket(title, description string) error {
|
||||
},
|
||||
}
|
||||
if err := m.exec.Do(cmd); err != nil {
|
||||
return err
|
||||
return ticket.Ticket{}, err
|
||||
}
|
||||
return m.reloadTickets()
|
||||
if err := m.reloadTickets(); err != nil {
|
||||
return ticket.Ticket{}, err
|
||||
}
|
||||
return cmd.Ticket, nil
|
||||
}
|
||||
|
||||
func (m *Model) deleteSelected() error {
|
||||
@@ -1232,7 +1445,15 @@ func (m *Model) openProjects() error {
|
||||
if bodyH < 1 {
|
||||
bodyH = 1
|
||||
}
|
||||
m.projectList = views.NewProjectList(projects, m.cfg.ActiveProject, m.width, bodyH)
|
||||
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.YouTrack != nil && meta.YouTrack.Configured()
|
||||
}
|
||||
m.projectList = views.NewProjectListWithYT(projects, m.cfg.ActiveProject, yt, m.width, bodyH)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1424,6 +1645,13 @@ func (m *Model) deleteSelectedTimeEntry() error {
|
||||
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
|
||||
}
|
||||
@@ -1433,6 +1661,28 @@ func (m *Model) deleteSelectedTimeEntry() error {
|
||||
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) saveEditedTime(raw string) error {
|
||||
if m.timeStore == nil {
|
||||
return fmt.Errorf("time store not configured")
|
||||
|
||||
@@ -33,10 +33,18 @@ func (m Model) helpEntries() []helpEntry {
|
||||
{"a-z", "jump"},
|
||||
{"enter", "select"},
|
||||
{"n", "new project"},
|
||||
{"e", "edit"},
|
||||
{"Y", "YouTrack"},
|
||||
{"x", "delete"},
|
||||
{"X", "force delete"},
|
||||
{"esc", "cancel"},
|
||||
}
|
||||
case ModeEditProject:
|
||||
return []helpEntry{
|
||||
{"tab/↑↓", "fields"},
|
||||
{"enter", "save"},
|
||||
{"esc", "cancel"},
|
||||
}
|
||||
case ModeMoveTicket:
|
||||
return []helpEntry{
|
||||
{"j/k", "navigate"},
|
||||
@@ -51,6 +59,13 @@ func (m Model) helpEntries() []helpEntry {
|
||||
{"enter", "apply"},
|
||||
{"esc", "cancel"},
|
||||
}
|
||||
case ModeSync:
|
||||
return []helpEntry{
|
||||
{"j/k", "select"},
|
||||
{"p/u", "jump"},
|
||||
{"enter", "run"},
|
||||
{"esc", "cancel"},
|
||||
}
|
||||
case ModeBoard:
|
||||
return []helpEntry{
|
||||
{"j/k", "navigate"},
|
||||
@@ -106,6 +121,7 @@ func (m Model) helpEntries() []helpEntry {
|
||||
{"t/T", "timer/log"},
|
||||
{"m", "entries"},
|
||||
{"r", "report"},
|
||||
{"Y", "YouTrack"},
|
||||
{"u/U", "undo/redo"},
|
||||
{"/", "search"},
|
||||
{"p", "project"},
|
||||
|
||||
+16
-1
@@ -24,11 +24,14 @@ type KeyMap struct {
|
||||
TimeManual key.Binding // T — manual duration
|
||||
TimeEntries key.Binding // m — manage time entries
|
||||
Report key.Binding // r — time report
|
||||
Sync key.Binding // Y — pull YouTrack issues
|
||||
Undo key.Binding
|
||||
Redo key.Binding
|
||||
Quit key.Binding
|
||||
Enter key.Binding
|
||||
Escape key.Binding
|
||||
Tab key.Binding // tab — next form field
|
||||
ShiftTab key.Binding // shift+tab — previous form field
|
||||
}
|
||||
|
||||
// DefaultKeyMap returns the default key bindings.
|
||||
@@ -114,6 +117,10 @@ func DefaultKeyMap() KeyMap {
|
||||
key.WithKeys("r"),
|
||||
key.WithHelp("r", "report"),
|
||||
),
|
||||
Sync: key.NewBinding(
|
||||
key.WithKeys("Y"),
|
||||
key.WithHelp("Y", "YouTrack pull/push"),
|
||||
),
|
||||
Undo: key.NewBinding(
|
||||
key.WithKeys("u", "ctrl+z"),
|
||||
key.WithHelp("u", "undo"),
|
||||
@@ -134,12 +141,20 @@ func DefaultKeyMap() KeyMap {
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "cancel"),
|
||||
),
|
||||
Tab: key.NewBinding(
|
||||
key.WithKeys("tab"),
|
||||
key.WithHelp("tab", "next field"),
|
||||
),
|
||||
ShiftTab: key.NewBinding(
|
||||
key.WithKeys("shift+tab"),
|
||||
key.WithHelp("shift+tab", "prev field"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// 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.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.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit}
|
||||
}
|
||||
|
||||
// FullHelp implements help.KeyMap.
|
||||
|
||||
@@ -80,10 +80,17 @@ func (m Model) renderBody(height int) string {
|
||||
m.projectList.SetSize(m.width, height)
|
||||
return m.projectList.View()
|
||||
|
||||
case ModeEditProject:
|
||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(m.projectEdit.View())
|
||||
|
||||
case ModeStatus:
|
||||
menu := m.statusMenu.View()
|
||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu)
|
||||
|
||||
case ModeSync:
|
||||
menu := m.syncMenu.View()
|
||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu)
|
||||
|
||||
case ModeBoard:
|
||||
menu := m.boardMenu.View()
|
||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu)
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"task/internal/project"
|
||||
)
|
||||
|
||||
// ProjectEdit field indices.
|
||||
const (
|
||||
FieldRemoteURL = iota
|
||||
FieldToken
|
||||
FieldProjectKey
|
||||
ProjectEditFieldCount
|
||||
)
|
||||
|
||||
// ProjectEditStyles styles the project edit form.
|
||||
type ProjectEditStyles struct {
|
||||
Label lipgloss.Style
|
||||
Focused lipgloss.Style
|
||||
Blurred lipgloss.Style
|
||||
Hint lipgloss.Style
|
||||
Title lipgloss.Style
|
||||
}
|
||||
|
||||
// DefaultProjectEditStyles returns sensible defaults.
|
||||
func DefaultProjectEditStyles() ProjectEditStyles {
|
||||
return ProjectEditStyles{
|
||||
Label: lipgloss.NewStyle().Foreground(lipgloss.Color("245")),
|
||||
Focused: lipgloss.NewStyle().Foreground(lipgloss.Color("205")),
|
||||
Blurred: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
|
||||
Hint: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
|
||||
Title: lipgloss.NewStyle().Bold(true),
|
||||
}
|
||||
}
|
||||
|
||||
// ProjectEditForm is a multi-field editor for YouTrack project settings.
|
||||
type ProjectEditForm struct {
|
||||
ProjectID string
|
||||
Inputs []textinput.Model
|
||||
Focus int
|
||||
Styles ProjectEditStyles
|
||||
Width int
|
||||
hadToken bool
|
||||
tokenChanged bool
|
||||
}
|
||||
|
||||
// NewProjectEditForm builds a form prefilled from meta.
|
||||
func NewProjectEditForm(projectID string, meta project.Meta, width int) ProjectEditForm {
|
||||
styles := DefaultProjectEditStyles()
|
||||
inputs := make([]textinput.Model, ProjectEditFieldCount)
|
||||
|
||||
mk := func(placeholder string, charLimit int, password bool) textinput.Model {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = placeholder
|
||||
ti.CharLimit = charLimit
|
||||
ti.Width = max(20, width-4)
|
||||
if password {
|
||||
ti.EchoMode = textinput.EchoPassword
|
||||
ti.EchoCharacter = '•'
|
||||
}
|
||||
return ti
|
||||
}
|
||||
|
||||
inputs[FieldRemoteURL] = mk("https://youtrack.example.com", 256, false)
|
||||
inputs[FieldToken] = mk("permanent token", 512, true)
|
||||
inputs[FieldProjectKey] = mk("PROJ", 64, false)
|
||||
|
||||
hadToken := false
|
||||
if meta.YouTrack != nil {
|
||||
yt := meta.YouTrack
|
||||
inputs[FieldRemoteURL].SetValue(yt.BaseURL)
|
||||
inputs[FieldProjectKey].SetValue(yt.ProjectKey)
|
||||
if yt.Token != "" {
|
||||
hadToken = true
|
||||
inputs[FieldToken].Placeholder = "(unchanged — type to replace)"
|
||||
}
|
||||
}
|
||||
|
||||
f := ProjectEditForm{
|
||||
ProjectID: projectID,
|
||||
Inputs: inputs,
|
||||
Focus: FieldRemoteURL,
|
||||
Styles: styles,
|
||||
Width: width,
|
||||
hadToken: hadToken,
|
||||
}
|
||||
f.focusIndex(FieldRemoteURL)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *ProjectEditForm) focusIndex(i int) {
|
||||
f.Focus = i
|
||||
for idx := range f.Inputs {
|
||||
if idx == i {
|
||||
f.Inputs[idx].Focus()
|
||||
f.Inputs[idx].PromptStyle = f.Styles.Focused
|
||||
f.Inputs[idx].TextStyle = f.Styles.Focused
|
||||
} else {
|
||||
f.Inputs[idx].Blur()
|
||||
f.Inputs[idx].PromptStyle = f.Styles.Blurred
|
||||
f.Inputs[idx].TextStyle = f.Styles.Blurred
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NextField moves focus to the next field.
|
||||
func (f *ProjectEditForm) NextField() {
|
||||
f.focusIndex((f.Focus + 1) % ProjectEditFieldCount)
|
||||
}
|
||||
|
||||
// PrevField moves focus to the previous field.
|
||||
func (f *ProjectEditForm) PrevField() {
|
||||
f.focusIndex((f.Focus - 1 + ProjectEditFieldCount) % ProjectEditFieldCount)
|
||||
}
|
||||
|
||||
// Update forwards a message to the focused text input.
|
||||
func (f *ProjectEditForm) Update(msg tea.Msg) tea.Cmd {
|
||||
var cmd tea.Cmd
|
||||
f.Inputs[f.Focus], cmd = f.Inputs[f.Focus].Update(msg)
|
||||
if f.Focus == FieldToken && f.Inputs[FieldToken].Value() != "" {
|
||||
f.tokenChanged = true
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// Meta builds the Meta to persist. Empty token keeps the previous token.
|
||||
func (f ProjectEditForm) Meta(previous project.Meta) project.Meta {
|
||||
baseURL := strings.TrimSpace(f.Inputs[FieldRemoteURL].Value())
|
||||
token := strings.TrimSpace(f.Inputs[FieldToken].Value())
|
||||
key := strings.TrimSpace(f.Inputs[FieldProjectKey].Value())
|
||||
|
||||
if baseURL == "" && token == "" && key == "" {
|
||||
return project.Meta{YouTrack: nil}
|
||||
}
|
||||
|
||||
yt := &project.YouTrack{
|
||||
BaseURL: baseURL,
|
||||
ProjectKey: key,
|
||||
Token: token,
|
||||
}
|
||||
if token == "" && previous.YouTrack != nil {
|
||||
yt.Token = previous.YouTrack.Token
|
||||
}
|
||||
if previous.YouTrack != nil && previous.YouTrack.Query != "" {
|
||||
yt.Query = previous.YouTrack.Query
|
||||
}
|
||||
return project.Meta{YouTrack: yt}
|
||||
}
|
||||
|
||||
// Validate ensures partial YouTrack configs are rejected.
|
||||
func (f ProjectEditForm) Validate(previous project.Meta) error {
|
||||
meta := f.Meta(previous)
|
||||
if meta.YouTrack == nil {
|
||||
return nil
|
||||
}
|
||||
yt := meta.YouTrack
|
||||
any := yt.BaseURL != "" || yt.Token != "" || yt.ProjectKey != ""
|
||||
if any && !yt.Configured() {
|
||||
missing := []string{}
|
||||
if yt.BaseURL == "" {
|
||||
missing = append(missing, "remote URL")
|
||||
}
|
||||
if yt.Token == "" {
|
||||
missing = append(missing, "token")
|
||||
}
|
||||
if yt.ProjectKey == "" {
|
||||
missing = append(missing, "project key")
|
||||
}
|
||||
return fmt.Errorf("incomplete YouTrack config: missing %s", strings.Join(missing, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// View renders the form.
|
||||
func (f ProjectEditForm) View() string {
|
||||
labels := []string{"Remote URL", "Token", "Project key"}
|
||||
var b strings.Builder
|
||||
b.WriteString(f.Styles.Title.Render(fmt.Sprintf("Edit project %s", f.ProjectID)))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(f.Styles.Hint.Render("YouTrack sync settings · tab/↑↓ fields · enter save · esc cancel"))
|
||||
b.WriteString("\n\n")
|
||||
for i, label := range labels {
|
||||
b.WriteString(f.Styles.Label.Render(label + ":"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(f.Inputs[i].View())
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
if f.hadToken && !f.tokenChanged {
|
||||
b.WriteString(f.Styles.Hint.Render("Token is set; leave blank to keep."))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package views_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"task/internal/project"
|
||||
"task/internal/ui/views"
|
||||
)
|
||||
|
||||
func TestProjectEditMetaKeepsToken(t *testing.T) {
|
||||
prev := project.Meta{
|
||||
YouTrack: &project.YouTrack{
|
||||
BaseURL: "https://yt.example",
|
||||
Token: "secret",
|
||||
ProjectKey: "ABC",
|
||||
},
|
||||
}
|
||||
f := views.NewProjectEditForm("inbox", prev, 80)
|
||||
// Leave token blank → keep previous.
|
||||
f.Inputs[views.FieldRemoteURL].SetValue("https://yt.example")
|
||||
f.Inputs[views.FieldProjectKey].SetValue("ABC")
|
||||
f.Inputs[views.FieldToken].SetValue("")
|
||||
|
||||
meta := f.Meta(prev)
|
||||
if meta.YouTrack == nil || meta.YouTrack.Token != "secret" {
|
||||
t.Fatalf("expected kept token, got %+v", meta.YouTrack)
|
||||
}
|
||||
if err := f.Validate(prev); err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectEditValidateIncomplete(t *testing.T) {
|
||||
f := views.NewProjectEditForm("inbox", project.Meta{}, 80)
|
||||
f.Inputs[views.FieldRemoteURL].SetValue("https://yt.example")
|
||||
// missing token + key
|
||||
if err := f.Validate(project.Meta{}); err == nil {
|
||||
t.Fatal("expected incomplete error")
|
||||
}
|
||||
}
|
||||
@@ -10,25 +10,39 @@ import (
|
||||
|
||||
// ProjectItem wraps a project for bubbles/list.
|
||||
type ProjectItem struct {
|
||||
Project project.Project
|
||||
Active bool
|
||||
Project project.Project
|
||||
Active bool
|
||||
YouTrack bool // true when YouTrack sync is configured
|
||||
}
|
||||
|
||||
func (i ProjectItem) Title() string {
|
||||
if i.Active {
|
||||
return i.Project.Name + " *"
|
||||
name := i.Project.Name
|
||||
if i.YouTrack {
|
||||
name += " [YT]"
|
||||
}
|
||||
return i.Project.Name
|
||||
if i.Active {
|
||||
return name + " *"
|
||||
}
|
||||
return name
|
||||
}
|
||||
func (i ProjectItem) Description() string { return i.Project.ID }
|
||||
func (i ProjectItem) FilterValue() string { return i.Project.Name }
|
||||
|
||||
// NewProjectList builds a list of projects.
|
||||
func NewProjectList(projects []project.Project, activeID string, width, height int) list.Model {
|
||||
return NewProjectListWithYT(projects, activeID, nil, width, height)
|
||||
}
|
||||
|
||||
// NewProjectListWithYT builds a list, marking projects with YouTrack config.
|
||||
func NewProjectListWithYT(projects []project.Project, activeID string, ytConfigured map[string]bool, width, height int) list.Model {
|
||||
items := make([]list.Item, len(projects))
|
||||
selected := 0
|
||||
for i, p := range projects {
|
||||
items[i] = ProjectItem{Project: p, Active: p.ID == activeID}
|
||||
items[i] = ProjectItem{
|
||||
Project: p,
|
||||
Active: p.ID == activeID,
|
||||
YouTrack: ytConfigured[p.ID],
|
||||
}
|
||||
if p.ID == activeID {
|
||||
selected = i
|
||||
}
|
||||
@@ -77,9 +91,14 @@ func JumpProjectByLetter(l *list.Model, r rune) bool {
|
||||
|
||||
// ProjectsToItems converts projects to list items.
|
||||
func ProjectsToItems(projects []project.Project, activeID string) []list.Item {
|
||||
return ProjectsToItemsWithYT(projects, activeID, nil)
|
||||
}
|
||||
|
||||
// ProjectsToItemsWithYT converts projects to list items with YouTrack flags.
|
||||
func ProjectsToItemsWithYT(projects []project.Project, activeID string, ytConfigured map[string]bool) []list.Item {
|
||||
items := make([]list.Item, len(projects))
|
||||
for i, p := range projects {
|
||||
items[i] = ProjectItem{Project: p, Active: p.ID == activeID}
|
||||
items[i] = ProjectItem{Project: p, Active: p.ID == activeID, YouTrack: ytConfigured[p.ID]}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// SyncAction is a YouTrack sync menu choice.
|
||||
type SyncAction string
|
||||
|
||||
const (
|
||||
SyncActionPull SyncAction = "pull"
|
||||
SyncActionPush SyncAction = "push"
|
||||
)
|
||||
|
||||
// SyncOption is one row in the sync menu.
|
||||
type SyncOption struct {
|
||||
Action SyncAction
|
||||
Label string
|
||||
Shortcut rune
|
||||
}
|
||||
|
||||
// AllSyncOptions is the ordered sync menu.
|
||||
var AllSyncOptions = []SyncOption{
|
||||
{Action: SyncActionPull, Label: "Pull from YouTrack", Shortcut: 'p'},
|
||||
{Action: SyncActionPush, Label: "Push to YouTrack", Shortcut: 'u'},
|
||||
}
|
||||
|
||||
// SyncMenu renders a pull/push picker overlay.
|
||||
type SyncMenu struct {
|
||||
Cursor int
|
||||
Width int
|
||||
Styles StatusMenuStyles
|
||||
}
|
||||
|
||||
// NewSyncMenu creates a sync action menu.
|
||||
func NewSyncMenu(width int, styles StatusMenuStyles) SyncMenu {
|
||||
return SyncMenu{Cursor: 0, Width: width, Styles: styles}
|
||||
}
|
||||
|
||||
// MoveDown moves the selection down.
|
||||
func (m *SyncMenu) MoveDown() {
|
||||
if m.Cursor < len(AllSyncOptions)-1 {
|
||||
m.Cursor++
|
||||
}
|
||||
}
|
||||
|
||||
// MoveUp moves the selection up.
|
||||
func (m *SyncMenu) MoveUp() {
|
||||
if m.Cursor > 0 {
|
||||
m.Cursor--
|
||||
}
|
||||
}
|
||||
|
||||
// JumpToShortcut selects the option whose shortcut matches r.
|
||||
func (m *SyncMenu) JumpToShortcut(r rune) bool {
|
||||
r = unicode.ToLower(r)
|
||||
for i, opt := range AllSyncOptions {
|
||||
if opt.Shortcut == r {
|
||||
m.Cursor = i
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Selected returns the highlighted action.
|
||||
func (m SyncMenu) Selected() SyncAction {
|
||||
if m.Cursor < 0 || m.Cursor >= len(AllSyncOptions) {
|
||||
return SyncActionPull
|
||||
}
|
||||
return AllSyncOptions[m.Cursor].Action
|
||||
}
|
||||
|
||||
// View renders the sync menu.
|
||||
func (m SyncMenu) View() string {
|
||||
var lines []string
|
||||
lines = append(lines, m.Styles.Title.Render("YouTrack"))
|
||||
for i, opt := range AllSyncOptions {
|
||||
entry := fmt.Sprintf("%c %s", opt.Shortcut, opt.Label)
|
||||
if i == m.Cursor {
|
||||
lines = append(lines, m.Styles.Selected.Render("▸ "+entry))
|
||||
} else {
|
||||
lines = append(lines, m.Styles.Normal.Render(" "+entry))
|
||||
}
|
||||
}
|
||||
body := strings.Join(lines, "\n")
|
||||
w := m.Width
|
||||
if w < 20 {
|
||||
w = 20
|
||||
}
|
||||
return m.Styles.Box.Width(min(44, w-2)).Render(body)
|
||||
}
|
||||
@@ -176,8 +176,12 @@ func (l *TicketList) renderCard(t ticket.Ticket, selected bool) string {
|
||||
innerWidth := max(10, l.Width-4)
|
||||
|
||||
badge := l.statusBadge(t.Status)
|
||||
headline := truncateOneLine(plainHeadline(t.Title), max(8, innerWidth-lipgloss.Width(badge)-2))
|
||||
headerLine := badge + " " + l.Styles.Headline.Render(headline)
|
||||
ext := ""
|
||||
if t.ExternalID != "" {
|
||||
ext = lipgloss.NewStyle().Faint(true).Render(t.ExternalID) + " "
|
||||
}
|
||||
headline := truncateOneLine(plainHeadline(t.Title), max(8, innerWidth-lipgloss.Width(badge)-lipgloss.Width(ext)-2))
|
||||
headerLine := badge + " " + ext + l.Styles.Headline.Render(headline)
|
||||
|
||||
var content string
|
||||
if selected {
|
||||
|
||||
Reference in New Issue
Block a user