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() }