aboutsummaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-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
6 files changed, 816 insertions, 4 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
+}