mirror of
https://github.com/lukaszraczylo/lolcathost.git
synced 2026-07-10 04:43:11 +00:00
bugfix: Deleted hosts were lingering in the TUI list.
This commit is contained in:
@@ -219,6 +219,21 @@ func TestServer_HandleDelete(t *testing.T) {
|
|||||||
assert.Equal(t, "ok", resp.Status)
|
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) {
|
t.Run("delete nonexistent", func(t *testing.T) {
|
||||||
req, _ := protocol.NewRequest(protocol.RequestDelete, protocol.DeletePayload{
|
req, _ := protocol.NewRequest(protocol.RequestDelete, protocol.DeletePayload{
|
||||||
Alias: "nonexistent",
|
Alias: "nonexistent",
|
||||||
|
|||||||
+68
-9
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/lolcathost/internal/client"
|
"github.com/lukaszraczylo/lolcathost/internal/client"
|
||||||
"github.com/lukaszraczylo/lolcathost/internal/config"
|
"github.com/lukaszraczylo/lolcathost/internal/config"
|
||||||
@@ -27,6 +28,7 @@ const (
|
|||||||
ViewBackups
|
ViewBackups
|
||||||
ViewHelp
|
ViewHelp
|
||||||
ViewSearch
|
ViewSearch
|
||||||
|
ViewConfirmDelete
|
||||||
)
|
)
|
||||||
|
|
||||||
// Model is the main Bubble Tea model.
|
// Model is the main Bubble Tea model.
|
||||||
@@ -45,13 +47,14 @@ type Model struct {
|
|||||||
searchInput textinput.Model
|
searchInput textinput.Model
|
||||||
|
|
||||||
// State
|
// State
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
message string
|
message string
|
||||||
messageStyle string // "error" or "success"
|
messageStyle string // "error" or "success"
|
||||||
messageTime time.Time
|
messageTime time.Time
|
||||||
searchTerm string
|
searchTerm string
|
||||||
allGroups []string // All groups including empty ones
|
allGroups []string // All groups including empty ones
|
||||||
|
pendingDeleteAlias string // Alias of host pending delete confirmation
|
||||||
|
|
||||||
// Update notification
|
// Update notification
|
||||||
updateAvailable bool
|
updateAvailable bool
|
||||||
@@ -352,7 +355,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
// Mark as disconnected to trigger reconnect
|
// Mark as disconnected to trigger reconnect
|
||||||
m.connected = false
|
m.connected = false
|
||||||
m.client.Close()
|
m.client.Close()
|
||||||
} else if msg.entries != nil {
|
} else {
|
||||||
|
// Always update the list, even if entries is nil/empty
|
||||||
m.list.SetItems(msg.entries)
|
m.list.SetItems(msg.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,7 +389,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.mode = ViewList
|
m.mode = ViewList
|
||||||
|
|
||||||
case deleteMsg:
|
case deleteMsg:
|
||||||
|
// Clear pending state regardless of success/failure
|
||||||
|
m.list.SetPending(msg.alias, false)
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
|
m.list.SetError(msg.alias, true)
|
||||||
m.setError(fmt.Sprintf("Delete failed: %v", msg.err))
|
m.setError(fmt.Sprintf("Delete failed: %v", msg.err))
|
||||||
} else {
|
} else {
|
||||||
cmds = append(cmds, m.refresh())
|
cmds = append(cmds, m.refresh())
|
||||||
@@ -521,6 +528,8 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd {
|
|||||||
return m.handleHelpKey(msg)
|
return m.handleHelpKey(msg)
|
||||||
case ViewSearch:
|
case ViewSearch:
|
||||||
return m.handleSearchKey(msg)
|
return m.handleSearchKey(msg)
|
||||||
|
case ViewConfirmDelete:
|
||||||
|
return m.handleConfirmDeleteKey(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -554,7 +563,8 @@ func (m *Model) handleListKey(msg tea.KeyMsg) tea.Cmd {
|
|||||||
}
|
}
|
||||||
case "d":
|
case "d":
|
||||||
if item := m.list.Selected(); item != nil {
|
if item := m.list.Selected(); item != nil {
|
||||||
return m.deleteHost(item.Entry.Alias)
|
m.pendingDeleteAlias = item.Entry.Alias
|
||||||
|
m.mode = ViewConfirmDelete
|
||||||
}
|
}
|
||||||
case "p":
|
case "p":
|
||||||
m.mode = ViewPresets
|
m.mode = ViewPresets
|
||||||
@@ -850,6 +860,23 @@ func (m *Model) handleSearchKey(msg tea.KeyMsg) tea.Cmd {
|
|||||||
return 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 {
|
func (m *Model) toggleSelected() tea.Cmd {
|
||||||
item := m.list.Selected()
|
item := m.list.Selected()
|
||||||
if item == nil {
|
if item == nil {
|
||||||
@@ -904,6 +931,8 @@ func (m *Model) View() string {
|
|||||||
sb.WriteString(m.helpView())
|
sb.WriteString(m.helpView())
|
||||||
case ViewSearch:
|
case ViewSearch:
|
||||||
sb.WriteString(m.searchView())
|
sb.WriteString(m.searchView())
|
||||||
|
case ViewConfirmDelete:
|
||||||
|
sb.WriteString(m.confirmDeleteView())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message
|
// Message
|
||||||
@@ -1077,6 +1106,36 @@ func (m *Model) searchView() string {
|
|||||||
return dialogStyle.Render(sb.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.
|
// Run starts the TUI application.
|
||||||
func Run(socketPath string) error {
|
func Run(socketPath string) error {
|
||||||
return RunWithVersion(socketPath, "dev", "", "")
|
return RunWithVersion(socketPath, "dev", "", "")
|
||||||
|
|||||||
@@ -407,3 +407,72 @@ func BenchmarkListView_View(b *testing.B) {
|
|||||||
_ = lv.View()
|
_ = 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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user