diff options
Diffstat (limited to 'components/root')
| -rw-r--r-- | components/root/connection.go | 465 | ||||
| -rw-r--r-- | components/root/main.go | 41 | ||||
| -rw-r--r-- | components/root/sidebar.go | 16 | ||||
| -rw-r--r-- | components/root/statusbar.go | 41 |
4 files changed, 563 insertions, 0 deletions
diff --git a/components/root/connection.go b/components/root/connection.go new file mode 100644 index 0000000..a610233 --- /dev/null +++ b/components/root/connection.go @@ -0,0 +1,465 @@ +package root + +import ( + "nectar/components/shared" + "nectar/types" + "nectar/utils" + "strings" + + catppuccin "github.com/catppuccin/go" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type ConnectionFormModel struct { + connection types.Connection + inputs []textinput.Model + filePicker shared.FilePickerModel + focused int + editing bool + showFilePicker bool + selectedColor int +} + +func NewConnectionForm() ConnectionFormModel { + // Initialize text inputs for all form fields + inputs := make([]textinput.Model, 5) + + // Host input + inputs[utils.InputHost] = textinput.New() + inputs[utils.InputHost].Placeholder = "localhost" + inputs[utils.InputHost].CharLimit = 255 + inputs[utils.InputHost].Width = 40 + + // Port input + inputs[utils.InputPort] = textinput.New() + inputs[utils.InputPort].Placeholder = utils.GetDefaultPort(types.PostgreSQL) + inputs[utils.InputPort].CharLimit = 6 + inputs[utils.InputPort].Width = 10 + + // User input + inputs[utils.InputUser] = textinput.New() + inputs[utils.InputUser].Placeholder = "username" + inputs[utils.InputUser].CharLimit = 100 + inputs[utils.InputUser].Width = 40 + + // Password input + inputs[utils.InputPassword] = textinput.New() + inputs[utils.InputPassword].Placeholder = "password" + inputs[utils.InputPassword].EchoMode = textinput.EchoPassword + inputs[utils.InputPassword].EchoCharacter = '•' + inputs[utils.InputPassword].CharLimit = 100 + inputs[utils.InputPassword].Width = 40 + + // Connection Name input + inputs[utils.InputConnectionName] = textinput.New() + inputs[utils.InputConnectionName].Placeholder = "Connection Name" + inputs[utils.InputConnectionName].CharLimit = 100 + inputs[utils.InputConnectionName].Width = 40 + + filePicker := shared.NewFilePicker() + + return ConnectionFormModel{ + connection: types.Connection{ + Type: types.PostgreSQL, + EnableSSL: false, + }, + inputs: inputs, + filePicker: filePicker, + focused: 0, + selectedColor: 0, + } +} + +func (m ConnectionFormModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m ConnectionFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if m.showFilePicker { + return m.handleFilePickerKeys(msg) + } + return m.handleFormKeys(msg) + } + + if m.showFilePicker { + var cmd tea.Cmd + m.filePicker, cmd = m.filePicker.Update(msg) + return m, cmd + } + + return m.updateInputs(msg) +} + +// Handle file picker navigation and disable sidebar keys +func (m ConnectionFormModel) handleFilePickerKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.showFilePicker = false + return m, nil + case "up", "down", "j", "k": + // Pass these keys to the file picker when it's active + var cmd tea.Cmd + m.filePicker, cmd = m.filePicker.Update(msg) + + // Check if a file was selected + if m.filePicker.SelectedFile() != "" { + m.connection.DatabaseFile = m.filePicker.SelectedFile() + m.showFilePicker = false + } + + return m, cmd + } + + // Pass all other keys to the file picker + var cmd tea.Cmd + m.filePicker, cmd = m.filePicker.Update(msg) + + if m.filePicker.SelectedFile() != "" { + m.connection.DatabaseFile = m.filePicker.SelectedFile() + m.showFilePicker = false + } + + return m, cmd +} + +// Handle form navigation and input +func (m ConnectionFormModel) handleFormKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.editing { + switch msg.String() { + case "enter", "esc", "tab", "shift+tab": + m.editing = false + m.blurAllInputs() + + if msg.String() == "tab" { + m.nextField() + } else if msg.String() == "shift+tab" { + m.prevField() + } + return m, nil + default: + // Handle text input in the focused field + for i, input := range m.inputs { + if input.Focused() { + var cmd tea.Cmd + m.inputs[i], cmd = input.Update(msg) + return m, cmd + } + } + } + } + + // Handle navigation and actions when not editing + switch msg.String() { + case "tab": + m.nextField() + case "shift+tab": + m.prevField() + case "enter": + return m.handleEnterKey() + case "left": + return m.handleLeftKey() + case "right": + return m.handleRightKey() + } + return m, nil +} + +// Utility methods for form navigation and state management +func (m ConnectionFormModel) getInputIndexFromFocus() int { + return utils.GetInputIndex(m.connection.Type, m.focused) +} + +func (m ConnectionFormModel) handleEnterKey() (tea.Model, tea.Cmd) { + // Handle database file selection for SQLite + if m.focused == utils.SQLiteFieldDatabaseFile && m.connection.Type == types.SQLite { + m.showFilePicker = true + return m, m.filePicker.Init() + } + + // Handle SSL toggle for non-SQLite databases + if m.focused == utils.FieldSSL && m.connection.Type != types.SQLite { + m.connection.EnableSSL = !m.connection.EnableSSL + return m, nil + } + + // Handle text input editing + inputIndex := m.getInputIndexFromFocus() + if inputIndex >= 0 { + if m.editing { + m.editing = false + m.inputs[inputIndex].Blur() + } else { + m.editing = true + m.inputs[inputIndex].Focus() + } + } + + return m, nil +} + +func (m ConnectionFormModel) handleLeftKey() (tea.Model, tea.Cmd) { + // Handle connection type navigation + if m.focused == utils.FieldConnectionType { + m.connection.Type = utils.PrevConnectionType(m.connection.Type) + m.updatePortPlaceholder() + } else if m.isColorField() { + // Handle color selection + if m.selectedColor > 0 { + m.selectedColor-- + } + } + return m, nil +} + +func (m ConnectionFormModel) handleRightKey() (tea.Model, tea.Cmd) { + // Handle connection type navigation + if m.focused == utils.FieldConnectionType { + m.connection.Type = utils.NextConnectionType(m.connection.Type) + m.updatePortPlaceholder() + } else if m.isColorField() { + // Handle color selection + if m.selectedColor < len(shared.ConnectionColors)-1 { + m.selectedColor++ + } + } + return m, nil +} + +// Helper method to check if current field is the color field +func (m ConnectionFormModel) isColorField() bool { + if m.connection.Type == types.SQLite { + return m.focused == utils.SQLiteFieldColor + } + return m.focused == utils.FieldColor +} + +// Update port placeholder based on connection type +func (m *ConnectionFormModel) updatePortPlaceholder() { + port := utils.GetDefaultPort(m.connection.Type) + if port != "" { + m.inputs[utils.InputPort].Placeholder = port + } +} + +// Navigation methods +func (m *ConnectionFormModel) nextField() { + m.blurAllInputs() + maxFields := utils.GetFieldCount(m.connection.Type) + m.focused = (m.focused + 1) % maxFields +} + +func (m *ConnectionFormModel) prevField() { + m.blurAllInputs() + m.focused-- + if m.focused < 0 { + maxFields := utils.GetFieldCount(m.connection.Type) + m.focused = maxFields - 1 + } +} + +func (m *ConnectionFormModel) blurAllInputs() { + for i := range m.inputs { + m.inputs[i].Blur() + } +} + +func (m ConnectionFormModel) updateInputs(msg tea.Msg) (tea.Model, tea.Cmd) { + cmds := make([]tea.Cmd, len(m.inputs)) + + for i := range m.inputs { + m.inputs[i], cmds[i] = m.inputs[i].Update(msg) + } + + return m, tea.Batch(cmds...) +} + +// View rendering methods +func (m ConnectionFormModel) View() string { + if m.showFilePicker { + return m.renderFilePicker() + } + return m.renderForm() +} + +func (m ConnectionFormModel) renderFilePicker() string { + return m.filePicker.View() +} + +func (m ConnectionFormModel) getEditingText() string { + if m.editing { + return "press Enter to finish editing" + } + return "press Enter to edit" +} + +func (m ConnectionFormModel) renderForm() string { + var content strings.Builder + + // Define consistent styles + titleStyle := lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{ + Light: catppuccin.Latte.Mauve().Hex, + Dark: catppuccin.Mocha.Mauve().Hex, + }). + Bold(true) + + labelStyle := lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{ + Light: catppuccin.Latte.Text().Hex, + Dark: catppuccin.Mocha.Text().Hex, + }) + + focusedStyle := lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{ + Light: catppuccin.Latte.Mauve().Hex, + Dark: catppuccin.Mocha.Mauve().Hex, + }) + + // Form title + content.WriteString(titleStyle.Render("New Connection") + "\n\n") + + // Connection Type selector + m.renderConnectionTypeField(&content, focusedStyle, labelStyle) + + // Database-specific fields + if m.connection.Type == types.SQLite { + m.renderSQLiteFields(&content, focusedStyle, labelStyle) + } else { + m.renderDatabaseServerFields(&content, focusedStyle, labelStyle) + } + + // Separator + content.WriteString("─────────────────────────────────────────\n\n") + + // Connection saving fields (common to all database types) + m.renderConnectionSavingFields(&content, focusedStyle, labelStyle) + + // Apply form styling with fixed width and border + formStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.AdaptiveColor{ + Light: catppuccin.Latte.Mauve().Hex, + Dark: catppuccin.Mocha.Mauve().Hex, + }). + Padding(2, 4). + Width(60) + + return formStyle.Render(content.String()) +} + +// Render the connection type selector field +func (m ConnectionFormModel) renderConnectionTypeField(content *strings.Builder, focusedStyle, labelStyle lipgloss.Style) { + connTypeLabel := "Connection Type: " + m.connection.Type.String() + if m.focused == utils.FieldConnectionType { + connTypeLabel = focusedStyle.Render("> " + connTypeLabel + " (use ← → to change)") + } else { + connTypeLabel = labelStyle.Render(" " + connTypeLabel) + } + content.WriteString(connTypeLabel + "\n\n") +} + +// Render SQLite-specific fields (database file selection) +func (m ConnectionFormModel) renderSQLiteFields(content *strings.Builder, focusedStyle, labelStyle lipgloss.Style) { + // Database File field + fileLabel := "Database File: " + if m.connection.DatabaseFile != "" { + fileLabel += m.connection.DatabaseFile + } else { + fileLabel += "No file selected" + } + + if m.focused == utils.SQLiteFieldDatabaseFile { + fileLabel = focusedStyle.Render("> " + fileLabel + " (press Enter to browse)") + } else { + fileLabel = labelStyle.Render(" " + fileLabel) + } + content.WriteString(fileLabel + "\n\n") +} + +// Render database server fields (PostgreSQL/MySQL) +func (m ConnectionFormModel) renderDatabaseServerFields(content *strings.Builder, focusedStyle, labelStyle lipgloss.Style) { + // Host field + m.renderInputField(content, "Host", utils.FieldHost, utils.InputHost, focusedStyle, labelStyle) + + // Port field + m.renderInputField(content, "Port", utils.FieldPort, utils.InputPort, focusedStyle, labelStyle) + + // SSL toggle field + m.renderSSLField(content, focusedStyle, labelStyle) + + // User field + m.renderInputField(content, "User", utils.FieldUser, utils.InputUser, focusedStyle, labelStyle) + + // Password field + m.renderInputField(content, "Password", utils.FieldPassword, utils.InputPassword, focusedStyle, labelStyle) +} + +// Helper to render a standard input field with label +func (m ConnectionFormModel) renderInputField(content *strings.Builder, fieldName string, fieldIndex, inputIndex int, focusedStyle, labelStyle lipgloss.Style) { + label := fieldName + ":" + if m.focused == fieldIndex { + label = focusedStyle.Render("> " + label + " (" + m.getEditingText() + ")") + } else { + label = labelStyle.Render(" " + label) + } + content.WriteString(label + "\n") + content.WriteString(" " + m.inputs[inputIndex].View() + "\n\n") +} + +// Render SSL toggle field for database servers +func (m ConnectionFormModel) renderSSLField(content *strings.Builder, focusedStyle, labelStyle lipgloss.Style) { + sslLabel := "Enable SSL: " + if m.connection.EnableSSL { + sslLabel += "✓ Yes" + } else { + sslLabel += "✗ No" + } + + if m.focused == utils.FieldSSL { + sslLabel = focusedStyle.Render("> " + sslLabel + " (press Enter to toggle)") + } else { + sslLabel = labelStyle.Render(" " + sslLabel) + } + content.WriteString(sslLabel + "\n\n") +} + +// Render connection saving fields (name and color) +func (m ConnectionFormModel) renderConnectionSavingFields(content *strings.Builder, focusedStyle, labelStyle lipgloss.Style) { + // Connection Name field - field index depends on database type + nameFieldIndex := utils.SQLiteFieldConnectionName + if m.connection.Type != types.SQLite { + nameFieldIndex = utils.FieldConnectionName + } + + saveNameLabel := "Save Connection:" + if m.focused == nameFieldIndex { + saveNameLabel = focusedStyle.Render("> " + saveNameLabel + " (" + m.getEditingText() + ")") + } else { + saveNameLabel = labelStyle.Render(" " + saveNameLabel) + } + content.WriteString(saveNameLabel + "\n") + content.WriteString(" " + m.inputs[utils.InputConnectionName].View() + "\n\n") + + // Color selection field - field index depends on database type + colorFieldIndex := utils.SQLiteFieldColor + if m.connection.Type != types.SQLite { + colorFieldIndex = utils.FieldColor + } + + colorLabel := "Color: " + shared.ConnectionColors[m.selectedColor].Name + if m.focused == colorFieldIndex { + colorLabel = focusedStyle.Render("> " + colorLabel + " (use ← → to change)") + } else { + colorLabel = labelStyle.Render(" " + colorLabel) + } + + // Color preview box + colorPreview := lipgloss.NewStyle(). + Background(shared.ConnectionColors[m.selectedColor].Color). + Render(" ") + content.WriteString(colorLabel + " " + colorPreview + "\n") +} diff --git a/components/root/main.go b/components/root/main.go new file mode 100644 index 0000000..e88a647 --- /dev/null +++ b/components/root/main.go @@ -0,0 +1,41 @@ +package root + +import ( + "nectar/types" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type MainAreaModel struct { + connectionForm ConnectionFormModel +} + +func NewMainArea() MainAreaModel { + return MainAreaModel{ + connectionForm: NewConnectionForm(), + } +} + +func (m MainAreaModel) Init() tea.Cmd { + return m.connectionForm.Init() +} + +func (m MainAreaModel) Update(msg tea.Msg) (MainAreaModel, tea.Cmd) { + var cmd tea.Cmd + formModel, cmd := m.connectionForm.Update(msg) + m.connectionForm = formModel.(ConnectionFormModel) + return m, cmd +} + +func MainArea(globals *types.Globals, mainArea MainAreaModel) string { + formContent := mainArea.connectionForm.View() + + return lipgloss.Place( + globals.Width-30, + globals.Height-1, + lipgloss.Center, + lipgloss.Center, + formContent, + ) +} diff --git a/components/root/sidebar.go b/components/root/sidebar.go new file mode 100644 index 0000000..7784cdf --- /dev/null +++ b/components/root/sidebar.go @@ -0,0 +1,16 @@ +package root + +import ( + "nectar/styles" + "nectar/types" + + "github.com/charmbracelet/lipgloss" +) + +func Sidebar(globals *types.Globals) string { + return styles.BaseStyle. + Width(30).Height(globals.Height - 1). + BorderRight(true). + BorderStyle(lipgloss.NormalBorder()). + Render("Sidebar placeholder") +} diff --git a/components/root/statusbar.go b/components/root/statusbar.go new file mode 100644 index 0000000..dd2992f --- /dev/null +++ b/components/root/statusbar.go @@ -0,0 +1,41 @@ +package root + +import ( + "nectar/styles" + "nectar/types" + + "github.com/charmbracelet/lipgloss" +) + +func StatusBar(globals *types.Globals) string { + w := lipgloss.Width + + helpText := lipgloss.JoinHorizontal( + lipgloss.Top, + styles.PaddedHorizontal.Render("↑/k: up"), + styles.PaddedHorizontal.Render("↓/j: down"), + styles.PaddedHorizontal.Render("↹: next"), + styles.PaddedHorizontal.Render("^n: new"), + styles.PaddedHorizontal.Render("^↵: connect"), + styles.PaddedHorizontal.Render("^s: save"), + styles.PaddedHorizontal.Render("^t: test"), + styles.PaddedHorizontal.Render("^d: delete"), + styles.PaddedHorizontal.Render("^c: quit"), + ) + + versionText := lipgloss.JoinHorizontal( + lipgloss.Top, + styles.PaddedHorizontal.Render("Nectar "+globals.Version+" ("+globals.BuildDate+")"), + ) + + separator := styles.BaseStyle.Width(globals.Width - w(helpText) - w(versionText)).Render("") + + return styles.StatusBar.Render( + lipgloss.JoinHorizontal( + lipgloss.Top, + helpText, + separator, + versionText, + ), + ) +} |
