diff --git a/cmd/hosts/main.go b/cmd/hosts/main.go index 6469ff4..4e0fbe4 100644 --- a/cmd/hosts/main.go +++ b/cmd/hosts/main.go @@ -1,137 +1,22 @@ package main import ( - "fmt" "log" - "strings" "hosts-go/internal/core" + "hosts-go/internal/tui" + + tea "github.com/charmbracelet/bubbletea" ) func main() { - fmt.Println("hosts-go - Phase 1: Core Functionality (Parser)") - fmt.Println("===============================================") - - // Demonstrate hosts file parsing with sample content - sampleHostsContent := `# Sample hosts file content -127.0.0.1 localhost # Local loopback -::1 ip6-localhost # IPv6 loopback -192.168.1.100 dev.example.com www.dev.example.com api.dev.example.com # Development server -# 10.0.0.50 staging.example.com # Disabled staging server -203.0.113.10 prod.example.com # Production server - -# Another comment -::ffff:192.168.1.200 test.example.com # Test server` - - fmt.Println("Sample hosts file content:") - fmt.Println(strings.Repeat("-", 50)) - fmt.Println(sampleHostsContent) - fmt.Println(strings.Repeat("-", 50)) - fmt.Println() - - // Parse the sample content - lines := strings.Split(sampleHostsContent, "\n") - hostsFile, warnings, err := core.ParseHostsContent(lines) + hostsFile, _, err := core.ParseHostsFile("/etc/hosts") if err != nil { - log.Fatalf("Failed to parse hosts content: %v", err) + log.Fatalf("failed to parse hosts file: %v", err) } - // Display parsing results - fmt.Printf("✅ Parsing successful!\n") - fmt.Printf(" Total entries: %d\n", len(hostsFile.Entries)) - fmt.Printf(" Active entries: %d\n", len(hostsFile.ActiveEntries())) - fmt.Printf(" Standalone comments: %d\n", len(hostsFile.Comments)) - fmt.Printf(" Warnings: %d\n", len(warnings)) - fmt.Println() - - // Show warnings if any - if len(warnings) > 0 { - fmt.Println("Parsing warnings:") - for _, warning := range warnings { - fmt.Printf(" Line %d: %s\n", warning.Line, warning.Message) - } - fmt.Println() + p := tea.NewProgram(tui.NewModel(hostsFile)) + if err := p.Start(); err != nil { + log.Fatalf("failed to start TUI: %v", err) } - - // Show standalone comments - if len(hostsFile.Comments) > 0 { - fmt.Println("Standalone comments found:") - for i, comment := range hostsFile.Comments { - fmt.Printf("%d. %s\n", i+1, comment) - } - fmt.Println() - } - - // Show parsed entries - fmt.Println("Parsed entries:") - for i, entry := range hostsFile.Entries { - status := "✅ Active" - if !entry.Active { - status = "❌ Disabled" - } - fmt.Printf("%d. [%s] %s -> %s", i+1, status, entry.IP, entry.Hostname) - if len(entry.Aliases) > 0 { - fmt.Printf(" (aliases: %s)", strings.Join(entry.Aliases, ", ")) - } - if entry.Comment != "" { - fmt.Printf(" # %s", entry.Comment) - } - fmt.Println() - } - fmt.Println() - - // Demonstrate intelligent formatting - fmt.Println("Intelligent formatting output:") - fmt.Println(strings.Repeat("-", 50)) - formattedLines := core.FormatHostsFile(hostsFile) - for _, line := range formattedLines { - fmt.Println(line) - } - fmt.Println(strings.Repeat("-", 50)) - fmt.Println() - - // Demonstrate formatting style detection - fmt.Println("Formatting style detection:") - style := core.DetectFormattingStyle(lines) - if style.UseTabs { - fmt.Printf(" Detected style: Tabs\n") - } else { - fmt.Printf(" Detected style: Spaces (%d per tab)\n", style.SpacesPerTab) - } - fmt.Printf(" Column widths: IP=%d, Host=%d\n", style.IPWidth, style.HostWidth) - fmt.Println() - - // Demonstrate search functionality - fmt.Println("Search demonstrations:") - if found := hostsFile.FindEntry("localhost"); found != nil { - fmt.Printf("✅ Found 'localhost': %s -> %s\n", found.IP, found.Hostname) - } - - if found := hostsFile.FindEntry("www.dev.example.com"); found != nil { - fmt.Printf("✅ Found alias 'www.dev.example.com': %s -> %s\n", found.IP, found.Hostname) - } - - if found := hostsFile.FindEntry("staging.example.com"); found != nil { - status := "active" - if !found.Active { - status = "disabled" - } - fmt.Printf("✅ Found 'staging.example.com': %s -> %s (%s)\n", found.IP, found.Hostname, status) - } - - if found := hostsFile.FindEntry("notfound.com"); found == nil { - fmt.Printf("❌ 'notfound.com' not found (as expected)\n") - } - fmt.Println() - - fmt.Println("🎉 Phase 1 Complete: Core Functionality (Parser)") - fmt.Println("✅ Hosts file parsing with format preservation") - fmt.Println("✅ Comment and disabled entry handling") - fmt.Println("✅ Intelligent formatting with column alignment") - fmt.Println("✅ Malformed line handling with warnings") - fmt.Println("✅ Round-trip parsing (parse → format → parse)") - fmt.Println("✅ Backup functionality") - fmt.Println("✅ Search and entry management") - fmt.Println() - fmt.Println("Ready for Phase 2: TUI Implementation!") } diff --git a/go.mod b/go.mod index dd1d3e3..e391812 100644 --- a/go.mod +++ b/go.mod @@ -2,20 +2,22 @@ module hosts-go go 1.24.5 -require github.com/stretchr/testify v1.10.0 +require ( + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.6 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/stretchr/testify v1.10.0 +) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbles v0.21.0 // indirect - github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.3.1 // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/lrstanley/bubblezone v1.0.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -25,6 +27,7 @@ require ( github.com/muesli/termenv v0.16.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.15.0 // indirect golang.org/x/sys v0.33.0 // indirect diff --git a/go.sum b/go.sum index eab93af..950982e 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= @@ -12,14 +16,16 @@ github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA= -github.com/lrstanley/bubblezone v1.0.0/go.mod h1:kcTekA8HE/0Ll2bWzqHlhA2c513KDNLW7uDfDP4Mly8= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -39,10 +45,14 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..8c37166 --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,57 @@ +package tui + +import ( + "hosts-go/internal/core" + + list "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" +) + +// entryItem wraps a HostEntry for display in a list component. +type entryItem struct{ entry *core.HostEntry } + +func (e entryItem) Title() string { return e.entry.Hostname } +func (e entryItem) Description() string { return e.entry.IP } +func (e entryItem) FilterValue() string { return e.entry.Hostname } + +// Model is the main Bubble Tea model for the application. +type Model struct { + list list.Model + hosts *core.HostsFile + width int + height int +} + +// NewModel constructs the TUI model from a parsed HostsFile. +func NewModel(hf *core.HostsFile) Model { + items := make([]list.Item, len(hf.Entries)) + for i, e := range hf.Entries { + items[i] = entryItem{entry: e} + } + + l := list.New(items, list.NewDefaultDelegate(), 0, 0) + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + l.SetShowHelp(false) + l.SetShowPagination(false) + + return Model{ + list: l, + hosts: hf, + } +} + +// Init satisfies tea.Model. +func (m Model) Init() tea.Cmd { return nil } + +// SelectedEntry returns the currently selected host entry. +func (m Model) SelectedEntry() *core.HostEntry { + if len(m.hosts.Entries) == 0 { + return nil + } + idx := m.list.Index() + if idx < 0 || idx >= len(m.hosts.Entries) { + return nil + } + return m.hosts.Entries[idx] +} diff --git a/internal/tui/update.go b/internal/tui/update.go new file mode 100644 index 0000000..80dc1df --- /dev/null +++ b/internal/tui/update.go @@ -0,0 +1,24 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +// Update handles all messages for the TUI. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.list.SetSize(msg.Width/2, msg.Height) + } + + m.list, cmd = m.list.Update(msg) + return m, cmd +} diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 0000000..0af600c --- /dev/null +++ b/internal/tui/view.go @@ -0,0 +1,40 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +var ( + listStyle = lipgloss.NewStyle().Padding(0, 1) + detailStyle = lipgloss.NewStyle().Padding(0, 1) +) + +// View renders the two-pane layout. +func (m Model) View() string { + listView := m.list.View() + + var detail strings.Builder + if len(m.hosts.Entries) > 0 { + entry := m.hosts.Entries[m.list.Index()] + status := "active" + if !entry.Active { + status = "inactive" + } + fmt.Fprintf(&detail, "IP: %s\n", entry.IP) + fmt.Fprintf(&detail, "Host: %s\n", entry.Hostname) + if len(entry.Aliases) > 0 { + fmt.Fprintf(&detail, "Aliases: %s\n", strings.Join(entry.Aliases, ", ")) + } + if entry.Comment != "" { + fmt.Fprintf(&detail, "Comment: %s\n", entry.Comment) + } + fmt.Fprintf(&detail, "Status: %s", status) + } + + left := listStyle.Width(m.width / 2).Height(m.height).Render(listView) + right := detailStyle.Width(m.width - m.width/2).Height(m.height).Render(detail.String()) + return lipgloss.JoinHorizontal(lipgloss.Top, left, right) +} diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md index 4fa6806..5c4e57c 100644 --- a/memory-bank/activeContext.md +++ b/memory-bank/activeContext.md @@ -2,8 +2,8 @@ ## Current Work Focus -**Status**: Phase 1 Complete - Ready for Phase 2 (TUI Implementation) -**Priority**: Implementing Bubble Tea TUI with two-pane layout +**Status**: Phase 2 In Progress - Basic TUI prototype implemented +**Priority**: Expand Bubble Tea TUI with navigation and view features ## Recent Changes @@ -36,25 +36,22 @@ ## Next Steps ### Immediate (Phase 2 - Current Priority) -1. **TUI Architecture Design** - - Design main Bubble Tea model structure following MVU pattern - - Plan state management for entries, selection, and modes - - Define component hierarchy (main → list → detail → modal) +1. **TUI Architecture Design** ✅ + - Main Bubble Tea model with list and detail panes + - State tracks selection and window size -2. **Two-Pane Layout Implementation** - - Create left pane: entry list with status indicators - - Create right pane: detailed entry view with editing capabilities - - Implement responsive layout with proper sizing +2. **Two-Pane Layout Implementation** ✅ + - Left pane: entry list using Bubbles list component + - Right pane: detail view of selected entry + - Responsive sizing handled in update -3. **Navigation System** - - Keyboard navigation between panes and entries - - Selection highlighting and status indicators - - Scroll handling for large hosts files +3. **Navigation System** 🟡 + - Basic keyboard navigation via list component + - Need scroll handling and pane switching -4. **View Mode Implementation** - - Safe browsing without modification capability - - Display parsed entries with active/inactive status - - Show entry details in right pane when selected +4. **View Mode Implementation** 🟡 + - Read-only display of `/etc/hosts` entries + - Needs status indicators and better styling ### Medium-term (Phase 3) 1. **Edit Mode Implementation** diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 642491f..0003adc 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -44,12 +44,12 @@ ## What's Left to Build -### 🎨 Basic TUI (Phase 2 - Current Priority) -- [ ] **Main Bubble Tea model**: Core application state and structure -- [ ] **Two-pane layout**: Left list + right detail view -- [ ] **Entry list display**: Show active status, IP, hostname columns -- [ ] **Entry selection**: Navigate and select entries with keyboard -- [ ] **View mode**: Safe browsing without modification capability +### 🎨 Basic TUI (Phase 2 - Current Priority) +- [x] **Main Bubble Tea model**: Core application state and structure +- [x] **Two-pane layout**: Left list + right detail view +- [x] **Entry list display**: Show IP and hostname columns +- [x] **Entry selection**: Navigate and select entries with keyboard +- [ ] **View mode**: Safe browsing with status indicators - [ ] **Integration**: Connect TUI with existing parser functionality ### 🔧 Edit Functionality (Phase 3) diff --git a/tests/tui_test.go b/tests/tui_test.go new file mode 100644 index 0000000..e6d9286 --- /dev/null +++ b/tests/tui_test.go @@ -0,0 +1,30 @@ +package tests + +import ( + "strings" + "testing" + + "hosts-go/internal/core" + "hosts-go/internal/tui" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestModelSelection(t *testing.T) { + sample := `127.0.0.1 localhost +192.168.1.10 example.com` + lines := strings.Split(sample, "\n") + hf, _, err := core.ParseHostsContent(lines) + require.NoError(t, err) + + m := tui.NewModel(hf) + require.NotNil(t, m.SelectedEntry()) + assert.Equal(t, "localhost", m.SelectedEntry().Hostname) + + // Move selection down + nm, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = nm.(tui.Model) + assert.Equal(t, "example.com", m.SelectedEntry().Hostname) +}