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:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user