257 lines
5.3 KiB
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
|
||
|
|
}
|