feat: Add pcap import, file browser, logging, local capture, and stable call ordering
This commit is contained in:
256
internal/tui/file_browser.go
Normal file
256
internal/tui/file_browser.go
Normal file
@@ -0,0 +1,256 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user