diff options
Diffstat (limited to 'components/shared/filepicker.go')
| -rw-r--r-- | components/shared/filepicker.go | 232 |
1 files changed, 232 insertions, 0 deletions
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 +} |
