|
|
@@ -0,0 +1,346 @@
|
|
|
+package main
|
|
|
+
|
|
|
+import (
|
|
|
+ "fmt"
|
|
|
+ "io"
|
|
|
+
|
|
|
+ "github.com/charmbracelet/bubbles/key"
|
|
|
+ "github.com/charmbracelet/bubbles/list"
|
|
|
+ "github.com/charmbracelet/lipgloss"
|
|
|
+)
|
|
|
+
|
|
|
+// Constants for UI dimensions
|
|
|
+const (
|
|
|
+ minEmailWidth = 50
|
|
|
+ maxEmailWidth = 70
|
|
|
+ minLoadingWidth = 40
|
|
|
+ maxLoadingWidth = 60
|
|
|
+ inputCharLimit = 256
|
|
|
+ inputWidth = 50
|
|
|
+ listHeightOffset = 2
|
|
|
+)
|
|
|
+
|
|
|
+// Color scheme
|
|
|
+var (
|
|
|
+ primaryColor = lipgloss.Color("205")
|
|
|
+ secondaryColor = lipgloss.Color("240")
|
|
|
+ successColor = lipgloss.Color("42")
|
|
|
+ subtleColor = lipgloss.Color("241")
|
|
|
+ borderColor = lipgloss.Color("236")
|
|
|
+ errorColor = lipgloss.Color("196")
|
|
|
+)
|
|
|
+
|
|
|
+// UI style definitions grouped by purpose
|
|
|
+var (
|
|
|
+ // Text styles
|
|
|
+ titleStyle = lipgloss.NewStyle().
|
|
|
+ Bold(true).
|
|
|
+ Foreground(primaryColor).
|
|
|
+ MarginBottom(1)
|
|
|
+
|
|
|
+ labelStyle = lipgloss.NewStyle().
|
|
|
+ Foreground(secondaryColor).
|
|
|
+ MarginRight(1)
|
|
|
+
|
|
|
+ helpTextStyle = lipgloss.NewStyle().
|
|
|
+ Foreground(subtleColor).
|
|
|
+ Italic(true).
|
|
|
+ MarginTop(1)
|
|
|
+
|
|
|
+ loadingStyle = lipgloss.NewStyle().
|
|
|
+ Foreground(primaryColor).
|
|
|
+ Margin(1, 0)
|
|
|
+
|
|
|
+ errorStyle = lipgloss.NewStyle().
|
|
|
+ Foreground(errorColor).
|
|
|
+ Bold(true)
|
|
|
+
|
|
|
+ // Container styles
|
|
|
+
|
|
|
+ containerStyle = lipgloss.NewStyle().
|
|
|
+ Border(lipgloss.RoundedBorder()).
|
|
|
+ BorderForeground(borderColor).
|
|
|
+ Background(lipgloss.NoColor{}).
|
|
|
+ Padding(1, 2).
|
|
|
+ Margin(1, 0)
|
|
|
+
|
|
|
+ // Checkbox styles
|
|
|
+ checkboxStyle = lipgloss.NewStyle().
|
|
|
+ Foreground(primaryColor).
|
|
|
+ MarginRight(1)
|
|
|
+
|
|
|
+ checkboxCheckedStyle = lipgloss.NewStyle().
|
|
|
+ Foreground(successColor).
|
|
|
+ MarginRight(1)
|
|
|
+
|
|
|
+ // Status styles
|
|
|
+ statusConnectedStyle = lipgloss.NewStyle().
|
|
|
+ Foreground(successColor).
|
|
|
+ Bold(true)
|
|
|
+
|
|
|
+ statusDisconnectedStyle = lipgloss.NewStyle().
|
|
|
+ Foreground(subtleColor)
|
|
|
+)
|
|
|
+
|
|
|
+// listKeyMap defines custom key bindings for the list view.
|
|
|
+type listKeyMap struct {
|
|
|
+ connect key.Binding
|
|
|
+ disconnect key.Binding
|
|
|
+ disconnAll key.Binding
|
|
|
+ refresh key.Binding
|
|
|
+}
|
|
|
+
|
|
|
+var customKeys = listKeyMap{
|
|
|
+ connect: key.NewBinding(
|
|
|
+ key.WithKeys("enter"),
|
|
|
+ key.WithHelp("enter", "connect"),
|
|
|
+ ),
|
|
|
+ disconnect: key.NewBinding(
|
|
|
+ key.WithKeys("d"),
|
|
|
+ key.WithHelp("d", "disconnect"),
|
|
|
+ ),
|
|
|
+ disconnAll: key.NewBinding(
|
|
|
+ key.WithKeys("ctrl+x"),
|
|
|
+ key.WithHelp("ctrl+x", "disconnect all"),
|
|
|
+ ),
|
|
|
+ refresh: key.NewBinding(
|
|
|
+ key.WithKeys("r"),
|
|
|
+ key.WithHelp("r", "refresh"),
|
|
|
+ ),
|
|
|
+}
|
|
|
+
|
|
|
+// item represents a connection item in the list view.
|
|
|
+type item struct {
|
|
|
+ name string
|
|
|
+ address string
|
|
|
+ connected bool
|
|
|
+ connType string
|
|
|
+}
|
|
|
+
|
|
|
+// Title returns the display title for the item, with a bullet indicator if connected.
|
|
|
+func (i item) Title() string {
|
|
|
+ if i.connected {
|
|
|
+ return "● " + i.name
|
|
|
+ }
|
|
|
+ return i.name
|
|
|
+}
|
|
|
+
|
|
|
+// Description returns the formatted description showing the address and connection status.
|
|
|
+func (i item) Description() string {
|
|
|
+ addressStyle := statusDisconnectedStyle
|
|
|
+ prefix := ""
|
|
|
+ if i.connType != "" {
|
|
|
+ prefix = "[" + i.connType + "] "
|
|
|
+ }
|
|
|
+ if i.connected {
|
|
|
+ addressStyle = statusConnectedStyle
|
|
|
+ return addressStyle.Render(prefix+i.address) + " " + statusConnectedStyle.Render("• Connected")
|
|
|
+ }
|
|
|
+ return addressStyle.Render(prefix + i.address)
|
|
|
+}
|
|
|
+
|
|
|
+// FilterValue returns the value used for filtering items in the list.
|
|
|
+func (i item) FilterValue() string { return i.name }
|
|
|
+
|
|
|
+// customDelegate provides custom rendering for list items with connection status styling.
|
|
|
+type customDelegate struct {
|
|
|
+ list.DefaultDelegate
|
|
|
+}
|
|
|
+
|
|
|
+// Render renders a list item with custom styling based on selection and connection status.
|
|
|
+func (d customDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
|
|
|
+ itm, ok := listItem.(item)
|
|
|
+ if !ok {
|
|
|
+ d.DefaultDelegate.Render(w, m, index, listItem)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ isSelected := index == m.Index()
|
|
|
+ titleStyle := d.getTitleStyle(isSelected, itm.connected)
|
|
|
+ title := titleStyle.Render(itm.Title())
|
|
|
+
|
|
|
+ descStyle := d.Styles.NormalDesc
|
|
|
+ if isSelected {
|
|
|
+ descStyle = d.Styles.SelectedDesc
|
|
|
+ }
|
|
|
+ desc := descStyle.Render(itm.Description())
|
|
|
+
|
|
|
+ fmt.Fprint(w, lipgloss.JoinVertical(lipgloss.Left, title, desc))
|
|
|
+}
|
|
|
+
|
|
|
+// getTitleStyle returns the appropriate title style based on selection and connection status.
|
|
|
+func (d customDelegate) getTitleStyle(isSelected, isConnected bool) lipgloss.Style {
|
|
|
+ baseStyle := lipgloss.NewStyle().
|
|
|
+ Background(lipgloss.NoColor{}).
|
|
|
+ Padding(0, 0, 0, 1)
|
|
|
+
|
|
|
+ if isSelected {
|
|
|
+ selectedStyle := d.Styles.SelectedTitle.
|
|
|
+ Border(lipgloss.NormalBorder(), false, false, false, true).
|
|
|
+ BorderForeground(primaryColor).
|
|
|
+ Bold(true).
|
|
|
+ Background(lipgloss.NoColor{}).
|
|
|
+ Padding(0, 0, 0, 1)
|
|
|
+
|
|
|
+ if isConnected {
|
|
|
+ return selectedStyle.Foreground(successColor)
|
|
|
+ }
|
|
|
+ return selectedStyle.Foreground(primaryColor)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Normal (unselected) item
|
|
|
+ if isConnected {
|
|
|
+ return baseStyle.
|
|
|
+ Foreground(successColor).
|
|
|
+ Bold(true)
|
|
|
+ }
|
|
|
+ return baseStyle.Foreground(lipgloss.Color("252"))
|
|
|
+}
|
|
|
+
|
|
|
+// newList creates and configures a new list model with custom styling.
|
|
|
+func newList(items []list.Item, width, height int) list.Model {
|
|
|
+ delegate := customDelegate{DefaultDelegate: list.NewDefaultDelegate()}
|
|
|
+ delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.
|
|
|
+ Border(lipgloss.NormalBorder(), false, false, false, true).
|
|
|
+ BorderForeground(primaryColor).
|
|
|
+ Foreground(primaryColor).
|
|
|
+ Bold(true).
|
|
|
+ Background(lipgloss.NoColor{}).
|
|
|
+ Padding(0, 0, 0, 1)
|
|
|
+ delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.
|
|
|
+ Border(lipgloss.NormalBorder(), false, false, false, true).
|
|
|
+ BorderForeground(primaryColor).
|
|
|
+ Foreground(secondaryColor).
|
|
|
+ Background(lipgloss.NoColor{}).
|
|
|
+ Padding(0, 0, 0, 1)
|
|
|
+ delegate.Styles.NormalDesc = delegate.Styles.NormalDesc.
|
|
|
+ Foreground(subtleColor).
|
|
|
+ Background(lipgloss.NoColor{}).
|
|
|
+ Padding(0, 0, 0, 1)
|
|
|
+
|
|
|
+ l := list.New(items, delegate, width, height)
|
|
|
+ l.Title = ""
|
|
|
+ l.SetShowStatusBar(false)
|
|
|
+ l.SetShowPagination(false)
|
|
|
+ l.SetFilteringEnabled(true)
|
|
|
+ l.SetShowHelp(true)
|
|
|
+ l.AdditionalShortHelpKeys = func() []key.Binding {
|
|
|
+ return []key.Binding{
|
|
|
+ customKeys.connect,
|
|
|
+ customKeys.disconnect,
|
|
|
+ customKeys.disconnAll,
|
|
|
+ customKeys.refresh,
|
|
|
+ }
|
|
|
+ }
|
|
|
+ l.Styles.StatusBar = lipgloss.NewStyle().
|
|
|
+ Foreground(subtleColor).
|
|
|
+ Background(lipgloss.NoColor{}).
|
|
|
+ MarginTop(1)
|
|
|
+ l.Styles.Title = lipgloss.NewStyle().
|
|
|
+ Background(lipgloss.NoColor{})
|
|
|
+ l.Styles.FilterPrompt = lipgloss.NewStyle().
|
|
|
+ Background(lipgloss.NoColor{})
|
|
|
+ l.Styles.FilterCursor = lipgloss.NewStyle().
|
|
|
+ Background(lipgloss.NoColor{})
|
|
|
+ return l
|
|
|
+}
|
|
|
+
|
|
|
+// isQuitKey checks if the given key string represents a quit command.
|
|
|
+func isQuitKey(key string) bool {
|
|
|
+ return key == "q" || key == "ctrl+c" || key == "esc"
|
|
|
+}
|
|
|
+
|
|
|
+// renderEmailPrompt renders the email authentication prompt view.
|
|
|
+func renderEmailPrompt(m model) string {
|
|
|
+ if m.loggingIn {
|
|
|
+ title := titleStyle.Render("Authenticating...")
|
|
|
+ spinnerText := loadingStyle.Render(m.spinner.View() + " Opening browser for authentication...")
|
|
|
+ content := lipgloss.JoinVertical(lipgloss.Center,
|
|
|
+ "",
|
|
|
+ title,
|
|
|
+ "",
|
|
|
+ spinnerText,
|
|
|
+ "",
|
|
|
+ )
|
|
|
+
|
|
|
+ width := clampWidth(m.width, minLoadingWidth, maxLoadingWidth)
|
|
|
+ return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
|
|
|
+ containerStyle.Width(width).Align(lipgloss.Center).Render(content))
|
|
|
+ }
|
|
|
+
|
|
|
+ checkbox := checkboxStyle.Render("[ ]")
|
|
|
+ if m.rememberEmail {
|
|
|
+ checkbox = checkboxCheckedStyle.Render("[x]")
|
|
|
+ }
|
|
|
+
|
|
|
+ title := titleStyle.Render("Authentication Required")
|
|
|
+ message := labelStyle.Render("You are not authenticated. Please login.")
|
|
|
+ emailLabel := labelStyle.Render("Email:")
|
|
|
+ emailInput := m.emailInput.View()
|
|
|
+ rememberText := checkbox + labelStyle.Render("Remember email (press Space to toggle)")
|
|
|
+ helpText := helpTextStyle.Render("Press Enter to login, Esc to quit")
|
|
|
+
|
|
|
+ content := lipgloss.JoinVertical(lipgloss.Left,
|
|
|
+ title,
|
|
|
+ "",
|
|
|
+ message,
|
|
|
+ "",
|
|
|
+ emailLabel+emailInput,
|
|
|
+ "",
|
|
|
+ rememberText,
|
|
|
+ "",
|
|
|
+ helpText,
|
|
|
+ )
|
|
|
+
|
|
|
+ width := clampWidth(m.width, minEmailWidth, maxEmailWidth)
|
|
|
+ return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
|
|
|
+ containerStyle.Width(width).Render(content))
|
|
|
+}
|
|
|
+
|
|
|
+// renderLoading renders the loading spinner view.
|
|
|
+func renderLoading(m model) string {
|
|
|
+ title := titleStyle.Render("Loading Connections")
|
|
|
+ spinnerText := loadingStyle.Render(m.spinner.View() + " Loading connections...")
|
|
|
+ content := lipgloss.JoinVertical(lipgloss.Center,
|
|
|
+ "",
|
|
|
+ title,
|
|
|
+ "",
|
|
|
+ spinnerText,
|
|
|
+ "",
|
|
|
+ )
|
|
|
+
|
|
|
+ width := clampWidth(m.width, minLoadingWidth, maxLoadingWidth)
|
|
|
+ return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
|
|
|
+ containerStyle.Width(width).Align(lipgloss.Center).Render(content))
|
|
|
+}
|
|
|
+
|
|
|
+// renderError renders the error view with retry and quit options.
|
|
|
+func renderError(m model) string {
|
|
|
+ title := titleStyle.Render("Error")
|
|
|
+ errText := errorStyle.Render(m.err)
|
|
|
+ helpText := helpTextStyle.Render("Press r to retry, q/Esc to quit")
|
|
|
+ content := lipgloss.JoinVertical(lipgloss.Center,
|
|
|
+ "",
|
|
|
+ title,
|
|
|
+ "",
|
|
|
+ errText,
|
|
|
+ "",
|
|
|
+ helpText,
|
|
|
+ "",
|
|
|
+ )
|
|
|
+
|
|
|
+ width := clampWidth(m.width, minLoadingWidth, maxLoadingWidth)
|
|
|
+ return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
|
|
|
+ containerStyle.Width(width).Align(lipgloss.Center).Render(content))
|
|
|
+}
|
|
|
+
|
|
|
+// clampWidth constrains the width value between min and max.
|
|
|
+func clampWidth(width, min, max int) int {
|
|
|
+ if width > max {
|
|
|
+ return max
|
|
|
+ }
|
|
|
+ if width < min {
|
|
|
+ return min
|
|
|
+ }
|
|
|
+ return width
|
|
|
+}
|