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 }