diff options
| author | Priyansh <[email protected]> | 2025-08-28 13:09:22 +0530 |
|---|---|---|
| committer | Priyansh <[email protected]> | 2025-08-28 13:09:22 +0530 |
| commit | 9e82811a2a963be962fc3ffc426c137e01d56e2d (patch) | |
| tree | 9cde1691f6e8dafb7204b7247dea12af0aa22bf6 | |
| parent | 18b897a36805d1acb6e2352ca536c1eee9249fe3 (diff) | |
| download | nectar-9e82811a2a963be962fc3ffc426c137e01d56e2d.tar.xz nectar-9e82811a2a963be962fc3ffc426c137e01d56e2d.zip | |
Restructure codebase with proper organization, fix file picker navigation, and add utils packageHEADmain
| -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 (renamed from components/statusbar.go) | 10 | ||||
| -rw-r--r-- | components/shared/colors.go | 56 | ||||
| -rw-r--r-- | components/shared/filepicker.go | 232 | ||||
| -rw-r--r-- | go.mod | 2 | ||||
| -rw-r--r-- | go.sum | 4 | ||||
| -rw-r--r-- | screens/root.go | 28 | ||||
| -rw-r--r-- | types/connection.go | 35 | ||||
| -rw-r--r-- | utils/constants.go | 75 | ||||
| -rw-r--r-- | utils/database.go | 50 |
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 +} @@ -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 @@ -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 +} |
