refactor: move V1 code into v1/ subdirectory
- git mv cmd/ internal/ pkg/ test/ go.mod go.sum Makefile build.sh docs/ v1/ - Create v1/README.md with V1 documentation - Update root README for V1 + V2 structure - V1 still builds (cd v1 && go build ./cmd/hostkeeper) and 105 tests pass - Root is now clean for V2 development
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
||||
)
|
||||
|
||||
// HostListTab is the host list tab
|
||||
type HostListTab struct {
|
||||
hosts []*models.Host
|
||||
selectedIndex int
|
||||
err error
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
// NewHostListTab creates a new host list tab
|
||||
func NewHostListTab() *HostListTab {
|
||||
return &HostListTab{
|
||||
selectedIndex: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the tab
|
||||
func (t *HostListTab) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Name returns the tab name
|
||||
func (t *HostListTab) Name() string {
|
||||
return "Hosts"
|
||||
}
|
||||
|
||||
// Update handles messages for the host list
|
||||
func (t *HostListTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
t.width = msg.Width
|
||||
t.height = msg.Height
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "up", "k":
|
||||
if t.selectedIndex > 0 {
|
||||
t.selectedIndex--
|
||||
}
|
||||
|
||||
case "down", "j":
|
||||
if t.selectedIndex < len(t.hosts)-1 {
|
||||
t.selectedIndex++
|
||||
}
|
||||
|
||||
case "enter", " ":
|
||||
if len(t.hosts) > 0 {
|
||||
host := t.hosts[t.selectedIndex]
|
||||
return t, func() tea.Msg {
|
||||
return sshConnectToMsg{host: host}
|
||||
}
|
||||
}
|
||||
|
||||
case "ctrl+n":
|
||||
return t, func() tea.Msg {
|
||||
return openHostFormMsg{}
|
||||
}
|
||||
|
||||
case "ctrl+e", "e":
|
||||
if len(t.hosts) > 0 {
|
||||
host := t.hosts[t.selectedIndex]
|
||||
return t, func() tea.Msg {
|
||||
return openHostFormMsg{editing: host}
|
||||
}
|
||||
}
|
||||
|
||||
case "ctrl+f":
|
||||
if len(t.hosts) > 0 {
|
||||
host := t.hosts[t.selectedIndex]
|
||||
return t, func() tea.Msg {
|
||||
return openSFTPMsg{host: host}
|
||||
}
|
||||
}
|
||||
|
||||
case "ctrl+k":
|
||||
return t, func() tea.Msg {
|
||||
return openKeyListMsg{}
|
||||
}
|
||||
|
||||
case "ctrl+p":
|
||||
return t, func() tea.Msg {
|
||||
return openSnippetListMsg{}
|
||||
}
|
||||
|
||||
case "q", "ctrl+c":
|
||||
return t, func() tea.Msg {
|
||||
return quitMsg{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// View renders the host list — responsive layout
|
||||
func (t *HostListTab) View() string {
|
||||
var b strings.Builder
|
||||
|
||||
// Error display
|
||||
if t.err != nil {
|
||||
b.WriteString(ErrorStyle.Render(fmt.Sprintf(" Error: %v ", t.err)))
|
||||
b.WriteString("\n")
|
||||
t.err = nil
|
||||
}
|
||||
|
||||
if len(t.hosts) == 0 {
|
||||
msg := SubtitleStyle.Render("(no connections — press Ctrl+N to add)")
|
||||
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, msg))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Scrolling
|
||||
availH := t.height - 10
|
||||
if availH < 1 {
|
||||
availH = 1
|
||||
}
|
||||
maxHosts := availH
|
||||
if maxHosts > len(t.hosts) {
|
||||
maxHosts = len(t.hosts)
|
||||
}
|
||||
|
||||
start := t.selectedIndex - maxHosts/2
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if start+maxHosts > len(t.hosts) {
|
||||
start = len(t.hosts) - maxHosts
|
||||
}
|
||||
|
||||
// Responsive width calculation
|
||||
sidePad := adaptiveSidePad(t.width)
|
||||
titlePlain := "Connection List"
|
||||
|
||||
// Measure content rows to determine natural box width
|
||||
widestContent := lipgloss.Width(titlePlain)
|
||||
for i := start; i < start+maxHosts; i++ {
|
||||
host := t.hosts[i]
|
||||
raw := fmt.Sprintf(" %-12s %-18s :%d", host.Name, host.Hostname, host.Port)
|
||||
if w := lipgloss.Width(raw); w > widestContent {
|
||||
widestContent = w
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp box to terminal width
|
||||
targetW := clampWidth(widestContent+sidePad*2, t.width)
|
||||
innerW := targetW - sidePad*2
|
||||
if innerW < 1 {
|
||||
innerW = 1
|
||||
}
|
||||
|
||||
// Build rows with adaptive format
|
||||
type styledRow struct {
|
||||
text string
|
||||
plain string
|
||||
}
|
||||
var rows []styledRow
|
||||
|
||||
for i := start; i < start+maxHosts; i++ {
|
||||
host := t.hosts[i]
|
||||
var raw string
|
||||
if innerW >= 35 {
|
||||
raw = fmt.Sprintf(" %-12s %-18s :%d", host.Name, host.Hostname, host.Port)
|
||||
} else {
|
||||
raw = fmt.Sprintf(" %s %s:%d", host.Name, host.Hostname, host.Port)
|
||||
}
|
||||
if lipgloss.Width(raw) > innerW {
|
||||
raw = truncateStr(raw, innerW)
|
||||
}
|
||||
|
||||
var styled string
|
||||
if i == t.selectedIndex {
|
||||
styled = lipgloss.NewStyle().
|
||||
Foreground(gbFg).
|
||||
Background(gbBgSel).
|
||||
Bold(true).
|
||||
Render("▸ " + strings.TrimLeft(raw, " "))
|
||||
} else {
|
||||
styled = lipgloss.NewStyle().Foreground(gbFg).Render(raw)
|
||||
}
|
||||
rows = append(rows, styledRow{text: styled, plain: raw})
|
||||
}
|
||||
|
||||
// Footer (wrapped to fit innerW)
|
||||
footerText := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Enter:SSH Ctrl+N:add Ctrl+E:edit Ctrl+F:SFTP Ctrl+K:keys Ctrl+P:snippets q:quit"
|
||||
footerWrapped := wrapFooter(footerText, innerW)
|
||||
|
||||
// Title
|
||||
title := lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(titlePlain)
|
||||
|
||||
var content strings.Builder
|
||||
|
||||
content.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, title))
|
||||
content.WriteString("\n\n")
|
||||
|
||||
for _, r := range rows {
|
||||
line := lipgloss.PlaceHorizontal(targetW, lipgloss.Center, r.text)
|
||||
content.WriteString(line)
|
||||
content.WriteString("\n")
|
||||
}
|
||||
content.WriteString("\n")
|
||||
|
||||
for _, line := range strings.Split(footerWrapped, "\n") {
|
||||
content.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, SubtitleStyle.Render(line)))
|
||||
content.WriteString("\n")
|
||||
}
|
||||
|
||||
box := BorderStyle.Render(content.String())
|
||||
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Close is a no-op for host list tab
|
||||
func (t *HostListTab) Close() {}
|
||||
|
||||
// SetHosts sets the host list
|
||||
func (t *HostListTab) SetHosts(hosts []*models.Host) {
|
||||
t.hosts = hosts
|
||||
if len(hosts) > 0 && t.selectedIndex >= len(hosts) {
|
||||
t.selectedIndex = len(hosts) - 1
|
||||
}
|
||||
}
|
||||
|
||||
// Hosts returns the host list
|
||||
func (t *HostListTab) Hosts() []*models.Host {
|
||||
return t.hosts
|
||||
}
|
||||
|
||||
// SelectedIndex returns the selected index
|
||||
func (t *HostListTab) SelectedIndex() int {
|
||||
return t.selectedIndex
|
||||
}
|
||||
|
||||
// FindHostListTab finds the first HostListTab in a list of tabs
|
||||
func FindHostListTab(tabs []Tab) *HostListTab {
|
||||
for _, tab := range tabs {
|
||||
if ht, ok := tab.(*HostListTab); ok {
|
||||
return ht
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatTagsForTUI formats tags for TUI display
|
||||
func formatTagsForTUI(tags []string) string {
|
||||
if len(tags) == 0 {
|
||||
return ""
|
||||
}
|
||||
var formatted []string
|
||||
for _, tag := range tags {
|
||||
formatted = append(formatted, "["+tag+"]")
|
||||
}
|
||||
return strings.Join(formatted, " ")
|
||||
}
|
||||
Reference in New Issue
Block a user