bugfix: Deleted hosts were lingering in the TUI list.

This commit is contained in:
2025-11-29 02:38:12 +00:00
parent 9598444d56
commit 76e364ca3e
3 changed files with 152 additions and 9 deletions
+15
View File
@@ -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",
+68 -9
View File
@@ -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", "", "")
+69
View File
@@ -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)
}