From a24041d6643b03203f303c574c8358f7388e884f Mon Sep 17 00:00:00 2001 From: Philip Henning Date: Wed, 13 Aug 2025 15:57:35 +0200 Subject: [PATCH 1/2] Implement TUI pane navigation --- internal/tui/model.go | 47 +++++++++++++++++++++++++++++++++--- internal/tui/update.go | 42 +++++++++++++++++++++++++++++--- internal/tui/view.go | 34 +++++++++----------------- memory-bank/activeContext.md | 33 ++++++++----------------- memory-bank/progress.md | 37 ++++++++++++++-------------- tests/tui_test.go | 17 +++++++++++++ 6 files changed, 137 insertions(+), 73 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 49124a6..acfcfb2 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -3,8 +3,10 @@ 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" ) @@ -30,12 +32,21 @@ const ( 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. @@ -51,11 +62,39 @@ func NewModel(hf *core.HostsFile) Model { l.SetShowHelp(false) l.SetShowPagination(false) - return Model{ - list: l, - hosts: hf, - mode: ViewMode, + 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. diff --git a/internal/tui/update.go b/internal/tui/update.go index 80dc1df..909e5b5 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -12,13 +12,47 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 "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 - m.list.SetSize(msg.Width/2, msg.Height) + m.height = msg.Height - 1 + m.list.SetSize(msg.Width/2, m.height) + m.detail.Width = msg.Width - msg.Width/2 + m.detail.Height = m.height + 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() + } } - - m.list, cmd = m.list.Update(msg) return m, cmd } diff --git a/internal/tui/view.go b/internal/tui/view.go index d0d7582..b6a27cd 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -2,41 +2,29 @@ package tui import ( "fmt" - "strings" "github.com/charmbracelet/lipgloss" ) var ( - listStyle = lipgloss.NewStyle().Padding(0, 1) - detailStyle = lipgloss.NewStyle().Padding(0, 1) - statusStyle = lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.Color("240")).Background(lipgloss.Color("236")) + 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() - - 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) - } + detailView := m.detail.View() 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()) + right := detailStyle.Width(m.width - m.width/2).Height(m.height).Render(detailView) + if m.focus == listPane { + left = focusedStyle.Render(left) + } else { + right = focusedStyle.Render(right) + } panes := lipgloss.JoinHorizontal(lipgloss.Top, left, right) status := fmt.Sprintf("VIEW MODE • %d entries", len(m.hosts.Entries)) diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md index 33e991b..65148e1 100644 --- a/memory-bank/activeContext.md +++ b/memory-bank/activeContext.md @@ -2,8 +2,8 @@ ## Current Work Focus -**Status**: Phase 2 In Progress - Basic TUI prototype implemented -**Priority**: Expand Bubble Tea TUI with navigation and view features +**Status**: Phase 2 Complete - Basic TUI prototype finished +**Priority**: Begin Phase 3 - Edit mode and file integration ## Recent Changes @@ -17,6 +17,12 @@ - ✅ **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 + ### Parser Capabilities Achieved - ✅ **Standard entries**: IPv4, IPv6, multiple aliases, inline comments - ✅ **Disabled entries**: Commented lines with `# IP hostname` format detection @@ -35,33 +41,14 @@ ## Next Steps -### Immediate (Phase 2 - Current Priority) -1. **TUI Architecture Design** ✅ - - Main Bubble Tea model with list and detail panes - - State tracks selection and window size - -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** 🟡 - - Basic keyboard navigation via list component - - Need scroll handling and pane switching - -4. **View Mode Implementation** 🟡 - - Read-only display of `/etc/hosts` entries - - Status bar and active/inactive indicators in list implemented - - Further styling improvements pending - -### 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 2. **File Integration** - - Connect TUI with existing parser functionality + - Connect TUI with existing parser functionality for writes - Real-time display of actual `/etc/hosts` content - Live validation and formatting preview diff --git a/memory-bank/progress.md b/memory-bank/progress.md index c93c8ff..620cd14 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -44,13 +44,13 @@ ## What's Left to Build -### 🎨 Basic TUI (Phase 2 - Current Priority) +### 🎨 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 - - [ ] **Integration**: Connect TUI with existing parser functionality +- [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 @@ -74,9 +74,9 @@ ## 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**: ~75% (parser and basic TUI implemented) +- **Active work**: Begin edit mode and file integration (Phase 3) - **Blockers**: None - comprehensive parser foundation with 54 tests completed - **Parser status**: Production-ready with all safety features implemented @@ -132,10 +132,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 +179,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 +196,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 index 4c9dda9..b9753ff 100644 --- a/tests/tui_test.go +++ b/tests/tui_test.go @@ -45,3 +45,20 @@ func TestViewModeStatusBar(t *testing.T) { 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) +} From 0bcb821c33ac1f1d71b0f3b74f6c414c97cbf794 Mon Sep 17 00:00:00 2001 From: Philip Henning Date: Wed, 13 Aug 2025 16:16:53 +0200 Subject: [PATCH 2/2] Fix pane border overflow --- internal/tui/update.go | 24 +++++++++++++++++++++--- internal/tui/view.go | 26 +++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/internal/tui/update.go b/internal/tui/update.go index 909e5b5..15b220b 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -42,9 +42,27 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - 1 - m.list.SetSize(msg.Width/2, m.height) - m.detail.Width = msg.Width - msg.Width/2 - m.detail.Height = m.height + + 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) } diff --git a/internal/tui/view.go b/internal/tui/view.go index b6a27cd..ecc371e 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -18,15 +18,35 @@ func (m Model) View() string { listView := m.list.View() detailView := m.detail.View() - left := listStyle.Width(m.width / 2).Height(m.height).Render(listView) - right := detailStyle.Width(m.width - m.width/2).Height(m.height).Render(detailView) + // 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) } - panes := lipgloss.JoinHorizontal(lipgloss.Top, left, right) + // join panes and status bar + panes := lipgloss.JoinHorizontal(lipgloss.Top, left, right) status := fmt.Sprintf("VIEW MODE • %d entries", len(m.hosts.Entries)) bar := statusStyle.Width(m.width).Render(status) return lipgloss.JoinVertical(lipgloss.Left, panes, bar)