aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--components/root/connection.go465
-rw-r--r--components/root/main.go41
-rw-r--r--components/root/sidebar.go16
-rw-r--r--components/root/statusbar.go (renamed from components/statusbar.go)10
-rw-r--r--components/shared/colors.go56
-rw-r--r--components/shared/filepicker.go232
-rw-r--r--go.mod2
-rw-r--r--go.sum4
-rw-r--r--screens/root.go28
-rw-r--r--types/connection.go35
-rw-r--r--utils/constants.go75
-rw-r--r--utils/database.go50
12 files changed, 1002 insertions, 12 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/statusbar.go b/components/root/statusbar.go
index 34975b0..dd2992f 100644
--- a/components/statusbar.go
+++ b/components/root/statusbar.go
@@ -1,4 +1,4 @@
-package components
+package root
import (
"nectar/styles"
@@ -7,16 +7,18 @@ import (
"github.com/charmbracelet/lipgloss"
)
-func RootStatusBar(globals *types.Globals) string {
+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("↵: select"),
- styles.PaddedHorizontal.Render("^n: new connection"),
+ 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"),
)
diff --git a/components/shared/colors.go b/components/shared/colors.go
new file mode 100644
index 0000000..2dbb113
--- /dev/null
+++ b/components/shared/colors.go
@@ -0,0 +1,56 @@
+package shared
+
+import (
+ catppuccin "github.com/catppuccin/go"
+ "github.com/charmbracelet/lipgloss"
+)
+
+type ColorOption struct {
+ Name string
+ Color lipgloss.AdaptiveColor
+}
+
+var ConnectionColors = []ColorOption{
+ {
+ Name: "Red",
+ Color: lipgloss.AdaptiveColor{
+ Light: catppuccin.Latte.Red().Hex,
+ Dark: catppuccin.Mocha.Red().Hex,
+ },
+ },
+ {
+ Name: "Green",
+ Color: lipgloss.AdaptiveColor{
+ Light: catppuccin.Latte.Green().Hex,
+ Dark: catppuccin.Mocha.Green().Hex,
+ },
+ },
+ {
+ Name: "Blue",
+ Color: lipgloss.AdaptiveColor{
+ Light: catppuccin.Latte.Blue().Hex,
+ Dark: catppuccin.Mocha.Blue().Hex,
+ },
+ },
+ {
+ Name: "Yellow",
+ Color: lipgloss.AdaptiveColor{
+ Light: catppuccin.Latte.Yellow().Hex,
+ Dark: catppuccin.Mocha.Yellow().Hex,
+ },
+ },
+ {
+ Name: "Mauve",
+ Color: lipgloss.AdaptiveColor{
+ Light: catppuccin.Latte.Mauve().Hex,
+ Dark: catppuccin.Mocha.Mauve().Hex,
+ },
+ },
+ {
+ Name: "Teal",
+ Color: lipgloss.AdaptiveColor{
+ Light: catppuccin.Latte.Teal().Hex,
+ Dark: catppuccin.Mocha.Teal().Hex,
+ },
+ },
+}
diff --git a/components/shared/filepicker.go b/components/shared/filepicker.go
new file mode 100644
index 0000000..a2f2062
--- /dev/null
+++ b/components/shared/filepicker.go
@@ -0,0 +1,232 @@
+package shared
+
+import (
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+const (
+ MaxVisibleFiles = 10
+)
+
+type FilePickerModel struct {
+ currentDir string
+ files []fs.DirEntry
+ selected int
+ selectedFile string
+ err error
+ scrollOffset int
+}
+
+func NewFilePicker() FilePickerModel {
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ homeDir = "."
+ }
+
+ fp := FilePickerModel{
+ currentDir: homeDir,
+ selected: 0,
+ scrollOffset: 0,
+ }
+ fp.loadDirectory()
+ return fp
+}
+
+func (m *FilePickerModel) loadDirectory() {
+ files, err := os.ReadDir(m.currentDir)
+ if err != nil {
+ m.err = err
+ m.files = nil
+ return
+ }
+
+ var filteredFiles []fs.DirEntry
+
+ // Always add parent directory option unless we're at root
+ parentDir := filepath.Dir(m.currentDir)
+ if parentDir != m.currentDir {
+ // Create a synthetic directory entry for ".."
+ filteredFiles = append(filteredFiles, &parentDirEntry{})
+ }
+
+ // Add directories and SQLite files
+ for _, file := range files {
+ if file.IsDir() {
+ filteredFiles = append(filteredFiles, file)
+ } else if strings.HasSuffix(strings.ToLower(file.Name()), ".db") ||
+ strings.HasSuffix(strings.ToLower(file.Name()), ".sqlite") ||
+ strings.HasSuffix(strings.ToLower(file.Name()), ".sqlite3") {
+ filteredFiles = append(filteredFiles, file)
+ }
+ }
+
+ // Sort: parent directory first, then directories, then files
+ sort.Slice(filteredFiles, func(i, j int) bool {
+ nameI := filteredFiles[i].Name()
+ nameJ := filteredFiles[j].Name()
+
+ // Parent directory always comes first
+ if nameI == ".." {
+ return true
+ }
+ if nameJ == ".." {
+ return false
+ }
+
+ // Then sort by type (directories first) and name
+ if filteredFiles[i].IsDir() != filteredFiles[j].IsDir() {
+ return filteredFiles[i].IsDir()
+ }
+ return nameI < nameJ
+ })
+
+ m.files = filteredFiles
+ m.selected = 0
+ m.scrollOffset = 0
+ m.err = nil
+}
+
+// parentDirEntry implements fs.DirEntry for the ".." parent directory
+type parentDirEntry struct{}
+
+func (p *parentDirEntry) Name() string { return ".." }
+func (p *parentDirEntry) IsDir() bool { return true }
+func (p *parentDirEntry) Type() fs.FileMode { return fs.ModeDir }
+func (p *parentDirEntry) Info() (fs.FileInfo, error) { return nil, nil }
+
+func (m FilePickerModel) Init() tea.Cmd {
+ return nil
+}
+
+func (m FilePickerModel) Update(msg tea.Msg) (FilePickerModel, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch msg.String() {
+ case "up", "k":
+ if m.selected > 0 {
+ m.selected--
+ // Adjust scroll offset to keep selection visible
+ if m.selected < m.scrollOffset {
+ m.scrollOffset = m.selected
+ }
+ }
+ case "down", "j":
+ if m.selected < len(m.files)-1 {
+ m.selected++
+ // Adjust scroll offset to keep selection visible
+ if m.selected >= m.scrollOffset+MaxVisibleFiles {
+ m.scrollOffset = m.selected - MaxVisibleFiles + 1
+ }
+ }
+ case "enter":
+ if len(m.files) == 0 {
+ break
+ }
+ selectedFile := m.files[m.selected]
+ if selectedFile.IsDir() {
+ if selectedFile.Name() == ".." {
+ // Navigate to parent directory
+ m.currentDir = filepath.Dir(m.currentDir)
+ } else {
+ // Navigate to selected directory
+ m.currentDir = filepath.Join(m.currentDir, selectedFile.Name())
+ }
+ m.loadDirectory()
+ } else {
+ // Select the SQLite file
+ m.selectedFile = filepath.Join(m.currentDir, selectedFile.Name())
+ }
+ }
+ }
+ return m, nil
+}
+
+func (m FilePickerModel) View() string {
+ // Fixed height container to prevent UI pushing
+ containerStyle := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ Padding(1, 2).
+ Width(80).
+ Height(MaxVisibleFiles + 8) // Fixed height: header + files + help + padding
+
+ if m.err != nil {
+ errorContent := "Select SQLite Database File\n\nError: " + m.err.Error()
+ return containerStyle.Render(errorContent)
+ }
+
+ var content strings.Builder
+
+ // Header section
+ headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
+ content.WriteString(headerStyle.Render("Select SQLite Database File"))
+ content.WriteString("\n")
+ content.WriteString("Current: " + filepath.Base(m.currentDir))
+ content.WriteString("\n\n")
+
+ // Files section with fixed height
+ if len(m.files) == 0 {
+ content.WriteString("No SQLite files found in this directory")
+ // Add padding to maintain consistent height
+ for i := 0; i < MaxVisibleFiles-1; i++ {
+ content.WriteString("\n")
+ }
+ } else {
+ visibleStart := m.scrollOffset
+ visibleEnd := min(visibleStart+MaxVisibleFiles, len(m.files))
+
+ // Display visible files
+ for i := visibleStart; i < visibleEnd; i++ {
+ file := m.files[i]
+ var line string
+
+ if file.Name() == ".." {
+ line = "📁 .. (parent directory)"
+ } else if file.IsDir() {
+ line = "📁 " + file.Name() + "/"
+ } else {
+ line = "📄 " + file.Name()
+ }
+
+ if i == m.selected {
+ selectedStyle := lipgloss.NewStyle().
+ Background(lipgloss.Color("12")).
+ Foreground(lipgloss.Color("0"))
+ line = selectedStyle.Render("> " + line)
+ } else {
+ line = " " + line
+ }
+ content.WriteString(line + "\n")
+ }
+
+ // Fill remaining lines to maintain consistent height
+ remainingLines := MaxVisibleFiles - (visibleEnd - visibleStart)
+ for range remainingLines {
+ content.WriteString("\n")
+ }
+ }
+
+ // Footer with navigation help
+ helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
+ help := helpStyle.Render("↑/↓: navigate, Enter: select/open, Esc: cancel")
+ content.WriteString("\n" + help)
+
+ return containerStyle.Render(content.String())
+}
+
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
+
+func (m FilePickerModel) SelectedFile() string {
+ return m.selectedFile
+}
diff --git a/go.mod b/go.mod
index 29e8dcc..f38d76f 100644
--- a/go.mod
+++ b/go.mod
@@ -11,11 +11,13 @@ require (
)
require (
+ github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.9.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
diff --git a/go.sum b/go.sum
index aec6a7a..6caaf5e 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
@@ -16,6 +18,8 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
diff --git a/screens/root.go b/screens/root.go
index 9774872..590c4db 100644
--- a/screens/root.go
+++ b/screens/root.go
@@ -1,38 +1,50 @@
package screens
import (
- "nectar/components"
+ "nectar/components/root"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
-type rootScreen struct{}
+type rootScreen struct {
+ mainArea root.MainAreaModel
+}
func _root() tea.Model {
- return &rootScreen{}
+ return &rootScreen{
+ mainArea: root.NewMainArea(),
+ }
}
func (r *rootScreen) Init() tea.Cmd {
- return nil
+ return r.mainArea.Init()
}
func (r *rootScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ globals.Width, globals.Height = msg.Width, msg.Height
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
return r, tea.Quit
- case "a":
- return r, switchScreen(_aux())
}
}
- return r, nil
+
+ var cmd tea.Cmd
+ r.mainArea, cmd = r.mainArea.Update(msg)
+ return r, cmd
}
func (r *rootScreen) View() string {
return lipgloss.JoinVertical(
lipgloss.Top,
- components.RootStatusBar(&globals),
+ lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ root.Sidebar(&globals),
+ root.MainArea(&globals, r.mainArea),
+ ),
+ root.StatusBar(&globals),
)
}
diff --git a/types/connection.go b/types/connection.go
new file mode 100644
index 0000000..bfeb0cb
--- /dev/null
+++ b/types/connection.go
@@ -0,0 +1,35 @@
+package types
+
+type ConnectionType int
+
+const (
+ PostgreSQL ConnectionType = iota
+ MySQL
+ SQLite
+)
+
+func (ct ConnectionType) String() string {
+ switch ct {
+ case PostgreSQL:
+ return "PostgreSQL"
+ case MySQL:
+ return "MySQL"
+ case SQLite:
+ return "SQLite"
+ default:
+ return "Unknown"
+ }
+}
+
+type Connection struct {
+ Name string
+ Type ConnectionType
+ Host string
+ Port string
+ User string
+ Password string
+ Database string
+ DatabaseFile string
+ EnableSSL bool
+ Color string
+}
diff --git a/utils/constants.go b/utils/constants.go
new file mode 100644
index 0000000..6ec594c
--- /dev/null
+++ b/utils/constants.go
@@ -0,0 +1,75 @@
+package utils
+
+import "nectar/types"
+
+// Form field indices using iota for better readability
+const (
+ FieldConnectionType = iota
+ FieldHost
+ FieldPort
+ FieldSSL
+ FieldUser
+ FieldPassword
+ FieldConnectionName
+ FieldColor
+)
+
+// SQLite-specific field indices (redefine to match the layout)
+const (
+ SQLiteFieldConnectionType = iota // 0: Connection Type
+ SQLiteFieldDatabaseFile // 1: Database File
+ SQLiteFieldConnectionName // 2: Connection Name
+ SQLiteFieldColor // 3: Color
+)
+
+// Input field indices using iota
+const (
+ InputHost = iota
+ InputPort
+ InputUser
+ InputPassword
+ InputConnectionName
+)
+
+// Database connection defaults
+var (
+ DefaultPorts = map[types.ConnectionType]string{
+ types.PostgreSQL: "5432",
+ types.MySQL: "3306",
+ types.SQLite: "",
+ }
+
+ ConnectionTypes = []types.ConnectionType{
+ types.PostgreSQL,
+ types.MySQL,
+ types.SQLite,
+ }
+
+ // Total field counts for each database type
+ FieldCounts = map[types.ConnectionType]int{
+ types.SQLite: 4, // Connection Type, Database File, Connection Name, Color
+ types.PostgreSQL: 8, // Connection Type, Host, Port, SSL, User, Password, Connection Name, Color
+ types.MySQL: 8, // Same as PostgreSQL
+ }
+)
+
+// Field mapping for SQLite (simplified structure)
+var SQLiteFieldMapping = map[int]int{
+ SQLiteFieldConnectionName: InputConnectionName,
+}
+
+// Field mapping for PostgreSQL and MySQL (full structure)
+var NonSQLiteFieldMapping = map[int]int{
+ FieldHost: InputHost,
+ FieldPort: InputPort,
+ FieldUser: InputUser,
+ FieldPassword: InputPassword,
+ FieldConnectionName: InputConnectionName,
+}
+
+// Complete field mappings for all database types
+var FieldMappings = map[types.ConnectionType]map[int]int{
+ types.SQLite: SQLiteFieldMapping,
+ types.PostgreSQL: NonSQLiteFieldMapping,
+ types.MySQL: NonSQLiteFieldMapping,
+}
diff --git a/utils/database.go b/utils/database.go
new file mode 100644
index 0000000..877ec2d
--- /dev/null
+++ b/utils/database.go
@@ -0,0 +1,50 @@
+package utils
+
+import "nectar/types"
+
+// GetDefaultPort returns the default port for a given connection type
+func GetDefaultPort(connType types.ConnectionType) string {
+ if port, exists := DefaultPorts[connType]; exists {
+ return port
+ }
+ return ""
+}
+
+// GetFieldCount returns the total number of fields for a connection type
+func GetFieldCount(connType types.ConnectionType) int {
+ if count, exists := FieldCounts[connType]; exists {
+ return count
+ }
+ return 0
+}
+
+// GetInputIndex returns the input index for a given field based on connection type
+func GetInputIndex(connType types.ConnectionType, fieldIndex int) int {
+ if mapping, exists := FieldMappings[connType]; exists {
+ if inputIndex, exists := mapping[fieldIndex]; exists {
+ return inputIndex
+ }
+ }
+ return -1
+}
+
+// NextConnectionType cycles to the next connection type
+func NextConnectionType(current types.ConnectionType) types.ConnectionType {
+ for i, connType := range ConnectionTypes {
+ if connType == current {
+ return ConnectionTypes[(i+1)%len(ConnectionTypes)]
+ }
+ }
+ return current
+}
+
+// PrevConnectionType cycles to the previous connection type
+func PrevConnectionType(current types.ConnectionType) types.ConnectionType {
+ for i, connType := range ConnectionTypes {
+ if connType == current {
+ prevIndex := (i - 1 + len(ConnectionTypes)) % len(ConnectionTypes)
+ return ConnectionTypes[prevIndex]
+ }
+ }
+ return current
+}