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..9b9f9a2 --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,118 @@ +package tui + +import ( + "fmt" + "hosts-go/internal/core" + "strings" + + list "github.com/charmbracelet/bubbles/list" + viewport "github.com/charmbracelet/bubbles/viewport" + 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 { + prefix := "[ ]" + if e.entry.Active { + prefix = "[✓]" + } + return fmt.Sprintf("%s %s", prefix, 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 Mode int + +const ( + ViewMode Mode = iota + EditMode +) + +type pane int + +const ( + listPane pane = iota + detailPane +) + +type Model struct { + list list.Model + detail viewport.Model + hosts *core.HostsFile + width int + height int + mode Mode + focus pane +} + +// 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) + + m := Model{ + list: l, + detail: viewport.New(0, 0), + hosts: hf, + mode: ViewMode, + focus: listPane, + } + m.refreshDetail() + return m +} + +func (m *Model) refreshDetail() { + if len(m.hosts.Entries) == 0 { + m.detail.SetContent("") + return + } + entry := m.hosts.Entries[m.list.Index()] + var b strings.Builder + status := "active" + if !entry.Active { + status = "inactive" + } + fmt.Fprintf(&b, "IP: %s\n", entry.IP) + fmt.Fprintf(&b, "Host: %s\n", entry.Hostname) + if len(entry.Aliases) > 0 { + fmt.Fprintf(&b, "Aliases: %s\n", strings.Join(entry.Aliases, ", ")) + } + if entry.Comment != "" { + fmt.Fprintf(&b, "Comment: %s\n", entry.Comment) + } + fmt.Fprintf(&b, "Status: %s", status) + m.detail.SetContent(b.String()) + m.detail.YOffset = 0 +} + +// 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] +} + +// Mode returns the current operating mode of the TUI. +func (m Model) Mode() Mode { + return m.mode +} diff --git a/internal/tui/update.go b/internal/tui/update.go new file mode 100644 index 0000000..e29fd15 --- /dev/null +++ b/internal/tui/update.go @@ -0,0 +1,90 @@ +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 "tab": + if m.focus == listPane { + m.focus = detailPane + } else { + m.focus = listPane + } + case "e": + if m.mode == ViewMode { + m.mode = EditMode + } else { + m.mode = ViewMode + } + case "a": + if m.mode == EditMode { + if entry := m.SelectedEntry(); entry != nil { + entry.Active = !entry.Active + m.list.SetItem(m.list.Index(), entryItem{entry}) + m.refreshDetail() + } + } + case "up", "k": + if m.focus == detailPane { + m.detail.LineUp(1) + } else { + m.list, cmd = m.list.Update(msg) + m.refreshDetail() + } + return m, cmd + case "down", "j": + if m.focus == detailPane { + m.detail.LineDown(1) + } else { + m.list, cmd = m.list.Update(msg) + m.refreshDetail() + } + return m, cmd + } + if m.focus == listPane { + m.list, cmd = m.list.Update(msg) + m.refreshDetail() + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height - 1 + + leftWidth := msg.Width / 2 + rightWidth := msg.Width - leftWidth + leftHeight := m.height + rightHeight := m.height + + if m.focus == listPane { + leftWidth -= 2 + leftHeight -= 2 + } else { + rightWidth -= 2 + rightHeight -= 2 + } + + leftWidth -= 2 + rightWidth -= 2 + + m.list.SetSize(leftWidth, leftHeight) + m.detail.Width = rightWidth + m.detail.Height = rightHeight + + if m.focus == listPane { + m.list, cmd = m.list.Update(msg) + } + default: + if m.focus == listPane { + m.list, cmd = m.list.Update(msg) + m.refreshDetail() + } + } + return m, cmd +} diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 0000000..8190a0e --- /dev/null +++ b/internal/tui/view.go @@ -0,0 +1,57 @@ +package tui + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +var ( + listStyle = lipgloss.NewStyle().Padding(0, 1) + detailStyle = lipgloss.NewStyle().Padding(0, 1) + focusedStyle = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("62")) + statusStyle = lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.Color("240")).Background(lipgloss.Color("236")) +) + +// View renders the two-pane layout. +func (m Model) View() string { + listView := m.list.View() + detailView := m.detail.View() + + // compute dimensions for each pane accounting for padding and focus border + leftWidth := m.width / 2 + rightWidth := m.width - leftWidth + leftHeight := m.height + rightHeight := m.height + + if m.focus == listPane { + leftWidth -= 2 // border + leftHeight -= 2 + } else { + rightWidth -= 2 + rightHeight -= 2 + } + + // account for horizontal padding + leftWidth -= 2 + rightWidth -= 2 + + left := listStyle.Width(leftWidth).Height(leftHeight).Render(listView) + right := detailStyle.Width(rightWidth).Height(rightHeight).Render(detailView) + + if m.focus == listPane { + left = focusedStyle.Render(left) + } else { + right = focusedStyle.Render(right) + } + + // join panes and status bar + panes := lipgloss.JoinHorizontal(lipgloss.Top, left, right) + modeLabel := "VIEW" + if m.mode == EditMode { + modeLabel = "EDIT" + } + status := fmt.Sprintf("%s MODE • %d entries", modeLabel, len(m.hosts.Entries)) + bar := statusStyle.Width(m.width).Render(status) + return lipgloss.JoinVertical(lipgloss.Left, panes, bar) +} diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md index 4fa6806..c19e750 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 3 In Progress - Edit mode basics implemented +**Priority**: Expand Phase 3 with file integration and advanced editing ## Recent Changes @@ -17,6 +17,17 @@ - ✅ **Comprehensive testing**: 54 comprehensive tests covering all parser functionality (100% passing) - ✅ **Demo application**: Full showcase of parser capabilities with real-world examples +### Phase 2: TUI Prototype (COMPLETED) ✅ +- ✅ **Two-pane layout** with list and detail panes +- ✅ **Navigation system** with pane switching and detail scrolling +- ✅ **View mode** showing entry details and status bar +- ✅ **Parser integration** loading `/etc/hosts` into the UI + +### Phase 3: Edit Mode (IN PROGRESS) +- 🔄 **Edit mode toggle** via 'e' key with status bar indication +- 🔄 **Entry activation toggle** using 'a' key +- 🔄 **Model tests** covering edit mode behavior + ### Parser Capabilities Achieved - ✅ **Standard entries**: IPv4, IPv6, multiple aliases, inline comments - ✅ **Disabled entries**: Commented lines with `# IP hostname` format detection @@ -35,42 +46,21 @@ ## 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) - -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 - -3. **Navigation System** - - Keyboard navigation between panes and entries - - Selection highlighting and status indicators - - Scroll handling for large hosts files - -4. **View Mode Implementation** - - Safe browsing without modification capability - - Display parsed entries with active/inactive status - - Show entry details in right pane when selected - -### Medium-term (Phase 3) +### Immediate (Phase 3 - Current Priority) 1. **Edit Mode Implementation** - - Explicit mode transition with visual indicators - - Permission handling with sudo request - - Entry modification forms with validation + - ✅ Explicit mode transition with visual indicators + - 🔄 Permission handling with sudo request + - 🔄 Entry modification forms with validation 2. **File Integration** - - Connect TUI with existing parser functionality - - Real-time display of actual `/etc/hosts` content - - Live validation and formatting preview + - 🔄 Connect TUI with existing parser functionality for writes + - 🔄 Real-time display of actual `/etc/hosts` content + - 🔄 Live validation and formatting preview 3. **Advanced Features** - - Entry toggle (activate/deactivate) - - Add/edit/delete operations - - Sorting and filtering capabilities + - ✅ Entry toggle (activate/deactivate) + - 🔄 Add/edit/delete operations + - 🔄 Sorting and filtering capabilities ## Active Decisions and Considerations diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 642491f..aedc400 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -44,18 +44,19 @@ ## 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 -- [ ] **Integration**: Connect TUI with existing parser functionality +### 🎨 Basic TUI (Phase 2 - Completed) +- [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 +- [x] **View mode**: Safe browsing with status bar and active/inactive indicators +- [x] **Integration**: Connect TUI with existing parser functionality ### 🔧 Edit Functionality (Phase 3) -- [ ] **Edit mode transition**: Explicit mode switching with visual indicators +- [x] **Edit mode transition**: Explicit mode switching with visual indicators - [ ] **Permission handling**: Request sudo access when entering edit mode -- [ ] **Entry modification**: Add, edit, delete, toggle active status +- [x] **Entry modification**: Toggle active status +- [ ] **Entry modification**: Add, edit, delete operations - [ ] **File writing**: Atomic updates with backup and rollback - [ ] **Input validation**: Real-time validation of IP and hostname inputs @@ -67,16 +68,16 @@ - [ ] **Search/filter**: Find entries quickly in large files ### 🧪 Testing & Quality (Ongoing) -- [ ] **TUI tests**: User interactions, state transitions +- [x] **TUI tests**: User interactions, state transitions - [ ] **Integration tests**: Complete TUI workflows with file operations - [ ] **Permission tests**: sudo scenarios, graceful degradation - [ ] **End-to-end tests**: Full application workflows ## Current Status -### Project Phase: **Phase 1 Complete → Phase 2 (TUI Implementation)** -- **Completion**: ~65% (foundation and complete core parser functionality implemented) -- **Active work**: Ready to implement Bubble Tea TUI with two-pane layout (Phase 2) +### Project Phase: **Phase 2 Complete → Phase 3 (Edit Mode Implementation)** +- **Completion**: ~80% (parser, TUI, and basic edit mode implemented) +- **Active work**: Expand edit mode with file integration and advanced editing - **Blockers**: None - comprehensive parser foundation with 54 tests completed - **Parser status**: Production-ready with all safety features implemented @@ -132,10 +133,10 @@ ## Success Metrics & Milestones ### Milestone 1: Basic Functionality (Target: Week 1) -- [ ] Parse and display existing hosts file entries -- [ ] Navigate entries with keyboard -- [ ] View entry details in right pane -- **Success criteria**: Can safely browse hosts file without editing +- [x] Parse and display existing hosts file entries +- [x] Navigate entries with keyboard +- [x] View entry details in right pane +- **Success criteria**: Can safely browse hosts file without editing (achieved) ### Milestone 2: Core Editing (Target: Week 2) - [ ] Toggle entries active/inactive @@ -179,15 +180,14 @@ 19. ✅ **Verify atomic operations** (temp files with rollback for safe writes) 20. ✅ **Complete parser documentation** (comprehensive demo showing all capabilities) -### 🚧 NEXT Phase 2 Actions (TUI Implementation) -1. **Design TUI architecture** following Bubble Tea MVU pattern -2. **Create main application model** with state management -3. **Implement two-pane layout** (entry list + detail view) -4. **Add navigation controls** (keyboard-driven interaction) -5. **Integrate parser functionality** with TUI display -6. **Implement view mode** (safe browsing without modifications) +### 🚧 NEXT Phase 3 Actions (Edit Mode & File Integration) +1. **Edit mode transition** with explicit mode switching +2. **Permission handling** requesting sudo when entering edit mode +3. **Entry modification** forms for add/edit/delete/toggle +4. **File writing** with backups and atomic updates +5. **Input validation** in real time -**Phase 1 is fully complete with a production-ready parser foundation.** +**Phase 2 delivered a functional view-only TUI.** ### Parser Achievement Summary - **54 comprehensive tests** covering all hosts file variations and edge cases @@ -197,4 +197,4 @@ - **Search and management** capabilities for finding and manipulating entries - **Demo application** showcasing all functionality with realistic examples -The foundation is robust, tested, and ready for TUI implementation in Phase 2. +The foundation is robust, tested, and ready for edit mode implementation in Phase 3. diff --git a/tests/tui_test.go b/tests/tui_test.go new file mode 100644 index 0000000..a73719a --- /dev/null +++ b/tests/tui_test.go @@ -0,0 +1,87 @@ +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) +} + +func TestViewModeStatusBar(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) + nm, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + m = nm.(tui.Model) + view := m.View() + + assert.Contains(t, view, "VIEW MODE") + assert.Contains(t, view, "[✓] localhost") + assert.Contains(t, view, "[ ] example.com") +} + +func TestPaneSwitching(t *testing.T) { + sample := "127.0.0.1 localhost\n192.168.1.10 example.com" + lines := strings.Split(sample, "\n") + hf, _, err := core.ParseHostsContent(lines) + require.NoError(t, err) + + m := tui.NewModel(hf) + // Switch focus to detail pane + nm, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab}) + m = nm.(tui.Model) + + // Attempt to move down; selection should remain on first entry + nm, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = nm.(tui.Model) + assert.Equal(t, "localhost", m.SelectedEntry().Hostname) +} + +func TestEditModeToggleAndActivation(t *testing.T) { + sample := "127.0.0.1 localhost\n192.168.1.10 example.com" + lines := strings.Split(sample, "\n") + hf, _, err := core.ParseHostsContent(lines) + require.NoError(t, err) + + m := tui.NewModel(hf) + + // enter edit mode + nm, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}) + m = nm.(tui.Model) + assert.Equal(t, tui.EditMode, m.Mode()) + + // toggle active state of first entry + nm, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) + m = nm.(tui.Model) + assert.False(t, m.SelectedEntry().Active) + + // status bar should reflect edit mode + view := m.View() + assert.Contains(t, view, "EDIT MODE") +}