Introduces the web backend (auth, RBAC, project tokens, revisions), YouTrack-or-taskmaster backend choice in the TUI, and a first-pass embedded Svelte UI. The frontend is functional but needs a major rework. Co-authored-by: Cursor <cursoragent@cursor.com>
336 lines
9.1 KiB
Go
336 lines
9.1 KiB
Go
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 (backend picker is separate).
|
|
const (
|
|
FieldRemoteURL = iota
|
|
FieldToken
|
|
FieldProjectKey
|
|
ProjectEditFieldCount
|
|
)
|
|
|
|
var backendOptions = []string{
|
|
project.BackendNone,
|
|
project.BackendYouTrack,
|
|
project.BackendTaskMaster,
|
|
}
|
|
|
|
// 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 sync backend settings.
|
|
type ProjectEditForm struct {
|
|
ProjectID string
|
|
Backend string
|
|
BackendIdx int
|
|
FocusBackend bool // true when selecting backend
|
|
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
|
|
}
|
|
|
|
backend := meta.EffectiveBackend()
|
|
if backend == "" {
|
|
backend = project.BackendNone
|
|
}
|
|
bIdx := 0
|
|
for i, b := range backendOptions {
|
|
if b == backend {
|
|
bIdx = i
|
|
break
|
|
}
|
|
}
|
|
|
|
hadToken := false
|
|
switch backend {
|
|
case project.BackendTaskMaster:
|
|
inputs[FieldRemoteURL] = mk("http://127.0.0.1:8080", 256, false)
|
|
inputs[FieldToken] = mk("tm_… project token", 512, true)
|
|
inputs[FieldProjectKey] = mk("remote project UUID", 64, false)
|
|
if meta.TaskMaster != nil {
|
|
tm := meta.TaskMaster
|
|
inputs[FieldRemoteURL].SetValue(tm.BaseURL)
|
|
inputs[FieldProjectKey].SetValue(tm.ProjectID)
|
|
if tm.Token != "" {
|
|
hadToken = true
|
|
inputs[FieldToken].Placeholder = "(unchanged — type to replace)"
|
|
}
|
|
}
|
|
default:
|
|
inputs[FieldRemoteURL] = mk("https://youtrack.example.com", 256, false)
|
|
inputs[FieldToken] = mk("permanent token", 512, true)
|
|
inputs[FieldProjectKey] = mk("PROJ", 64, 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,
|
|
Backend: backend,
|
|
BackendIdx: bIdx,
|
|
FocusBackend: true,
|
|
Inputs: inputs,
|
|
Focus: FieldRemoteURL,
|
|
Styles: styles,
|
|
Width: width,
|
|
hadToken: hadToken,
|
|
}
|
|
return f
|
|
}
|
|
|
|
func (f *ProjectEditForm) applyBackendPlaceholders() {
|
|
f.Backend = backendOptions[f.BackendIdx]
|
|
switch f.Backend {
|
|
case project.BackendTaskMaster:
|
|
f.Inputs[FieldRemoteURL].Placeholder = "http://127.0.0.1:8080"
|
|
f.Inputs[FieldToken].Placeholder = "tm_… project token"
|
|
f.Inputs[FieldProjectKey].Placeholder = "remote project UUID"
|
|
case project.BackendYouTrack:
|
|
f.Inputs[FieldRemoteURL].Placeholder = "https://youtrack.example.com"
|
|
f.Inputs[FieldToken].Placeholder = "permanent token"
|
|
f.Inputs[FieldProjectKey].Placeholder = "PROJ"
|
|
default:
|
|
f.Inputs[FieldRemoteURL].Placeholder = "(unused)"
|
|
f.Inputs[FieldToken].Placeholder = "(unused)"
|
|
f.Inputs[FieldProjectKey].Placeholder = "(unused)"
|
|
}
|
|
}
|
|
|
|
func (f *ProjectEditForm) focusIndex(i int) {
|
|
f.FocusBackend = false
|
|
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() {
|
|
if f.FocusBackend {
|
|
if f.Backend == project.BackendNone {
|
|
return
|
|
}
|
|
f.focusIndex(FieldRemoteURL)
|
|
return
|
|
}
|
|
f.focusIndex((f.Focus + 1) % ProjectEditFieldCount)
|
|
}
|
|
|
|
// PrevField moves focus to the previous field.
|
|
func (f *ProjectEditForm) PrevField() {
|
|
if f.FocusBackend {
|
|
return
|
|
}
|
|
if f.Focus == FieldRemoteURL {
|
|
f.blurInputs()
|
|
f.FocusBackend = true
|
|
return
|
|
}
|
|
f.focusIndex((f.Focus - 1 + ProjectEditFieldCount) % ProjectEditFieldCount)
|
|
}
|
|
|
|
func (f *ProjectEditForm) blurInputs() {
|
|
for idx := range f.Inputs {
|
|
f.Inputs[idx].Blur()
|
|
f.Inputs[idx].PromptStyle = f.Styles.Blurred
|
|
f.Inputs[idx].TextStyle = f.Styles.Blurred
|
|
}
|
|
}
|
|
|
|
// CycleBackend changes the selected backend when focusing the picker.
|
|
func (f *ProjectEditForm) CycleBackend(delta int) {
|
|
if !f.FocusBackend {
|
|
return
|
|
}
|
|
n := len(backendOptions)
|
|
f.BackendIdx = (f.BackendIdx + delta + n) % n
|
|
f.applyBackendPlaceholders()
|
|
}
|
|
|
|
// Update forwards a message to the focused text input.
|
|
func (f *ProjectEditForm) Update(msg tea.Msg) tea.Cmd {
|
|
if f.FocusBackend || f.Backend == project.BackendNone {
|
|
return nil
|
|
}
|
|
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 {
|
|
backend := backendOptions[f.BackendIdx]
|
|
baseURL := strings.TrimSpace(f.Inputs[FieldRemoteURL].Value())
|
|
token := strings.TrimSpace(f.Inputs[FieldToken].Value())
|
|
key := strings.TrimSpace(f.Inputs[FieldProjectKey].Value())
|
|
|
|
switch backend {
|
|
case project.BackendNone:
|
|
return project.Meta{Backend: project.BackendNone}
|
|
case project.BackendTaskMaster:
|
|
tm := &project.TaskMaster{BaseURL: baseURL, ProjectID: key, Token: token}
|
|
if token == "" && previous.TaskMaster != nil {
|
|
tm.Token = previous.TaskMaster.Token
|
|
}
|
|
if baseURL == "" && token == "" && key == "" {
|
|
return project.Meta{Backend: project.BackendNone}
|
|
}
|
|
return project.Meta{Backend: project.BackendTaskMaster, TaskMaster: tm}
|
|
default:
|
|
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
|
|
}
|
|
if baseURL == "" && token == "" && key == "" {
|
|
return project.Meta{Backend: project.BackendNone}
|
|
}
|
|
return project.Meta{Backend: project.BackendYouTrack, YouTrack: yt}
|
|
}
|
|
}
|
|
|
|
// Validate ensures partial configs are rejected.
|
|
func (f ProjectEditForm) Validate(previous project.Meta) error {
|
|
meta := f.Meta(previous)
|
|
switch meta.EffectiveBackend() {
|
|
case project.BackendNone:
|
|
return nil
|
|
case project.BackendTaskMaster:
|
|
tm := meta.TaskMaster
|
|
if tm == nil {
|
|
return nil
|
|
}
|
|
if !tm.Configured() {
|
|
return fmt.Errorf("incomplete taskmaster config: need base URL, token, project id")
|
|
}
|
|
return nil
|
|
default:
|
|
yt := meta.YouTrack
|
|
if yt == nil {
|
|
return nil
|
|
}
|
|
if !yt.Configured() {
|
|
return fmt.Errorf("incomplete YouTrack config: need remote URL, token, project key")
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// View renders the form.
|
|
func (f ProjectEditForm) View() string {
|
|
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("Sync backend · ←/→ change · tab fields · enter save · esc cancel"))
|
|
b.WriteString("\n\n")
|
|
|
|
backendLine := "Backend: "
|
|
for i, opt := range backendOptions {
|
|
label := opt
|
|
if i == f.BackendIdx {
|
|
if f.FocusBackend {
|
|
label = f.Styles.Focused.Render("[" + opt + "]")
|
|
} else {
|
|
label = f.Styles.Focused.Render(opt)
|
|
}
|
|
} else {
|
|
label = f.Styles.Blurred.Render(opt)
|
|
}
|
|
if i > 0 {
|
|
backendLine += " "
|
|
}
|
|
backendLine += label
|
|
}
|
|
b.WriteString(backendLine)
|
|
b.WriteString("\n\n")
|
|
|
|
if f.Backend == project.BackendNone {
|
|
b.WriteString(f.Styles.Hint.Render("No remote sync for this project."))
|
|
b.WriteString("\n")
|
|
return b.String()
|
|
}
|
|
|
|
labels := []string{"Remote URL", "Token", "Project key"}
|
|
if f.Backend == project.BackendTaskMaster {
|
|
labels = []string{"Server URL", "Project token", "Remote project ID"}
|
|
}
|
|
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()
|
|
}
|