Files
telephony-inspector/internal/tui/file_browser.go

257 lines
5.3 KiB
Go

package tui
import (
"os"
"path/filepath"
"sort"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// FileBrowserModel handles file selection
type FileBrowserModel struct {
currentDir string
entries []os.DirEntry
selected int
filter string // file extension filter (e.g., ".pcap")
height int
offset int
// Results
selectedFile string
cancelled bool
err error
}
// NewFileBrowser creates a new file browser starting at dir
func NewFileBrowser(startDir string, filter string) FileBrowserModel {
if startDir == "" {
startDir, _ = os.Getwd()
}
fb := FileBrowserModel{
currentDir: startDir,
filter: filter,
height: 15,
}
fb.loadDir()
return fb
}
func (m *FileBrowserModel) loadDir() {
entries, err := os.ReadDir(m.currentDir)
if err != nil {
m.err = err
return
}
// Filter and sort entries
var filtered []os.DirEntry
for _, e := range entries {
if e.IsDir() {
filtered = append(filtered, e)
} else if m.filter == "" {
filtered = append(filtered, e)
} else {
name := strings.ToLower(e.Name())
if strings.HasSuffix(name, m.filter) || strings.HasSuffix(name, ".pcapng") {
filtered = append(filtered, e)
}
}
}
// Sort: directories first, then alphabetically
sort.Slice(filtered, func(i, j int) bool {
iDir := filtered[i].IsDir()
jDir := filtered[j].IsDir()
if iDir != jDir {
return iDir
}
return strings.ToLower(filtered[i].Name()) < strings.ToLower(filtered[j].Name())
})
m.entries = filtered
m.selected = 0
m.offset = 0
}
// Init initializes the model
func (m FileBrowserModel) Init() tea.Cmd {
return nil
}
// Update handles messages
func (m FileBrowserModel) Update(msg tea.Msg) (FileBrowserModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc", "q":
m.cancelled = true
return m, nil
case "up", "k":
if m.selected > 0 {
m.selected--
if m.selected < m.offset {
m.offset = m.selected
}
}
case "down", "j":
if m.selected < len(m.entries)-1 {
m.selected++
if m.selected >= m.offset+m.height {
m.offset = m.selected - m.height + 1
}
}
case "enter":
if len(m.entries) == 0 {
return m, nil
}
entry := m.entries[m.selected]
fullPath := filepath.Join(m.currentDir, entry.Name())
if entry.IsDir() {
m.currentDir = fullPath
m.loadDir()
} else {
m.selectedFile = fullPath
}
case "backspace", "h":
// Go to parent directory
parent := filepath.Dir(m.currentDir)
if parent != m.currentDir {
m.currentDir = parent
m.loadDir()
}
case "home":
home, err := os.UserHomeDir()
if err == nil {
m.currentDir = home
m.loadDir()
}
}
case tea.WindowSizeMsg:
m.height = msg.Height - 10
if m.height < 5 {
m.height = 5
}
}
return m, nil
}
// View renders the file browser
func (m FileBrowserModel) View() string {
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
MarginBottom(1)
pathStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#AFAFAF")).
MarginBottom(1)
dirStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#8BE9FD"))
fileStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#F8F8F2"))
selectedStyle := lipgloss.NewStyle().
Bold(true).
Background(lipgloss.Color("#44475A")).
Foreground(lipgloss.Color("#50FA7B"))
helpStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#626262")).
MarginTop(1)
var b strings.Builder
b.WriteString(titleStyle.Render("📁 Select PCAP File"))
b.WriteString("\n")
b.WriteString(pathStyle.Render(m.currentDir))
b.WriteString("\n\n")
if m.err != nil {
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5555")).Render("Error: " + m.err.Error()))
b.WriteString("\n")
}
if len(m.entries) == 0 {
b.WriteString("(empty directory)\n")
} else {
// Show entries with scrolling
end := m.offset + m.height
if end > len(m.entries) {
end = len(m.entries)
}
for i := m.offset; i < end; i++ {
entry := m.entries[i]
name := entry.Name()
var style lipgloss.Style
prefix := " "
if entry.IsDir() {
name = "📂 " + name + "/"
style = dirStyle
} else {
name = "📄 " + name
style = fileStyle
}
if i == m.selected {
prefix = "▶ "
style = selectedStyle
}
b.WriteString(prefix + style.Render(name) + "\n")
}
// Show scroll indicator
if len(m.entries) > m.height {
b.WriteString(pathStyle.Render(
strings.Repeat("─", 20) +
" " + string(rune('0'+m.offset/10)) + string(rune('0'+m.offset%10)) +
"/" + string(rune('0'+len(m.entries)/10)) + string(rune('0'+len(m.entries)%10)) + " " +
strings.Repeat("─", 20)))
b.WriteString("\n")
}
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("↑/↓ navigate • Enter select/open • Backspace parent • Esc cancel"))
return b.String()
}
// IsSelected returns true if a file was selected
func (m FileBrowserModel) IsSelected() bool {
return m.selectedFile != ""
}
// IsCancelled returns true if cancelled
func (m FileBrowserModel) IsCancelled() bool {
return m.cancelled
}
// GetSelectedFile returns the selected file path
func (m FileBrowserModel) GetSelectedFile() string {
return m.selectedFile
}
// GetCurrentDir returns current directory
func (m FileBrowserModel) GetCurrentDir() string {
return m.currentDir
}