This commit is contained in:
1 parent
fa7446e5f7
commit
79c0027833
8 files changed
+219
-61
No files matched your search
@@ -8,7 +8,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"charm.land/huh/v2"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
|
||||
+57
-17
@@ -8,15 +8,15 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/bubbles/v2/list"
|
||||
"charm.land/bubbles/v2/textarea"
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
||||
)
|
||||
|
||||
// SelectedHost is returned when the user confirms a host in the TUI.
|
||||
@@ -78,7 +78,7 @@ func (d compactDelegate) Render(w io.Writer, m list.Model, index int, item list.
|
||||
if index == m.Index() {
|
||||
line = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")).Render("> " + strings.TrimPrefix(line, " "))
|
||||
}
|
||||
fmt.Fprintln(w, line)
|
||||
fmt.Fprint(w, line)
|
||||
}
|
||||
|
||||
// --- filter ---
|
||||
@@ -221,6 +221,7 @@ func (m *Model) loadRecent() tea.Cmd {
|
||||
}
|
||||
|
||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
defer m.resizeList()
|
||||
switch msg := msg.(type) {
|
||||
case noteResultMsg:
|
||||
m.noteBusy = false
|
||||
@@ -240,18 +241,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
extraRows := 4
|
||||
if m.filterOpen {
|
||||
extraRows++
|
||||
}
|
||||
if m.editMode {
|
||||
extraRows += 2
|
||||
}
|
||||
m.list.SetSize(msg.Width, max(1, msg.Height-extraRows))
|
||||
m.input.SetWidth(max(1, msg.Width-lipgloss.Width(m.input.Prompt)))
|
||||
m.filterInput.SetWidth(max(1, msg.Width-lipgloss.Width(m.filterInput.Prompt)))
|
||||
m.editInput.SetWidth(max(1, msg.Width-lipgloss.Width("Connect as: ")))
|
||||
m.noteInput.SetWidth(max(10, msg.Width-4))
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
case tea.KeyPressMsg:
|
||||
if msg.String() == "ctrl+c" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
@@ -427,10 +423,26 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
if m.noteMode {
|
||||
if m.noteBusy {
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.noteInput, cmd = m.noteInput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
// Paste and cursor events are separate from key presses in Bubble Tea v2.
|
||||
// Route them to the focused editor as well.
|
||||
if m.editMode {
|
||||
var cmd tea.Cmd
|
||||
m.editInput, cmd = m.editInput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
if m.filterOpen {
|
||||
var cmd tea.Cmd
|
||||
m.filterInput, cmd = m.filterInput.Update(msg)
|
||||
m.updateListItems(m.lastResults)
|
||||
return m, cmd
|
||||
}
|
||||
// Forward to search input and restart debounce timer.
|
||||
var cmds []tea.Cmd
|
||||
var inputCmd tea.Cmd
|
||||
@@ -449,7 +461,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *Model) View() string {
|
||||
func (m *Model) View() tea.View {
|
||||
view := tea.NewView(m.viewContent())
|
||||
view.AltScreen = true
|
||||
return view
|
||||
}
|
||||
|
||||
func (m *Model) viewContent() string {
|
||||
if m.noteMode {
|
||||
target := "local"
|
||||
if m.noteRemote {
|
||||
@@ -562,6 +580,28 @@ func (m *Model) updateListItems(entries []netbox.HostEntry) {
|
||||
items[i] = hostItem{id: e.ID, name: e.Name, ip: ip, kind: e.Kind, tags: e.Tags}
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
m.resizeList()
|
||||
}
|
||||
|
||||
// resizeList keeps the footer visible when the recent heading or editors change.
|
||||
func (m *Model) resizeList() {
|
||||
if m.width <= 0 || m.height <= 0 {
|
||||
return
|
||||
}
|
||||
extraRows := 5 // title, blank line, search, footer separator, footer
|
||||
if m.recentMode && m.input.Value() == "" && len(m.list.Items()) > 0 {
|
||||
extraRows++
|
||||
}
|
||||
if m.filterOpen {
|
||||
extraRows++
|
||||
}
|
||||
if m.editMode {
|
||||
extraRows++
|
||||
}
|
||||
height := max(1, m.height-extraRows)
|
||||
if m.list.Width() != m.width || m.list.Height() != height {
|
||||
m.list.SetSize(m.width, height)
|
||||
}
|
||||
}
|
||||
|
||||
// openEdit switches to edit mode for the given list item.
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTypingEDoesNotOpenConnectionEditor(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.updateListItems([]netbox.HostEntry{{Name: "server"}})
|
||||
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})
|
||||
m.Update(tea.KeyPressMsg{Code: 'e', Text: "e"})
|
||||
if m.editMode || m.input.Value() != "e" {
|
||||
t.Fatal("e intercepted")
|
||||
}
|
||||
@@ -52,7 +54,7 @@ func TestFailedNoteLoadCannotBeSaved(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.SetNotes(¬es.Service{Dir: t.TempDir()})
|
||||
m.noteMode = true
|
||||
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
|
||||
_, cmd := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl})
|
||||
if cmd != nil {
|
||||
t.Fatal("save allowed before successful load")
|
||||
}
|
||||
@@ -63,7 +65,7 @@ func TestConnectionEditorKeepsOriginalHost(t *testing.T) {
|
||||
first := hostItem{name: "first", ip: "10.0.0.1"}
|
||||
m.openEdit(first)
|
||||
m.updateListItems([]netbox.HostEntry{{Name: "second", PrimaryIP4: "10.0.0.2"}})
|
||||
m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||
if m.selected == nil || m.selected.Name != "first" {
|
||||
t.Fatalf("edited host changed: %+v", m.selected)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
)
|
||||
|
||||
func TestV2PasteFollowsFocusedEditor(t *testing.T) {
|
||||
t.Run("search", func(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
_, cmd := m.Update(tea.PasteMsg{Content: "web-ä"})
|
||||
if m.input.Value() != "web-ä" || m.seq != 1 || cmd == nil {
|
||||
t.Fatalf("paste did not start search: %q, seq %d", m.input.Value(), m.seq)
|
||||
}
|
||||
})
|
||||
t.Run("connection editor", func(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.openEdit(hostItem{name: "web", ip: "10.0.0.1"})
|
||||
m.editInput.SetValue("")
|
||||
m.Update(tea.PasteMsg{Content: "ops@web:2222"})
|
||||
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||
if m.selected == nil || m.selected.User != "ops" || m.selected.Port != "2222" {
|
||||
t.Fatalf("paste lost in editor: %+v", m.selected)
|
||||
}
|
||||
if m.input.Value() != "" {
|
||||
t.Fatal("editor paste changed search")
|
||||
}
|
||||
})
|
||||
t.Run("filter", func(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.lastResults = []netbox.HostEntry{{Name: "vm", Kind: "vm"}, {Name: "device", Kind: "device"}}
|
||||
m.updateListItems(m.lastResults)
|
||||
m.Update(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
|
||||
m.Update(tea.PasteMsg{Content: "kind:vm"})
|
||||
if m.filterInput.Value() != "kind:vm" || len(m.list.Items()) != 1 {
|
||||
t.Fatalf("paste did not filter: %q, %d results", m.filterInput.Value(), len(m.list.Items()))
|
||||
}
|
||||
if m.input.Value() != "" {
|
||||
t.Fatal("filter paste changed search")
|
||||
}
|
||||
})
|
||||
t.Run("notes", func(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.noteMode = true
|
||||
m.input.Blur()
|
||||
m.noteInput.Focus()
|
||||
m.Update(tea.PasteMsg{Content: "First line\nSecond line"})
|
||||
if m.noteInput.Value() != "First line\nSecond line" {
|
||||
t.Fatalf("multiline paste lost: %q", m.noteInput.Value())
|
||||
}
|
||||
m.noteBusy = true
|
||||
m.Update(tea.PasteMsg{Content: "must not change busy note"})
|
||||
if m.noteInput.Value() != "First line\nSecond line" {
|
||||
t.Fatal("paste edited note while loading/saving")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestV2ViewKeepsAlternateScreenInEachMode(t *testing.T) {
|
||||
for _, mode := range []string{"search", "edit", "notes"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
|
||||
if mode == "edit" {
|
||||
m.openEdit(hostItem{name: "web"})
|
||||
}
|
||||
if mode == "notes" {
|
||||
m.noteMode = true
|
||||
}
|
||||
view := m.View()
|
||||
if !view.AltScreen || strings.TrimSpace(view.Content) == "" {
|
||||
t.Fatalf("invalid %s view: %+v", mode, view)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2KeyReleaseDoesNotTypeTwice(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.Update(tea.KeyPressMsg{Code: 'e', Text: "e"})
|
||||
m.Update(tea.KeyReleaseMsg{Code: 'e', Text: "e"})
|
||||
if m.input.Value() != "e" {
|
||||
t.Fatalf("key release changed input: %q", m.input.Value())
|
||||
}
|
||||
m.Update(tea.KeyPressMsg{Code: ' ', Text: " "})
|
||||
if m.input.Value() != "e " {
|
||||
t.Fatalf("space not inserted: %q", m.input.Value())
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2ResizeShowsFullSearchPromptAndFooter(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.Update(tea.WindowSizeMsg{Width: 100, Height: 24})
|
||||
var entries []netbox.HostEntry
|
||||
for range 30 {
|
||||
entries = append(entries, netbox.HostEntry{Name: "web", Kind: "device"})
|
||||
}
|
||||
m.updateListItems(entries)
|
||||
view := m.View()
|
||||
if m.input.Width() != 98 || m.filterInput.Width() != 92 || m.editInput.Width() != 88 {
|
||||
t.Fatalf("editors were not sized for terminal: search=%d filter=%d edit=%d", m.input.Width(), m.filterInput.Width(), m.editInput.Width())
|
||||
}
|
||||
if !strings.Contains(view.Content, "earch hostname") || !strings.Contains(view.Content, "ctrl+c quit") {
|
||||
t.Fatalf("search prompt or footer missing: %s", view.Content)
|
||||
}
|
||||
assertFits := func() {
|
||||
t.Helper()
|
||||
if rows := strings.Count(m.View().Content, "\n"); rows > 24 {
|
||||
t.Fatalf("view overflows terminal: %d rows", rows)
|
||||
}
|
||||
}
|
||||
assertFits()
|
||||
m.lastResults = entries
|
||||
m.Update(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
|
||||
assertFits()
|
||||
m.Update(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
|
||||
m.Update(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl})
|
||||
assertFits()
|
||||
}
|
||||
Reference in new issue
Block a user