package tui import ( "fmt" "strconv" "strings" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // editableField represents an editable field on an auth file. type editableField struct { label string key string // API field key: "prefix", "proxy_url", "priority" } var authEditableFields = []editableField{ {label: "Prefix", key: "prefix"}, {label: "Proxy URL", key: "proxy_url"}, {label: "Priority", key: "priority"}, } // authTabModel displays auth credential files with interactive management. type authTabModel struct { client *Client viewport viewport.Model files []map[string]any err error width int height int ready bool cursor int expanded int // -1 = none expanded, >=0 = expanded index confirm int // -1 = no confirmation, >=0 = confirm delete for index status string // Editing state editing bool // true when editing a field editField int // index into authEditableFields editInput textinput.Model // text input for editing editFileName string // name of file being edited } type authFilesMsg struct { files []map[string]any err error } type authActionMsg struct { action string // "deleted", "toggled", "updated" err error } func newAuthTabModel(client *Client) authTabModel { ti := textinput.New() ti.CharLimit = 256 return authTabModel{ client: client, expanded: -1, confirm: -1, editInput: ti, } } func (m authTabModel) Init() tea.Cmd { return m.fetchFiles } func (m authTabModel) fetchFiles() tea.Msg { files, err := m.client.GetAuthFiles() return authFilesMsg{files: files, err: err} } func (m authTabModel) Update(msg tea.Msg) (authTabModel, tea.Cmd) { switch msg := msg.(type) { case authFilesMsg: if msg.err != nil { m.err = msg.err } else { m.err = nil m.files = msg.files if m.cursor >= len(m.files) { m.cursor = max(0, len(m.files)-1) } m.status = "" } m.viewport.SetContent(m.renderContent()) return m, nil case authActionMsg: if msg.err != nil { m.status = errorStyle.Render("✗ " + msg.err.Error()) } else { m.status = successStyle.Render("✓ " + msg.action) } m.confirm = -1 m.viewport.SetContent(m.renderContent()) return m, m.fetchFiles case tea.KeyMsg: // ---- Editing mode ---- if m.editing { switch msg.String() { case "enter": value := m.editInput.Value() fieldKey := authEditableFields[m.editField].key fileName := m.editFileName m.editing = false m.editInput.Blur() fields := map[string]any{} if fieldKey == "priority" { p, _ := strconv.Atoi(value) fields[fieldKey] = p } else { fields[fieldKey] = value } return m, func() tea.Msg { err := m.client.PatchAuthFileFields(fileName, fields) if err != nil { return authActionMsg{err: err} } return authActionMsg{action: fmt.Sprintf("Updated %s on %s", fieldKey, fileName)} } case "esc": m.editing = false m.editInput.Blur() m.viewport.SetContent(m.renderContent()) return m, nil default: var cmd tea.Cmd m.editInput, cmd = m.editInput.Update(msg) m.viewport.SetContent(m.renderContent()) return m, cmd } } // ---- Delete confirmation mode ---- if m.confirm >= 0 { switch msg.String() { case "y", "Y": idx := m.confirm m.confirm = -1 if idx < len(m.files) { name := getString(m.files[idx], "name") return m, func() tea.Msg { err := m.client.DeleteAuthFile(name) if err != nil { return authActionMsg{err: err} } return authActionMsg{action: fmt.Sprintf("Deleted %s", name)} } } m.viewport.SetContent(m.renderContent()) return m, nil case "n", "N", "esc": m.confirm = -1 m.viewport.SetContent(m.renderContent()) return m, nil } return m, nil } // ---- Normal mode ---- switch msg.String() { case "j", "down": if len(m.files) > 0 { m.cursor = (m.cursor + 1) % len(m.files) m.viewport.SetContent(m.renderContent()) } return m, nil case "k", "up": if len(m.files) > 0 { m.cursor = (m.cursor - 1 + len(m.files)) % len(m.files) m.viewport.SetContent(m.renderContent()) } return m, nil case "enter", " ": if m.expanded == m.cursor { m.expanded = -1 } else { m.expanded = m.cursor } m.viewport.SetContent(m.renderContent()) return m, nil case "d", "D": if m.cursor < len(m.files) { m.confirm = m.cursor m.viewport.SetContent(m.renderContent()) } return m, nil case "e", "E": if m.cursor < len(m.files) { f := m.files[m.cursor] name := getString(f, "name") disabled := getBool(f, "disabled") newDisabled := !disabled return m, func() tea.Msg { err := m.client.ToggleAuthFile(name, newDisabled) if err != nil { return authActionMsg{err: err} } action := "Enabled" if newDisabled { action = "Disabled" } return authActionMsg{action: fmt.Sprintf("%s %s", action, name)} } } return m, nil case "1": return m, m.startEdit(0) // prefix case "2": return m, m.startEdit(1) // proxy_url case "3": return m, m.startEdit(2) // priority case "r": m.status = "" return m, m.fetchFiles default: var cmd tea.Cmd m.viewport, cmd = m.viewport.Update(msg) return m, cmd } } var cmd tea.Cmd m.viewport, cmd = m.viewport.Update(msg) return m, cmd } // startEdit activates inline editing for a field on the currently selected auth file. func (m *authTabModel) startEdit(fieldIdx int) tea.Cmd { if m.cursor >= len(m.files) { return nil } f := m.files[m.cursor] m.editFileName = getString(f, "name") m.editField = fieldIdx m.editing = true // Pre-populate with current value key := authEditableFields[fieldIdx].key currentVal := getAnyString(f, key) m.editInput.SetValue(currentVal) m.editInput.Focus() m.editInput.Prompt = fmt.Sprintf(" %s: ", authEditableFields[fieldIdx].label) m.viewport.SetContent(m.renderContent()) return textinput.Blink } func (m *authTabModel) SetSize(w, h int) { m.width = w m.height = h m.editInput.Width = w - 20 if !m.ready { m.viewport = viewport.New(w, h) m.viewport.SetContent(m.renderContent()) m.ready = true } else { m.viewport.Width = w m.viewport.Height = h } } func (m authTabModel) View() string { if !m.ready { return "Loading..." } return m.viewport.View() } func (m authTabModel) renderContent() string { var sb strings.Builder sb.WriteString(titleStyle.Render("🔑 Auth Files")) sb.WriteString("\n") sb.WriteString(helpStyle.Render(" [↑↓/jk] navigate • [Enter] expand • [e] enable/disable • [d] delete • [r] refresh")) sb.WriteString("\n") sb.WriteString(helpStyle.Render(" [1] edit prefix • [2] edit proxy_url • [3] edit priority")) sb.WriteString("\n") sb.WriteString(strings.Repeat("─", m.width)) sb.WriteString("\n") if m.err != nil { sb.WriteString(errorStyle.Render("⚠ Error: " + m.err.Error())) sb.WriteString("\n") return sb.String() } if len(m.files) == 0 { sb.WriteString(subtitleStyle.Render("\n No auth files found")) sb.WriteString("\n") return sb.String() } for i, f := range m.files { name := getString(f, "name") channel := getString(f, "channel") email := getString(f, "email") disabled := getBool(f, "disabled") statusIcon := successStyle.Render("●") statusText := "active" if disabled { statusIcon = lipgloss.NewStyle().Foreground(colorMuted).Render("○") statusText = "disabled" } cursor := " " rowStyle := lipgloss.NewStyle() if i == m.cursor { cursor = "▸ " rowStyle = lipgloss.NewStyle().Bold(true) } displayName := name if len(displayName) > 24 { displayName = displayName[:21] + "..." } displayEmail := email if len(displayEmail) > 28 { displayEmail = displayEmail[:25] + "..." } row := fmt.Sprintf("%s%s %-24s %-12s %-28s %s", cursor, statusIcon, displayName, channel, displayEmail, statusText) sb.WriteString(rowStyle.Render(row)) sb.WriteString("\n") // Delete confirmation if m.confirm == i { sb.WriteString(warningStyle.Render(fmt.Sprintf(" ⚠ Delete %s? [y/n] ", name))) sb.WriteString("\n") } // Inline edit input if m.editing && i == m.cursor { sb.WriteString(m.editInput.View()) sb.WriteString("\n") sb.WriteString(helpStyle.Render(" Enter: save • Esc: cancel")) sb.WriteString("\n") } // Expanded detail view if m.expanded == i { sb.WriteString(m.renderDetail(f)) } } if m.status != "" { sb.WriteString("\n") sb.WriteString(m.status) sb.WriteString("\n") } return sb.String() } func (m authTabModel) renderDetail(f map[string]any) string { var sb strings.Builder labelStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("111")). Bold(true) valueStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("252")) editableMarker := lipgloss.NewStyle(). Foreground(lipgloss.Color("214")). Render(" ✎") sb.WriteString(" ┌─────────────────────────────────────────────\n") fields := []struct { label string key string editable bool }{ {"Name", "name", false}, {"Channel", "channel", false}, {"Email", "email", false}, {"Status", "status", false}, {"Status Msg", "status_message", false}, {"File Name", "file_name", false}, {"Auth Type", "auth_type", false}, {"Prefix", "prefix", true}, {"Proxy URL", "proxy_url", true}, {"Priority", "priority", true}, {"Project ID", "project_id", false}, {"Disabled", "disabled", false}, {"Created", "created_at", false}, {"Updated", "updated_at", false}, } for _, field := range fields { val := getAnyString(f, field.key) if val == "" || val == "" { if field.editable { val = "(not set)" } else { continue } } editMark := "" if field.editable { editMark = editableMarker } line := fmt.Sprintf(" │ %s %s%s", labelStyle.Render(fmt.Sprintf("%-12s:", field.label)), valueStyle.Render(val), editMark) sb.WriteString(line) sb.WriteString("\n") } sb.WriteString(" └─────────────────────────────────────────────\n") return sb.String() } // getAnyString converts any value to its string representation. func getAnyString(m map[string]any, key string) string { v, ok := m[key] if !ok || v == nil { return "" } return fmt.Sprintf("%v", v) } func max(a, b int) int { if a > b { return a } return b }