From 76e364ca3e9d4508ad0f4aac3a25d7865a1ef096 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sat, 29 Nov 2025 02:38:12 +0000 Subject: [PATCH] bugfix: Deleted hosts were lingering in the TUI list. --- internal/daemon/server_test.go | 15 +++++++ internal/tui/app.go | 77 ++++++++++++++++++++++++++++++---- internal/tui/list_test.go | 69 ++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 9 deletions(-) diff --git a/internal/daemon/server_test.go b/internal/daemon/server_test.go index e223404..85a94d5 100644 --- a/internal/daemon/server_test.go +++ b/internal/daemon/server_test.go @@ -219,6 +219,21 @@ func TestServer_HandleDelete(t *testing.T) { assert.Equal(t, "ok", resp.Status) }) + t.Run("verify deleted entry not in list", func(t *testing.T) { + // After delete, list should not contain the deleted entry + resp := server.handleList() + assert.Equal(t, "ok", resp.Status) + + var data protocol.ListData + err := resp.ParseData(&data) + require.NoError(t, err) + + // Check that the deleted entry is not in the list + for _, entry := range data.Entries { + assert.NotEqual(t, "todelete", entry.Alias, "deleted entry should not appear in list") + } + }) + t.Run("delete nonexistent", func(t *testing.T) { req, _ := protocol.NewRequest(protocol.RequestDelete, protocol.DeletePayload{ Alias: "nonexistent", diff --git a/internal/tui/app.go b/internal/tui/app.go index b19db2b..3d3389f 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/lukaszraczylo/lolcathost/internal/client" "github.com/lukaszraczylo/lolcathost/internal/config" @@ -27,6 +28,7 @@ const ( ViewBackups ViewHelp ViewSearch + ViewConfirmDelete ) // Model is the main Bubble Tea model. @@ -45,13 +47,14 @@ type Model struct { searchInput textinput.Model // State - width int - height int - message string - messageStyle string // "error" or "success" - messageTime time.Time - searchTerm string - allGroups []string // All groups including empty ones + width int + height int + message string + messageStyle string // "error" or "success" + messageTime time.Time + searchTerm string + allGroups []string // All groups including empty ones + pendingDeleteAlias string // Alias of host pending delete confirmation // Update notification updateAvailable bool @@ -352,7 +355,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Mark as disconnected to trigger reconnect m.connected = false m.client.Close() - } else if msg.entries != nil { + } else { + // Always update the list, even if entries is nil/empty m.list.SetItems(msg.entries) } @@ -385,7 +389,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.mode = ViewList case deleteMsg: + // Clear pending state regardless of success/failure + m.list.SetPending(msg.alias, false) if msg.err != nil { + m.list.SetError(msg.alias, true) m.setError(fmt.Sprintf("Delete failed: %v", msg.err)) } else { cmds = append(cmds, m.refresh()) @@ -521,6 +528,8 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { return m.handleHelpKey(msg) case ViewSearch: return m.handleSearchKey(msg) + case ViewConfirmDelete: + return m.handleConfirmDeleteKey(msg) } return nil @@ -554,7 +563,8 @@ func (m *Model) handleListKey(msg tea.KeyMsg) tea.Cmd { } case "d": if item := m.list.Selected(); item != nil { - return m.deleteHost(item.Entry.Alias) + m.pendingDeleteAlias = item.Entry.Alias + m.mode = ViewConfirmDelete } case "p": m.mode = ViewPresets @@ -850,6 +860,23 @@ func (m *Model) handleSearchKey(msg tea.KeyMsg) tea.Cmd { return cmd } +func (m *Model) handleConfirmDeleteKey(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { + case "y", "Y": + alias := m.pendingDeleteAlias + m.pendingDeleteAlias = "" + m.mode = ViewList + // Set pending state for visual feedback + m.list.SetPending(alias, true) + return m.deleteHost(alias) + case "n", "N", "esc": + m.pendingDeleteAlias = "" + m.mode = ViewList + return nil + } + return nil +} + func (m *Model) toggleSelected() tea.Cmd { item := m.list.Selected() if item == nil { @@ -904,6 +931,8 @@ func (m *Model) View() string { sb.WriteString(m.helpView()) case ViewSearch: sb.WriteString(m.searchView()) + case ViewConfirmDelete: + sb.WriteString(m.confirmDeleteView()) } // Message @@ -1077,6 +1106,36 @@ func (m *Model) searchView() string { return dialogStyle.Render(sb.String()) } +func (m *Model) confirmDeleteView() string { + var sb strings.Builder + + sb.WriteString(titleStyle.Render("Confirm Delete")) + sb.WriteString("\n\n") + + // Find the entry details for the pending delete + var domain, ip string + for _, item := range m.list.items { + if item.Entry.Alias == m.pendingDeleteAlias { + domain = item.Entry.Domain + ip = item.Entry.IP + break + } + } + + warningStyle := lipgloss.NewStyle().Foreground(colorWarning).Bold(true) + sb.WriteString(warningStyle.Render("Are you sure you want to delete this host?")) + sb.WriteString("\n\n") + + sb.WriteString(fmt.Sprintf(" Alias: %s\n", helpKeyStyle.Render(m.pendingDeleteAlias))) + sb.WriteString(fmt.Sprintf(" Domain: %s\n", helpDescStyle.Render(domain))) + sb.WriteString(fmt.Sprintf(" IP: %s\n", helpDescStyle.Render(ip))) + + sb.WriteString("\n") + sb.WriteString(helpDescStyle.Render("y confirm • n/Esc cancel")) + + return dialogStyle.Render(sb.String()) +} + // Run starts the TUI application. func Run(socketPath string) error { return RunWithVersion(socketPath, "dev", "", "") diff --git a/internal/tui/list_test.go b/internal/tui/list_test.go index d0577de..0c2d3bd 100644 --- a/internal/tui/list_test.go +++ b/internal/tui/list_test.go @@ -407,3 +407,72 @@ func BenchmarkListView_View(b *testing.B) { _ = lv.View() } } + +func TestListView_DeleteSimulation(t *testing.T) { + lv := NewListView() + + // Initial entries + entries := []protocol.HostEntry{ + {Domain: "a.com", IP: "127.0.0.1", Alias: "a", Enabled: true, Group: "dev"}, + {Domain: "b.com", IP: "127.0.0.1", Alias: "b", Enabled: false, Group: "dev"}, + {Domain: "c.com", IP: "192.168.1.1", Alias: "c", Enabled: true, Group: "staging"}, + } + lv.SetItems(entries) + require.Equal(t, 3, lv.Len()) + + // Select the second item + lv.MoveDown() + selected := lv.Selected() + require.NotNil(t, selected) + require.Equal(t, "b", selected.Entry.Alias) + + // Simulate delete: set new items without the deleted entry + entriesAfterDelete := []protocol.HostEntry{ + {Domain: "a.com", IP: "127.0.0.1", Alias: "a", Enabled: true, Group: "dev"}, + {Domain: "c.com", IP: "192.168.1.1", Alias: "c", Enabled: true, Group: "staging"}, + } + lv.SetItems(entriesAfterDelete) + + // Verify list has only 2 items now + assert.Equal(t, 2, lv.Len()) + + // Verify "b" is no longer in the list + for i := 0; i < lv.Len(); i++ { + assert.NotEqual(t, "b", lv.items[i].Entry.Alias, "deleted entry should not be in list") + } + + // Cursor should be adjusted if needed + assert.LessOrEqual(t, lv.cursor, lv.Len()-1) +} + +func TestListView_SetItemsWithNil(t *testing.T) { + lv := NewListView() + + // Initial entries + entries := []protocol.HostEntry{ + {Domain: "a.com", IP: "127.0.0.1", Alias: "a", Enabled: true, Group: "dev"}, + } + lv.SetItems(entries) + require.Equal(t, 1, lv.Len()) + + // Set nil entries (simulating empty list from server) + lv.SetItems(nil) + assert.Equal(t, 0, lv.Len()) + assert.Equal(t, 0, lv.cursor) +} + +func TestListView_SetItemsWithEmptySlice(t *testing.T) { + lv := NewListView() + + // Initial entries + entries := []protocol.HostEntry{ + {Domain: "a.com", IP: "127.0.0.1", Alias: "a", Enabled: true, Group: "dev"}, + } + lv.SetItems(entries) + require.Equal(t, 1, lv.Len()) + + // Set empty slice + lv.SetItems([]protocol.HostEntry{}) + assert.Equal(t, 0, lv.Len()) + assert.Equal(t, 0, lv.cursor) +}