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:
swanadiva
2026-07-07 11:56:27 +07:00
parent 8ebdebedc8
commit 847989df75
68 changed files with 58 additions and 116 deletions
+137
View File
@@ -0,0 +1,137 @@
package tui
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
)
// ErrorSeverity indicates the level of an error message
type ErrorSeverity int
const (
SevError ErrorSeverity = iota
SevWarning
SevInfo
)
// ErrorBanner displays a structured error message with title, details, and hints
type ErrorBanner struct {
Title string
Detail string
Hints []string
Severity ErrorSeverity
AutoDismiss bool
DismissAfter time.Duration
createdAt time.Time
visible bool
}
// NewErrorBanner creates a new error banner with the given severity
func NewErrorBanner(severity ErrorSeverity) *ErrorBanner {
return &ErrorBanner{
Severity: severity,
AutoDismiss: true,
DismissAfter: 5 * time.Second,
visible: false,
}
}
// Show displays the error banner with the given message
func (b *ErrorBanner) Show(title, detail string, hints ...string) {
b.Title = title
b.Detail = detail
b.Hints = hints
b.visible = true
b.createdAt = time.Now()
}
// Hide hides the error banner
func (b *ErrorBanner) Hide() {
b.visible = false
}
// IsVisible returns whether the banner is currently visible
func (b *ErrorBanner) IsVisible() bool {
return b.visible
}
// Update checks if auto-dismiss time has elapsed
func (b *ErrorBanner) Update() {
if b.visible && b.AutoDismiss && time.Since(b.createdAt) > b.DismissAfter {
b.visible = false
}
}
// View renders the error banner
func (b *ErrorBanner) View(width int) string {
if !b.visible {
return ""
}
var (
titleStyle lipgloss.Style
borderColor lipgloss.Color
prefix string
)
switch b.Severity {
case SevError:
titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#ea6962")).Bold(true)
borderColor = lipgloss.Color("#ea6962")
prefix = "✖"
case SevWarning:
titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#d8a657")).Bold(true)
borderColor = lipgloss.Color("#d8a657")
prefix = "⚠"
case SevInfo:
titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#7daea3")).Bold(true)
borderColor = lipgloss.Color("#7daea3")
prefix = ""
}
var sb strings.Builder
// Title line with prefix
sb.WriteString(titleStyle.Render(fmt.Sprintf("%s %s", prefix, b.Title)))
// Detail line
if b.Detail != "" {
sb.WriteString("\n")
sb.WriteString(strings.Repeat(" ", len(prefix)+1))
detailStyle := lipgloss.NewStyle().Foreground(activeTheme.Fg)
sb.WriteString(detailStyle.Render(b.Detail))
}
// Hints
if len(b.Hints) > 0 {
sb.WriteString("\n")
sb.WriteString(strings.Repeat(" ", len(prefix)+1))
hintStyle := lipgloss.NewStyle().Foreground(activeTheme.FgMute)
sb.WriteString(hintStyle.Render("Hints:"))
for i, hint := range b.Hints {
sb.WriteString("\n")
sb.WriteString(strings.Repeat(" ", len(prefix)+2))
sb.WriteString(hintStyle.Render(fmt.Sprintf("%d. %s", i+1, hint)))
}
}
// Wrap in a styled box
borderStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(borderColor).
Padding(0, 1).
Width(min(width-2, 80))
return borderStyle.Render(sb.String())
}
// min returns the smaller of two integers
func min(a, b int) int {
if a < b {
return a
}
return b
}
+374
View File
@@ -0,0 +1,374 @@
package tui
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/google/uuid"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
)
type formMode int
const (
formModeAdd formMode = iota
formModeEdit
)
type fieldID int
const (
fieldName fieldID = iota
fieldHostname
fieldPort
fieldUsername
fieldAuthType
fieldPassword
fieldGroup
fieldTags
fieldNotes
fieldCount
)
var fieldLabels = map[fieldID]string{
fieldName: "Name",
fieldHostname: "Hostname",
fieldPort: "Port",
fieldUsername: "Username",
fieldAuthType: "Auth Type",
fieldPassword: "Password",
fieldGroup: "Group",
fieldTags: "Tags",
fieldNotes: "Notes",
}
// HostFormTab is a tab for adding/editing hosts
type HostFormTab struct {
mode formMode
editing *models.Host
dataDir string
inputs []textinput.Model
focus fieldID
width int
height int
err error
saved bool
}
// NewAddHostFormTab creates a new host add form tab
func NewAddHostFormTab(dataDir string) *HostFormTab {
return newHostFormTab(formModeAdd, nil, dataDir)
}
// NewEditHostFormTab creates a new host edit form tab
func NewEditHostFormTab(host *models.Host, dataDir string) *HostFormTab {
return newHostFormTab(formModeEdit, host, dataDir)
}
func newHostFormTab(mode formMode, host *models.Host, dataDir string) *HostFormTab {
inputs := make([]textinput.Model, fieldCount)
for i := range inputs {
inputs[i] = textinput.New()
inputs[i].Prompt = ""
}
inputs[fieldName].Placeholder = "My Server"
inputs[fieldHostname].Placeholder = "192.168.1.1 or server.example.com"
inputs[fieldPort].Placeholder = "22"
inputs[fieldPort].SetValue("22")
inputs[fieldUsername].Placeholder = "root"
inputs[fieldAuthType].SetValue("password")
inputs[fieldPassword].EchoMode = textinput.EchoPassword
inputs[fieldPassword].Placeholder = "Enter password"
inputs[fieldGroup].Placeholder = "production"
inputs[fieldTags].Placeholder = "web,backend"
inputs[fieldNotes].Placeholder = "Optional notes..."
if mode == formModeEdit && host != nil {
inputs[fieldName].SetValue(host.Name)
inputs[fieldHostname].SetValue(host.Hostname)
inputs[fieldPort].SetValue(strconv.Itoa(host.Port))
inputs[fieldUsername].SetValue(host.Username)
inputs[fieldAuthType].SetValue(host.Auth.Type)
if host.Auth.Password != "" {
inputs[fieldPassword].SetValue(host.Auth.Password)
}
inputs[fieldGroup].SetValue(host.Group)
inputs[fieldTags].SetValue(strings.Join(host.Tags, ","))
inputs[fieldNotes].SetValue(host.Notes)
}
inputs[fieldName].Focus()
inputs[fieldName].Prompt = "> "
return &HostFormTab{
mode: mode,
editing: host,
dataDir: dataDir,
inputs: inputs,
}
}
func (t *HostFormTab) Name() string {
if t.mode == formModeEdit {
return "Edit: " + t.editing.Name
}
return "Add Host"
}
func (t *HostFormTab) Init() tea.Cmd {
return textinput.Blink
}
func (t *HostFormTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
if t.saved {
return t, nil
}
switch msg := msg.(type) {
case tea.WindowSizeMsg:
t.width = msg.Width
t.height = msg.Height
case tea.KeyMsg:
switch msg.String() {
case "esc":
return t, func() tea.Msg { return closeFormMsg{} }
case "enter":
if t.focus == fieldAuthType {
t.toggleAuthType()
return t, nil
}
if t.focus == fieldCount-1 {
return t.submit()
}
t.nextField()
case " ", "left", "right":
if t.focus == fieldAuthType {
t.toggleAuthType()
return t, nil
}
// pass through to text input (allow typing spaces, cursor nav)
var cmd tea.Cmd
t.inputs[t.focus], cmd = t.inputs[t.focus].Update(msg)
return t, cmd
case "tab", "down":
t.nextField()
case "shift+tab", "up":
t.prevField()
case "ctrl+s":
return t.submit()
default:
if t.focus == fieldAuthType {
// ignore typing on auth type field
return t, nil
}
var cmd tea.Cmd
t.inputs[t.focus], cmd = t.inputs[t.focus].Update(msg)
return t, cmd
}
}
return t, nil
}
func (t *HostFormTab) nextField() {
t.inputs[t.focus].Blur()
t.inputs[t.focus].Prompt = ""
t.focus++
if t.focus >= fieldCount {
t.focus = fieldCount - 1
}
t.inputs[t.focus].Focus()
t.inputs[t.focus].Prompt = "> "
}
func (t *HostFormTab) prevField() {
t.inputs[t.focus].Blur()
t.inputs[t.focus].Prompt = ""
t.focus--
if t.focus < 0 {
t.focus = 0
}
t.inputs[t.focus].Focus()
t.inputs[t.focus].Prompt = "> "
}
func cycleAuthType(current string) string {
switch current {
case "password":
return "key"
case "key":
return "password"
default:
return "password"
}
}
func (t *HostFormTab) toggleAuthType() {
current := t.inputs[fieldAuthType].Value()
t.inputs[fieldAuthType].SetValue(cycleAuthType(current))
}
func (t *HostFormTab) submit() (Tab, tea.Cmd) {
name := t.inputs[fieldName].Value()
hostname := t.inputs[fieldHostname].Value()
username := t.inputs[fieldUsername].Value()
if name == "" || hostname == "" || username == "" {
t.err = fmt.Errorf("name, hostname, and username are required")
return t, nil
}
port := 22
if p := t.inputs[fieldPort].Value(); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
port = parsed
}
}
authType := t.inputs[fieldAuthType].Value()
if authType == "" {
authType = "password"
}
var tags []string
if tagStr := t.inputs[fieldTags].Value(); tagStr != "" {
for _, tag := range strings.Split(tagStr, ",") {
if trimmed := strings.TrimSpace(tag); trimmed != "" {
tags = append(tags, trimmed)
}
}
}
var host *models.Host
if t.mode == formModeEdit && t.editing != nil {
host = t.editing
host.Name = name
host.Hostname = hostname
host.Port = port
host.Username = username
host.Auth.Type = authType
host.Auth.Password = t.inputs[fieldPassword].Value()
host.Group = t.inputs[fieldGroup].Value()
host.Tags = tags
host.Notes = t.inputs[fieldNotes].Value()
} else {
host = &models.Host{
ID: uuid.New().String(),
Name: name,
Hostname: hostname,
Port: port,
Username: username,
Auth: models.AuthConfig{
Type: authType,
Password: t.inputs[fieldPassword].Value(),
},
Group: t.inputs[fieldGroup].Value(),
Tags: tags,
Notes: t.inputs[fieldNotes].Value(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
}
t.saved = true
return t, saveHostCmd(host, t.dataDir)
}
func (t *HostFormTab) View() string {
contentW := t.width - 12
if contentW < 30 {
contentW = 30
}
if contentW > 70 {
contentW = 70
}
var inner strings.Builder
title := "Add New Host"
if t.mode == formModeEdit {
title = "Edit Host"
}
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(title)))
inner.WriteString("\n\n")
if t.err != nil {
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))))
inner.WriteString("\n\n")
}
for i := fieldID(0); i < fieldCount; i++ {
input := t.inputs[i]
label := fieldLabels[i]
style := SubtitleStyle
if i == t.focus {
style = HighlightStyle
}
inner.WriteString(style.Render(label + ":"))
inner.WriteString("\n")
if i == fieldAuthType {
current := input.Value()
pills := []string{"password", "key"}
var parts []string
for _, p := range pills {
if p == current {
if i == t.focus {
parts = append(parts, SelectedStyle.Render(" "+p+" "))
} else {
parts = append(parts, TagStyle.Render(" "+p+" "))
}
} else {
parts = append(parts, SubtitleStyle.Render(" "+p+" "))
}
}
inner.WriteString(" ")
inner.WriteString(strings.Join(parts, " "))
inner.WriteString("\n")
if i == t.focus {
inner.WriteString(" " + InfoStyle.Render("Space/←/→ to toggle"))
}
inner.WriteString("\n\n")
} else {
renderedInput := input.View()
inner.WriteString(" ")
inner.WriteString(renderedInput)
inner.WriteString("\n\n")
}
}
inner.WriteString("\n")
footerText := "Ctrl+Tab:switch Ctrl+Q:close Tab:next Shift+Tab:prev ↑↓:nav Enter:next Ctrl+S:save Esc:cancel"
footerWrapped := wrapFooter(footerText, contentW)
for _, line := range strings.Split(footerWrapped, "\n") {
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center, SubtitleStyle.Render(line)))
inner.WriteString("\n")
}
box := BorderStyle.Render(inner.String())
var b strings.Builder
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box))
return b.String()
}
func (t *HostFormTab) Close() {}
+266
View File
@@ -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, " ")
}
+251
View File
@@ -0,0 +1,251 @@
package tui
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/google/uuid"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
)
type keyFormMode int
const (
keyFormAdd keyFormMode = iota
keyFormEdit
)
type keyFieldID int
const (
keyFieldName keyFieldID = iota
keyFieldType
keyFieldPrivateKey
keyFieldPassphrase
keyFieldCount
)
var keyFieldLabels = map[keyFieldID]string{
keyFieldName: "Name",
keyFieldType: "Type (rsa/ed25519/ecdsa)",
keyFieldPrivateKey: "Private Key (PEM)",
keyFieldPassphrase: "Passphrase",
}
// KeyFormTab is a tab for adding/editing SSH key pairs
type KeyFormTab struct {
mode keyFormMode
editing *models.KeyPair
dataDir string
inputs []textinput.Model
focus keyFieldID
width int
height int
err error
saved bool
}
func NewAddKeyFormTab(dataDir string) *KeyFormTab {
return newKeyFormTab(keyFormAdd, nil, dataDir)
}
func NewEditKeyFormTab(key *models.KeyPair, dataDir string) *KeyFormTab {
return newKeyFormTab(keyFormEdit, key, dataDir)
}
func newKeyFormTab(mode keyFormMode, key *models.KeyPair, dataDir string) *KeyFormTab {
inputs := make([]textinput.Model, keyFieldCount)
for i := range inputs {
inputs[i] = textinput.New()
inputs[i].Prompt = ""
}
inputs[keyFieldName].Placeholder = "My SSH Key"
inputs[keyFieldType].Placeholder = "ed25519"
inputs[keyFieldType].SetValue("ed25519")
inputs[keyFieldPrivateKey].Placeholder = "-----BEGIN OPENSSH PRIVATE KEY-----"
inputs[keyFieldPassphrase].Placeholder = "Optional passphrase"
if mode == keyFormEdit && key != nil {
inputs[keyFieldName].SetValue(key.Name)
inputs[keyFieldType].SetValue(key.Type)
inputs[keyFieldPrivateKey].SetValue(key.PrivateKey)
inputs[keyFieldPassphrase].SetValue(key.Passphrase)
}
inputs[keyFieldName].Focus()
inputs[keyFieldName].Prompt = "> "
return &KeyFormTab{
mode: mode,
editing: key,
dataDir: dataDir,
inputs: inputs,
}
}
func (t *KeyFormTab) Name() string {
if t.mode == keyFormEdit {
return "Edit Key"
}
return "Add Key"
}
func (t *KeyFormTab) Init() tea.Cmd {
return textinput.Blink
}
func (t *KeyFormTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
if t.saved {
return t, nil
}
switch msg := msg.(type) {
case tea.WindowSizeMsg:
t.width = msg.Width
t.height = msg.Height
case tea.KeyMsg:
switch msg.String() {
case "esc":
return t, func() tea.Msg { return closeFormMsg{} }
case "enter":
if t.focus == keyFieldCount-1 {
return t.submit()
}
t.nextField()
case "tab", "down":
t.nextField()
case "shift+tab", "up":
t.prevField()
case "ctrl+s":
return t.submit()
default:
var cmd tea.Cmd
t.inputs[t.focus], cmd = t.inputs[t.focus].Update(msg)
return t, cmd
}
}
return t, nil
}
func (t *KeyFormTab) nextField() {
t.inputs[t.focus].Blur()
t.inputs[t.focus].Prompt = ""
t.focus++
if t.focus >= keyFieldCount {
t.focus = keyFieldCount - 1
}
t.inputs[t.focus].Focus()
t.inputs[t.focus].Prompt = "> "
}
func (t *KeyFormTab) prevField() {
t.inputs[t.focus].Blur()
t.inputs[t.focus].Prompt = ""
t.focus--
if t.focus < 0 {
t.focus = 0
}
t.inputs[t.focus].Focus()
t.inputs[t.focus].Prompt = "> "
}
func (t *KeyFormTab) submit() (Tab, tea.Cmd) {
name := t.inputs[keyFieldName].Value()
privateKey := t.inputs[keyFieldPrivateKey].Value()
if name == "" || privateKey == "" {
t.err = fmt.Errorf("name and private key are required")
return t, nil
}
var key *models.KeyPair
if t.mode == keyFormEdit && t.editing != nil {
key = t.editing
key.Name = name
key.Type = t.inputs[keyFieldType].Value()
key.PrivateKey = privateKey
key.Passphrase = t.inputs[keyFieldPassphrase].Value()
} else {
key = &models.KeyPair{
ID: uuid.New().String(),
Name: name,
Type: t.inputs[keyFieldType].Value(),
PrivateKey: privateKey,
Passphrase: t.inputs[keyFieldPassphrase].Value(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
}
t.saved = true
return t, saveKeyCmd(key, t.dataDir)
}
func (t *KeyFormTab) View() string {
contentW := t.width - 12
if contentW < 30 {
contentW = 30
}
if contentW > 70 {
contentW = 70
}
var inner strings.Builder
title := "Add SSH Key"
if t.mode == keyFormEdit {
title = "Edit SSH Key"
}
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(title)))
inner.WriteString("\n\n")
if t.err != nil {
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))))
inner.WriteString("\n\n")
}
for i := keyFieldID(0); i < keyFieldCount; i++ {
input := t.inputs[i]
label := keyFieldLabels[i]
style := SubtitleStyle
if i == t.focus {
style = HighlightStyle
}
inner.WriteString(style.Render(label + ":"))
inner.WriteString("\n ")
inner.WriteString(input.View())
inner.WriteString("\n\n")
}
inner.WriteString("\n")
footerText := "Ctrl+Tab:switch Ctrl+Q:close Tab:next Shift+Tab:prev ↑↓:nav Enter:next Ctrl+S:save Esc:cancel"
footerWrapped := wrapFooter(footerText, contentW)
for _, line := range strings.Split(footerWrapped, "\n") {
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center, SubtitleStyle.Render(line)))
inner.WriteString("\n")
}
box := BorderStyle.Render(inner.String())
var b strings.Builder
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box))
return b.String()
}
func (t *KeyFormTab) Close() {}
+267
View File
@@ -0,0 +1,267 @@
package tui
import (
"context"
"fmt"
"strings"
"sync"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
)
type keyListState int
const (
keyListLoading keyListState = iota
keyListReady
keyListError
)
// KeyListTab displays and manages SSH key pairs
type KeyListTab struct {
dataDir string
keys []*models.KeyPair
selected int
state keyListState
err error
width int
height int
mu sync.Mutex
}
func NewKeyListTab(dataDir string) *KeyListTab {
return &KeyListTab{
dataDir: dataDir,
state: keyListLoading,
}
}
func (t *KeyListTab) Name() string { return "SSH Keys" }
func (t *KeyListTab) Init() tea.Cmd {
return func() tea.Msg {
store, err := storage.NewJSONStorage(t.dataDir)
if err != nil {
return keyListLoadedMsg{err: err}
}
keys, err := store.ListKeyPairs(context.Background())
if err != nil {
return keyListLoadedMsg{err: err}
}
return keyListLoadedMsg{keys: keys}
}
}
func (t *KeyListTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
t.mu.Lock()
t.width = msg.Width
t.height = msg.Height
t.mu.Unlock()
case keyListLoadedMsg:
t.mu.Lock()
if msg.err != nil {
t.state = keyListError
t.err = msg.err
} else {
t.state = keyListReady
t.keys = msg.keys
}
t.mu.Unlock()
case saveKeyResultMsg:
return t, t.Init()
case deleteKeyResultMsg:
if msg.err != nil {
t.mu.Lock()
t.err = msg.err
t.mu.Unlock()
}
return t, t.Init()
case tea.KeyMsg:
switch msg.String() {
case "up", "k":
t.mu.Lock()
if t.selected > 0 {
t.selected--
}
t.mu.Unlock()
case "down", "j":
t.mu.Lock()
if t.selected < len(t.keys)-1 {
t.selected++
}
t.mu.Unlock()
case "ctrl+n":
return t, func() tea.Msg { return openKeyFormMsg{} }
case "ctrl+e":
t.mu.Lock()
keys := t.keys
idx := t.selected
t.mu.Unlock()
if len(keys) > 0 && idx >= 0 && idx < len(keys) {
return t, func() tea.Msg { return openKeyFormMsg{editing: keys[idx]} }
}
case "delete", "d":
t.mu.Lock()
keys := t.keys
idx := t.selected
t.mu.Unlock()
if len(keys) > 0 && idx >= 0 && idx < len(keys) {
return t, deleteKeyCmd(keys[idx].ID, t.dataDir)
}
case "esc":
return t, func() tea.Msg { return closeFormMsg{} }
}
}
return t, nil
}
func (t *KeyListTab) View() string {
t.mu.Lock()
defer t.mu.Unlock()
if t.state == keyListLoading {
return lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
SubtitleStyle.Render("Loading keys..."))
}
if t.state == keyListError {
var b strings.Builder
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))))
b.WriteString("\n")
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
SubtitleStyle.Render("Press Esc to go back")))
return b.String()
}
type styledRow struct {
text string
plain string
}
titlePlain := "SSH Key Pairs"
// Build content rows
var rows []styledRow
if t.err != nil {
errLine := fmt.Sprintf("Error: %v", t.err)
rows = append(rows, styledRow{text: ErrorStyle.Render(errLine), plain: errLine})
}
if len(t.keys) == 0 {
empty := "No SSH keys stored."
hint := "Press Ctrl+N to add a new key."
rows = append(rows, styledRow{text: SubtitleStyle.Render(empty), plain: empty})
rows = append(rows, styledRow{text: SubtitleStyle.Render(hint), plain: hint})
} else {
for i, key := range t.keys {
var plain string
if key.Type != "" {
plain = fmt.Sprintf(" %s (%s)", key.Name, key.Type)
} else {
plain = fmt.Sprintf(" %s", key.Name)
}
var styled string
if i == t.selected {
styled = lipgloss.NewStyle().
Foreground(gbFg).
Background(gbBgSel).
Bold(true).
Render("▸ " + strings.TrimLeft(plain, " "))
} else {
styled = lipgloss.NewStyle().Foreground(gbFg).Render(plain)
}
rows = append(rows, styledRow{text: styled, plain: plain})
}
}
// Responsive width
sidePad := adaptiveSidePad(t.width)
widestContent := lipgloss.Width(titlePlain)
for _, r := range rows {
if w := lipgloss.Width(r.plain); w > widestContent {
widestContent = w
}
}
targetW := clampWidth(widestContent+sidePad*2, t.width)
innerW := targetW - sidePad*2
if innerW < 1 {
innerW = 1
}
// Footer (wrapped)
footerText := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Ctrl+N:add Ctrl+E:edit D:delete Esc:back"
footerWrapped := wrapFooter(footerText, innerW)
// Render
var inner strings.Builder
titleStyled := lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(titlePlain)
inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, titleStyled))
inner.WriteString("\n\n")
for _, r := range rows {
plain := r.plain
if lipgloss.Width(plain) > innerW {
plain = truncateStr(plain, innerW)
}
styled := r.text
if lipgloss.Width(r.plain) > innerW {
styled = truncateStr(r.text, innerW)
}
line := lipgloss.PlaceHorizontal(targetW, lipgloss.Center, styled)
inner.WriteString(line)
inner.WriteString("\n")
}
inner.WriteString("\n")
for _, line := range strings.Split(footerWrapped, "\n") {
inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, SubtitleStyle.Render(line)))
inner.WriteString("\n")
}
box := BorderStyle.Render(inner.String())
var b strings.Builder
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box))
return b.String()
}
func (t *KeyListTab) Close() {}
// SetKeys updates the key list data directly (used for refresh)
func (t *KeyListTab) SetKeys(keys []*models.KeyPair) {
t.mu.Lock()
defer t.mu.Unlock()
t.state = keyListReady
t.keys = keys
}
// FindKeyListTab finds the first KeyListTab in a list of tabs
func FindKeyListTab(tabs []Tab) *KeyListTab {
for _, tab := range tabs {
if kt, ok := tab.(*KeyListTab); ok {
return kt
}
}
return nil
}
// keyListLoadedMsg carries the loaded key list
type keyListLoadedMsg struct {
keys []*models.KeyPair
err error
}
+183
View File
@@ -0,0 +1,183 @@
package tui
import (
"context"
tea "github.com/charmbracelet/bubbletea"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
)
// quitMsg signals the TUI to exit
type quitMsg struct{}
// sshConnectToMsg signals the TUI to connect to a host via native SSH
type sshConnectToMsg struct {
host *models.Host
}
// sshExitMsg signals that a native SSH session has ended
type sshExitMsg struct {
err error
}
// openHostFormMsg signals the TUI to open a host form tab
type openHostFormMsg struct {
editing *models.Host // nil for add mode
}
// closeFormMsg signals the TUI to close the active form tab
type closeFormMsg struct{}
// saveHostMsg is produced when the form needs to save a host
type saveHostMsg struct {
host *models.Host
dataDir string
}
// saveHostResultMsg is produced after a save attempt
type saveHostResultMsg struct {
host *models.Host
err error
}
// openSFTPMsg signals the TUI to open an SFTP browser tab
type openSFTPMsg struct {
host *models.Host
}
// loadedHostsMsg is produced after reloading hosts from storage
type loadedHostsMsg struct {
hosts []*models.Host
}
// Key management messages
type openKeyListMsg struct{}
type openKeyFormMsg struct {
editing *models.KeyPair
}
type saveKeyMsg struct {
key *models.KeyPair
dataDir string
}
type saveKeyResultMsg struct {
key *models.KeyPair
err error
}
type deleteKeyMsg struct {
id string
dataDir string
}
type deleteKeyResultMsg struct {
err error
}
// Snippet management messages
type openSnippetListMsg struct{}
type openSnippetFormMsg struct {
editing *models.Snippet
}
type saveSnippetMsg struct {
snippet *models.Snippet
dataDir string
}
type saveSnippetResultMsg struct {
snippet *models.Snippet
err error
}
type deleteSnippetMsg struct {
id string
dataDir string
}
type deleteSnippetResultMsg struct {
err error
}
// saveKeyCmd creates a command that saves a key pair to storage
func saveKeyCmd(key *models.KeyPair, dataDir string) tea.Cmd {
return func() tea.Msg {
store, err := storage.NewJSONStorage(dataDir)
if err != nil {
return saveKeyResultMsg{err: err}
}
ctx := context.Background()
if err := store.SaveKeyPair(ctx, key); err != nil {
return saveKeyResultMsg{err: err}
}
return saveKeyResultMsg{key: key}
}
}
// deleteKeyCmd creates a command that deletes a key pair
func deleteKeyCmd(id, dataDir string) tea.Cmd {
return func() tea.Msg {
store, err := storage.NewJSONStorage(dataDir)
if err != nil {
return deleteKeyResultMsg{err: err}
}
ctx := context.Background()
if err := store.DeleteKeyPair(ctx, id); err != nil {
return deleteKeyResultMsg{err: err}
}
return deleteKeyResultMsg{}
}
}
// saveSnippetCmd creates a command that saves a snippet to storage
func saveSnippetCmd(snippet *models.Snippet, dataDir string) tea.Cmd {
return func() tea.Msg {
store, err := storage.NewJSONStorage(dataDir)
if err != nil {
return saveSnippetResultMsg{err: err}
}
ctx := context.Background()
if err := store.SaveSnippet(ctx, snippet); err != nil {
return saveSnippetResultMsg{err: err}
}
return saveSnippetResultMsg{snippet: snippet}
}
}
// deleteSnippetCmd creates a command that deletes a snippet
func deleteSnippetCmd(id, dataDir string) tea.Cmd {
return func() tea.Msg {
store, err := storage.NewJSONStorage(dataDir)
if err != nil {
return deleteSnippetResultMsg{err: err}
}
ctx := context.Background()
if err := store.DeleteSnippet(ctx, id); err != nil {
return deleteSnippetResultMsg{err: err}
}
return deleteSnippetResultMsg{}
}
}
// saveHostCmd creates a command that saves a host to storage
func saveHostCmd(host *models.Host, dataDir string) tea.Cmd {
return func() tea.Msg {
store, err := storage.NewJSONStorage(dataDir)
if err != nil {
return saveHostResultMsg{err: err}
}
ctx := context.Background()
if err := store.SaveHost(ctx, host); err != nil {
return saveHostResultMsg{err: err}
}
return saveHostResultMsg{host: host}
}
}
// openEncryptPromptMsg shows the encryption setup prompt
type openEncryptPromptMsg struct{}
+218
View File
@@ -0,0 +1,218 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// passwordPromptMode determines what the password prompt is for
type passwordPromptMode int
const (
// passwordModeSetup — first time enabling encryption, ask for new password
passwordModeSetup passwordPromptMode = iota
// passwordModeUnlock — encryption already enabled, ask for existing password
passwordModeUnlock
)
// PasswordPromptTab shows a password prompt for encryption setup or unlock
type PasswordPromptTab struct {
mode passwordPromptMode
inputs []textinput.Model
focus int
width int
height int
err error
dataDir string
onComplete func(password string) // called with password on success
}
// NewPasswordPromptTab creates a new password prompt tab
func NewPasswordPromptTab(mode passwordPromptMode, dataDir string, onComplete func(string)) *PasswordPromptTab {
p := &PasswordPromptTab{
mode: mode,
dataDir: dataDir,
onComplete: onComplete,
}
// Password field
passwordInput := textinput.New()
passwordInput.Placeholder = "Enter master password"
passwordInput.EchoMode = textinput.EchoPassword
passwordInput.EchoCharacter = '•'
passwordInput.Focus()
// Confirm password field (only for setup mode)
confirmInput := textinput.New()
confirmInput.Placeholder = "Confirm password"
confirmInput.EchoMode = textinput.EchoPassword
confirmInput.EchoCharacter = '•'
if mode == passwordModeSetup {
p.inputs = []textinput.Model{passwordInput, confirmInput}
} else {
p.inputs = []textinput.Model{passwordInput}
}
return p
}
func (p *PasswordPromptTab) Name() string {
if p.mode == passwordModeSetup {
return "Setup Encryption"
}
return "Unlock Storage"
}
func (p *PasswordPromptTab) Init() tea.Cmd {
return textinput.Blink
}
func (p *PasswordPromptTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.width = msg.Width
p.height = msg.Height
return p, nil
case tea.KeyMsg:
switch msg.String() {
case "tab", "down":
p.focus++
if p.focus >= len(p.inputs) {
p.focus = 0
}
for i := range p.inputs {
if i == p.focus {
p.inputs[i].Focus()
} else {
p.inputs[i].Blur()
}
}
return p, nil
case "shift+tab", "up":
p.focus--
if p.focus < 0 {
p.focus = len(p.inputs) - 1
}
for i := range p.inputs {
if i == p.focus {
p.inputs[i].Focus()
} else {
p.inputs[i].Blur()
}
}
return p, nil
case "enter":
password := p.inputs[0].Value()
if password == "" {
p.err = fmt.Errorf("password cannot be empty")
return p, nil
}
if p.mode == passwordModeSetup && len(p.inputs) > 1 {
confirm := p.inputs[1].Value()
if password != confirm {
p.err = fmt.Errorf("passwords do not match")
return p, nil
}
}
// Success — call onComplete
if p.onComplete != nil {
p.onComplete(password)
}
return p, func() tea.Msg { return passwordSetMsg{} }
case "esc":
// Cancel — go back or quit
return p, func() tea.Msg { return closeFormMsg{} }
default:
// Update current input
var cmd tea.Cmd
p.inputs[p.focus], cmd = p.inputs[p.focus].Update(msg)
return p, cmd
}
}
return p, nil
}
func (p *PasswordPromptTab) Close() {
// Nothing to clean up
}
func (p *PasswordPromptTab) View() string {
var b strings.Builder
title := "Setup Master Password"
if p.mode == passwordModeUnlock {
title = "Enter Master Password"
}
b.WriteString(lipgloss.PlaceHorizontal(p.width, lipgloss.Center,
AppTitleStyle.Render(title)))
b.WriteString("\n\n")
if p.mode == passwordModeSetup {
b.WriteString(lipgloss.PlaceHorizontal(p.width, lipgloss.Center,
SubtitleStyle.Render("Encrypt all sensitive data (passwords, keys) with AES-256")))
b.WriteString("\n")
b.WriteString(lipgloss.PlaceHorizontal(p.width, lipgloss.Center,
SubtitleStyle.Render("You will need this password to access your data")))
b.WriteString("\n\n")
}
// Input fields
contentW := p.width - 8
if contentW > 60 {
contentW = 60
}
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62")).
Padding(1, 2).
Width(contentW)
var fields strings.Builder
for i, input := range p.inputs {
label := "Password:"
if i == 1 {
label = "Confirm:"
}
fields.WriteString(SubtitleStyle.Render(" " + label))
fields.WriteString("\n")
fields.WriteString(input.View())
fields.WriteString("\n\n")
}
b.WriteString(lipgloss.PlaceHorizontal(p.width, lipgloss.Center, box.Render(fields.String())))
if p.err != nil {
b.WriteString("\n")
b.WriteString(lipgloss.PlaceHorizontal(p.width, lipgloss.Center,
ErrorStyle.Render(p.err.Error())))
}
// Footer
footerText := "Enter:confirm Esc:cancel Tab:next field"
b.WriteString("\n\n")
for _, line := range strings.Split(wrapFooter(footerText, p.width), "\n") {
b.WriteString(lipgloss.PlaceHorizontal(p.width, lipgloss.Center, SubtitleStyle.Render(line)))
}
return b.String()
}
// passwordSetMsg is sent when password is successfully set
type passwordSetMsg struct {
password string
}
+125
View File
@@ -0,0 +1,125 @@
package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
// Responsive breakpoints
const (
widthCompact = 60 // mobile / Termux
widthMedium = 100 // tablet
)
// boxOverhead is the horizontal chars consumed by BorderStyle (border 2 + padding 4)
const boxOverhead = 6
// wrapFooter wraps a footer string into multiple lines that fit availW.
// Words are split on double-space separators (" ") and grouped greedily.
// Returns the wrapped string with "\n" line breaks.
func wrapFooter(text string, availW int) string {
if availW < 1 {
availW = 1
}
if lipgloss.Width(text) <= availW {
return text
}
words := strings.Split(text, " ")
var lines []string
var current strings.Builder
for _, word := range words {
word = strings.TrimSpace(word)
if word == "" {
continue
}
if current.Len() == 0 {
current.WriteString(word)
} else if current.Len()+2+lipgloss.Width(word) <= availW {
current.WriteString(" ")
current.WriteString(word)
} else {
lines = append(lines, current.String())
current.Reset()
current.WriteString(word)
}
}
if current.Len() > 0 {
lines = append(lines, current.String())
}
return strings.Join(lines, "\n")
}
// adaptiveSidePad returns horizontal padding based on terminal width.
// wide: 6, medium: 3, compact: 1
func adaptiveSidePad(termWidth int) int {
switch {
case termWidth < widthCompact:
return 1
case termWidth < widthMedium:
return 3
default:
return 6
}
}
// clampWidth clamps a target box width to fit within the terminal.
// Reserves boxOverhead for border+padding. Enforces a minimum of 20.
func clampWidth(target, termWidth int) int {
maxW := termWidth - boxOverhead
if maxW < 20 {
maxW = 20
}
if target > maxW {
return maxW
}
if target < 20 {
return 20
}
return target
}
// truncateStr truncates a string to maxLen with an ellipsis character.
func truncateStr(s string, maxLen int) string {
if maxLen < 1 {
return ""
}
if lipgloss.Width(s) <= maxLen {
return s
}
if maxLen <= 1 {
return "…"
}
runes := []rune(s)
var result []rune
resultW := 0
for _, r := range runes {
rw := lipgloss.Width(string(r))
if resultW+rw > maxLen-1 {
break
}
result = append(result, r)
resultW += rw
}
return string(result) + "…"
}
// Exported wrappers for testing
// WrapFooter wraps a footer string into multiple lines that fit availW
func WrapFooter(text string, availW int) string {
return wrapFooter(text, availW)
}
// ClampWidth clamps a target box width to fit within the terminal
func ClampWidth(target, termWidth int) int {
return clampWidth(target, termWidth)
}
// TruncateStr truncates a string to maxLen with an ellipsis character
func TruncateStr(s string, maxLen int) string {
return truncateStr(s, maxLen)
}
File diff suppressed because it is too large Load Diff
+259
View File
@@ -0,0 +1,259 @@
package tui
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/google/uuid"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
)
type snippetFormMode int
const (
snippetFormAdd snippetFormMode = iota
snippetFormEdit
)
type snippetFieldID int
const (
snippetFieldName snippetFieldID = iota
snippetFieldCommand
snippetFieldDescription
snippetFieldTags
snippetFieldCount
)
var snippetFieldLabels = map[snippetFieldID]string{
snippetFieldName: "Name",
snippetFieldCommand: "Command",
snippetFieldDescription: "Description",
snippetFieldTags: "Tags (comma-separated)",
}
// SnippetFormTab is a tab for adding/editing command snippets
type SnippetFormTab struct {
mode snippetFormMode
editing *models.Snippet
dataDir string
inputs []textinput.Model
focus snippetFieldID
width int
height int
err error
saved bool
}
func NewAddSnippetFormTab(dataDir string) *SnippetFormTab {
return newSnippetFormTab(snippetFormAdd, nil, dataDir)
}
func NewEditSnippetFormTab(snippet *models.Snippet, dataDir string) *SnippetFormTab {
return newSnippetFormTab(snippetFormEdit, snippet, dataDir)
}
func newSnippetFormTab(mode snippetFormMode, sn *models.Snippet, dataDir string) *SnippetFormTab {
inputs := make([]textinput.Model, snippetFieldCount)
for i := range inputs {
inputs[i] = textinput.New()
inputs[i].Prompt = ""
}
inputs[snippetFieldName].Placeholder = "Check logs"
inputs[snippetFieldCommand].Placeholder = "journalctl -u nginx --no-pager -n 100"
inputs[snippetFieldDescription].Placeholder = "View last 100 nginx log entries"
inputs[snippetFieldTags].Placeholder = "nginx,logs,troubleshooting"
if mode == snippetFormEdit && sn != nil {
inputs[snippetFieldName].SetValue(sn.Name)
inputs[snippetFieldCommand].SetValue(sn.Command)
inputs[snippetFieldDescription].SetValue(sn.Description)
inputs[snippetFieldTags].SetValue(strings.Join(sn.Tags, ","))
}
inputs[snippetFieldName].Focus()
inputs[snippetFieldName].Prompt = "> "
return &SnippetFormTab{
mode: mode,
editing: sn,
dataDir: dataDir,
inputs: inputs,
}
}
func (t *SnippetFormTab) Name() string {
if t.mode == snippetFormEdit {
return "Edit Snippet"
}
return "Add Snippet"
}
func (t *SnippetFormTab) Init() tea.Cmd {
return textinput.Blink
}
func (t *SnippetFormTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
if t.saved {
return t, nil
}
switch msg := msg.(type) {
case tea.WindowSizeMsg:
t.width = msg.Width
t.height = msg.Height
case tea.KeyMsg:
switch msg.String() {
case "esc":
return t, func() tea.Msg { return closeFormMsg{} }
case "enter":
if t.focus == snippetFieldCount-1 {
return t.submit()
}
t.nextField()
case "tab", "down":
t.nextField()
case "shift+tab", "up":
t.prevField()
case "ctrl+s":
return t.submit()
default:
var cmd tea.Cmd
t.inputs[t.focus], cmd = t.inputs[t.focus].Update(msg)
return t, cmd
}
}
return t, nil
}
func (t *SnippetFormTab) nextField() {
t.inputs[t.focus].Blur()
t.inputs[t.focus].Prompt = ""
t.focus++
if t.focus >= snippetFieldCount {
t.focus = snippetFieldCount - 1
}
t.inputs[t.focus].Focus()
t.inputs[t.focus].Prompt = "> "
}
func (t *SnippetFormTab) prevField() {
t.inputs[t.focus].Blur()
t.inputs[t.focus].Prompt = ""
t.focus--
if t.focus < 0 {
t.focus = 0
}
t.inputs[t.focus].Focus()
t.inputs[t.focus].Prompt = "> "
}
func (t *SnippetFormTab) submit() (Tab, tea.Cmd) {
name := t.inputs[snippetFieldName].Value()
command := t.inputs[snippetFieldCommand].Value()
if name == "" || command == "" {
t.err = fmt.Errorf("name and command are required")
return t, nil
}
var tags []string
if tagStr := t.inputs[snippetFieldTags].Value(); tagStr != "" {
for _, tag := range strings.Split(tagStr, ",") {
if trimmed := strings.TrimSpace(tag); trimmed != "" {
tags = append(tags, trimmed)
}
}
}
var sn *models.Snippet
if t.mode == snippetFormEdit && t.editing != nil {
sn = t.editing
sn.Name = name
sn.Command = command
sn.Description = t.inputs[snippetFieldDescription].Value()
sn.Tags = tags
} else {
sn = &models.Snippet{
ID: uuid.New().String(),
Name: name,
Command: command,
Description: t.inputs[snippetFieldDescription].Value(),
Tags: tags,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
}
t.saved = true
return t, saveSnippetCmd(sn, t.dataDir)
}
func (t *SnippetFormTab) View() string {
contentW := t.width - 12
if contentW < 30 {
contentW = 30
}
if contentW > 70 {
contentW = 70
}
var inner strings.Builder
title := "Add Snippet"
if t.mode == snippetFormEdit {
title = "Edit Snippet"
}
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(title)))
inner.WriteString("\n\n")
if t.err != nil {
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))))
inner.WriteString("\n\n")
}
for i := snippetFieldID(0); i < snippetFieldCount; i++ {
input := t.inputs[i]
label := snippetFieldLabels[i]
style := SubtitleStyle
if i == t.focus {
style = HighlightStyle
}
inner.WriteString(style.Render(label + ":"))
inner.WriteString("\n ")
inner.WriteString(input.View())
inner.WriteString("\n\n")
}
inner.WriteString("\n")
footerText := "Ctrl+Tab:switch Ctrl+Q:close Tab:next Shift+Tab:prev ↑↓:nav Enter:next Ctrl+S:save Esc:cancel"
footerWrapped := wrapFooter(footerText, contentW)
for _, line := range strings.Split(footerWrapped, "\n") {
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center, SubtitleStyle.Render(line)))
inner.WriteString("\n")
}
box := BorderStyle.Render(inner.String())
var b strings.Builder
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box))
return b.String()
}
func (t *SnippetFormTab) Close() {}
+262
View File
@@ -0,0 +1,262 @@
package tui
import (
"context"
"fmt"
"strings"
"sync"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
)
type snippetListState int
const (
snippetListLoading snippetListState = iota
snippetListReady
snippetListError
)
// SnippetListTab displays and manages command snippets
type SnippetListTab struct {
dataDir string
snippets []*models.Snippet
selected int
state snippetListState
err error
width int
height int
mu sync.Mutex
}
func NewSnippetListTab(dataDir string) *SnippetListTab {
return &SnippetListTab{
dataDir: dataDir,
state: snippetListLoading,
}
}
func (t *SnippetListTab) Name() string { return "Snippets" }
func (t *SnippetListTab) Init() tea.Cmd {
return func() tea.Msg {
store, err := storage.NewJSONStorage(t.dataDir)
if err != nil {
return snippetListLoadedMsg{err: err}
}
snippets, err := store.ListSnippets(context.Background())
if err != nil {
return snippetListLoadedMsg{err: err}
}
return snippetListLoadedMsg{snippets: snippets}
}
}
func (t *SnippetListTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
t.mu.Lock()
t.width = msg.Width
t.height = msg.Height
t.mu.Unlock()
case snippetListLoadedMsg:
t.mu.Lock()
if msg.err != nil {
t.state = snippetListError
t.err = msg.err
} else {
t.state = snippetListReady
t.snippets = msg.snippets
}
t.mu.Unlock()
case saveSnippetResultMsg:
return t, t.Init()
case deleteSnippetResultMsg:
if msg.err != nil {
t.mu.Lock()
t.err = msg.err
t.mu.Unlock()
}
return t, t.Init()
case tea.KeyMsg:
switch msg.String() {
case "up", "k":
t.mu.Lock()
if t.selected > 0 {
t.selected--
}
t.mu.Unlock()
case "down", "j":
t.mu.Lock()
if t.selected < len(t.snippets)-1 {
t.selected++
}
t.mu.Unlock()
case "ctrl+n":
return t, func() tea.Msg { return openSnippetFormMsg{} }
case "ctrl+e":
t.mu.Lock()
snippets := t.snippets
idx := t.selected
t.mu.Unlock()
if len(snippets) > 0 && idx >= 0 && idx < len(snippets) {
return t, func() tea.Msg { return openSnippetFormMsg{editing: snippets[idx]} }
}
case "delete", "d":
t.mu.Lock()
snippets := t.snippets
idx := t.selected
t.mu.Unlock()
if len(snippets) > 0 && idx >= 0 && idx < len(snippets) {
return t, deleteSnippetCmd(snippets[idx].ID, t.dataDir)
}
case "esc":
return t, func() tea.Msg { return closeFormMsg{} }
}
}
return t, nil
}
func (t *SnippetListTab) View() string {
t.mu.Lock()
defer t.mu.Unlock()
if t.state == snippetListLoading {
return lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
SubtitleStyle.Render("Loading snippets..."))
}
if t.state == snippetListError {
var b strings.Builder
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))))
b.WriteString("\n")
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
SubtitleStyle.Render("Press Esc to go back")))
return b.String()
}
type styledRow struct {
text string
plain string
}
titlePlain := "Command Snippets"
var rows []styledRow
if t.err != nil {
errLine := fmt.Sprintf("Error: %v", t.err)
rows = append(rows, styledRow{text: ErrorStyle.Render(errLine), plain: errLine})
}
if len(t.snippets) == 0 {
empty := "No snippets stored."
hint := "Press Ctrl+N to add a new snippet."
rows = append(rows, styledRow{text: SubtitleStyle.Render(empty), plain: empty})
rows = append(rows, styledRow{text: SubtitleStyle.Render(hint), plain: hint})
} else {
for i, sn := range t.snippets {
var plain string
if sn.Description != "" {
plain = fmt.Sprintf(" %s — %s", sn.Name, sn.Description)
} else {
plain = fmt.Sprintf(" %s", sn.Name)
}
var styled string
if i == t.selected {
styled = lipgloss.NewStyle().
Foreground(gbFg).
Background(gbBgSel).
Bold(true).
Render("▸ " + strings.TrimLeft(plain, " "))
} else {
styled = lipgloss.NewStyle().Foreground(gbFg).Render(plain)
}
rows = append(rows, styledRow{text: styled, plain: plain})
}
}
// Responsive width
sidePad := adaptiveSidePad(t.width)
widestContent := lipgloss.Width(titlePlain)
for _, r := range rows {
if w := lipgloss.Width(r.plain); w > widestContent {
widestContent = w
}
}
targetW := clampWidth(widestContent+sidePad*2, t.width)
innerW := targetW - sidePad*2
if innerW < 1 {
innerW = 1
}
// Footer (wrapped)
footerText := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Ctrl+N:add Ctrl+E:edit D:delete Esc:back"
footerWrapped := wrapFooter(footerText, innerW)
// Render
var inner strings.Builder
titleStyled := lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(titlePlain)
inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, titleStyled))
inner.WriteString("\n\n")
for _, r := range rows {
styled := r.text
if lipgloss.Width(r.plain) > innerW {
styled = truncateStr(r.text, innerW)
}
line := lipgloss.PlaceHorizontal(targetW, lipgloss.Center, styled)
inner.WriteString(line)
inner.WriteString("\n")
}
inner.WriteString("\n")
for _, line := range strings.Split(footerWrapped, "\n") {
inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, SubtitleStyle.Render(line)))
inner.WriteString("\n")
}
box := BorderStyle.Render(inner.String())
var b strings.Builder
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box))
return b.String()
}
func (t *SnippetListTab) Close() {}
// SetSnippets updates the snippet list data directly (used for refresh)
func (t *SnippetListTab) SetSnippets(snippets []*models.Snippet) {
t.mu.Lock()
defer t.mu.Unlock()
t.state = snippetListReady
t.snippets = snippets
}
// FindSnippetListTab finds the first SnippetListTab in a list of tabs
func FindSnippetListTab(tabs []Tab) *SnippetListTab {
for _, tab := range tabs {
if st, ok := tab.(*SnippetListTab); ok {
return st
}
}
return nil
}
// snippetListLoadedMsg carries the loaded snippet list
type snippetListLoadedMsg struct {
snippets []*models.Snippet
err error
}
+137
View File
@@ -0,0 +1,137 @@
package tui
import (
"context"
"fmt"
"os"
"os/exec"
"strconv"
tea "github.com/charmbracelet/bubbletea"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
)
// sshConnectCmd builds and runs a native SSH command via tea.ExecProcess.
// Password auth: sshpass -e ssh user@host (SSHPASS env)
// Key auth: ssh -i <tmpfile> user@host (SSH_ASKPASS for passphrase)
//
// Must return tea.ExecProcess directly (NOT wrapped in another closure)
// so Bubble Tea can execute the process command correctly.
func sshConnectCmd(host *models.Host, dataDir string) tea.Cmd {
port := host.Port
if port == 0 {
port = 22
}
portStr := strconv.Itoa(port)
target := fmt.Sprintf("%s@%s", host.Username, host.Hostname)
ctrlSock := fmt.Sprintf("/tmp/hk-%s", host.ID)
env := os.Environ()
// Common SSH args
sshArgs := []string{
"-p", portStr,
"-o", "StrictHostKeyChecking=accept-new",
"-o", "ServerAliveInterval=60",
"-o", "ServerAliveCountMax=3",
"-S", ctrlSock,
"-o", "ControlMaster=auto",
}
cleanup := func() {
exec.Command("ssh", "-S", ctrlSock, "-O", "exit", target).Run()
}
switch host.Auth.Type {
case "password":
allArgs := append([]string{"-e", "ssh"}, sshArgs...)
allArgs = append(allArgs, target)
cmd := exec.Command("sshpass", allArgs...)
cmd.Env = append(env, "SSHPASS="+host.Auth.Password)
return tea.ExecProcess(cmd, func(err error) tea.Msg {
cleanup()
return sshExitMsg{err: err}
})
case "key":
keyContent, err := loadKeyContent(host, dataDir)
if err != nil {
return errorCmd(fmt.Errorf("load key: %w", err))
}
tmpFile, err := os.CreateTemp("", "hk-key-*")
if err != nil {
return errorCmd(fmt.Errorf("create temp key: %w", err))
}
tmpPath := tmpFile.Name()
if _, err := tmpFile.Write([]byte(keyContent)); err != nil {
tmpFile.Close()
os.Remove(tmpPath)
return errorCmd(fmt.Errorf("write temp key: %w", err))
}
tmpFile.Close()
os.Chmod(tmpPath, 0600)
keyArgs := append([]string{"-i", tmpPath}, sshArgs...)
keyArgs = append(keyArgs, target)
cmd := exec.Command("ssh", keyArgs...)
if host.Auth.Password != "" {
self, err := os.Executable()
if err == nil {
script := fmt.Sprintf("#!/bin/sh\nexec %q askpass\n", self)
f, err := os.CreateTemp("", "hk-askpass-*.sh")
if err == nil {
f.WriteString(script)
f.Close()
os.Chmod(f.Name(), 0700)
env = append(env,
"HK_PASSPHRASE="+host.Auth.Password,
"SSH_ASKPASS="+f.Name(),
"SSH_ASKPASS_REQUIRE=force",
)
if os.Getenv("DISPLAY") == "" {
env = append(env, "DISPLAY=:0")
}
if setsid, err := exec.LookPath("setsid"); err == nil {
newArgs := append([]string{"ssh"}, keyArgs...)
cmd = exec.Command(setsid, newArgs...)
}
}
}
}
cmd.Env = env
return tea.ExecProcess(cmd, func(err error) tea.Msg {
os.Remove(tmpPath)
cleanup()
return sshExitMsg{err: err}
})
default:
return errorCmd(fmt.Errorf("unsupported auth type: %s", host.Auth.Type))
}
}
// errorCmd returns a Cmd that sends an sshExitMsg with the given error.
func errorCmd(err error) tea.Cmd {
return func() tea.Msg {
return sshExitMsg{err: err}
}
}
// loadKeyContent reads the private key content for a host
func loadKeyContent(host *models.Host, dataDir string) (string, error) {
if host.Auth.KeyID == "" {
return "", fmt.Errorf("key auth requires key_id")
}
store, err := storage.NewJSONStorage(dataDir)
if err != nil {
return "", err
}
keyPair, err := store.GetKeyPair(context.Background(), host.Auth.KeyID)
if err != nil {
return "", fmt.Errorf("load key %s: %w", host.Auth.KeyID, err)
}
return keyPair.PrivateKey, nil
}
+59
View File
@@ -0,0 +1,59 @@
package tui
import "github.com/charmbracelet/lipgloss"
// Gruvbox Material Dark Hard palette — warm, soft, easy on eyes
var (
gbFg = lipgloss.Color("#d4be98") // primary text
gbFgMute = lipgloss.Color("#7c6f64") // secondary/hints
gbBgSel = lipgloss.Color("#45403d") // cursor row bg
gbRed = lipgloss.Color("#ea6962") // error/destructive
gbOrange = lipgloss.Color("#e78a4e") // section headers
gbYellow = lipgloss.Color("#d8a657") // accent/titles
gbGreen = lipgloss.Color("#a9b665") // active pane/success
gbAqua = lipgloss.Color("#89b482") // interactive keys
gbBlue = lipgloss.Color("#7daea3")
gbPurple = lipgloss.Color("#d3869b")
gbBorder = lipgloss.Color("#504945") // subtle border
)
// Component styles
var (
TabActiveStyle = lipgloss.NewStyle().Background(gbYellow).Foreground(lipgloss.Color("#1d2021")).Bold(true).Padding(0, 2)
TabInactiveStyle = lipgloss.NewStyle().Background(gbBorder).Foreground(gbFgMute).Padding(0, 2)
TabBarStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1d2021"))
StatusBarStyle = lipgloss.NewStyle().Background(gbGreen).Foreground(lipgloss.Color("#1d2021")).Padding(0, 1)
AppTitleStyle = lipgloss.NewStyle().Foreground(gbYellow).Bold(true)
HighlightStyle = lipgloss.NewStyle().Foreground(gbOrange).Bold(true)
SelectedStyle = lipgloss.NewStyle().Foreground(gbFg).Background(gbBgSel).Bold(true).Padding(0, 1)
ErrorStyle = lipgloss.NewStyle().Foreground(gbRed).Bold(true)
SuccessStyle = lipgloss.NewStyle().Foreground(gbGreen).Bold(true)
InfoStyle = lipgloss.NewStyle().Foreground(gbAqua)
SubtitleStyle = lipgloss.NewStyle().Foreground(gbFgMute)
HostNameStyle = lipgloss.NewStyle().Foreground(gbYellow).Bold(true)
HostDetailStyle = lipgloss.NewStyle().Foreground(gbFgMute)
TagStyle = lipgloss.NewStyle().Foreground(gbGreen)
TitleStyle = AppTitleStyle
SectionStyle = lipgloss.NewStyle().Foreground(gbOrange).Bold(true)
BorderStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(gbBorder).Padding(1, 2)
)
// TabWidth returns the width of the tab bar content
func TabBarWidth(totalWidth int) int {
if totalWidth < 10 {
return totalWidth
}
return totalWidth - 2
}
// Pane styles for dual-pane layout
var (
StylePaneActive = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(gbGreen).Padding(0, 1)
StylePaneInactive = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(gbBorder).Padding(0, 1)
)
// Host card styles
var (
HostCardStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(gbBorder).Padding(0, 1)
HostCardActiveStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(gbGreen).Padding(0, 1)
)
+231
View File
@@ -0,0 +1,231 @@
package tui
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Tab represents a single tab in the TUI
type Tab interface {
Init() tea.Cmd
Update(tea.Msg) (Tab, tea.Cmd)
View() string
Name() string
// Close is called when the tab is removed; implement for cleanup (e.g. disconnect SSH)
Close()
}
// TabManager manages multiple tabs
type TabManager struct {
tabs []Tab
active int
width int
height int
}
// NewTabManager creates a new TabManager with an initial tab
func NewTabManager(initial Tab) *TabManager {
return &TabManager{
tabs: []Tab{initial},
active: 0,
}
}
// Active returns the currently active tab
func (tm *TabManager) Active() Tab {
if len(tm.tabs) == 0 {
return nil
}
return tm.tabs[tm.active]
}
// Add adds a new tab, switches to it, and returns its init command
func (tm *TabManager) Add(tab Tab) tea.Cmd {
tm.tabs = append(tm.tabs, tab)
tm.active = len(tm.tabs) - 1
// Forward current terminal size so new tabs know their dimensions
if tm.width > 0 && tm.height > 0 {
updated, _ := tab.Update(tea.WindowSizeMsg{Width: tm.width, Height: tm.height})
tm.tabs[tm.active] = updated
}
return tab.Init()
}
// Close removes the tab at index and returns the active tab
func (tm *TabManager) Close(index int) Tab {
if index < 0 || index >= len(tm.tabs) {
return nil
}
// Call Close for cleanup (e.g. disconnect SSH)
tm.tabs[index].Close()
tm.tabs = append(tm.tabs[:index], tm.tabs[index+1:]...)
if len(tm.tabs) == 0 {
return nil
}
if tm.active >= len(tm.tabs) {
tm.active = len(tm.tabs) - 1
}
return tm.tabs[tm.active]
}
// CloseActive closes the active tab
func (tm *TabManager) CloseActive() Tab {
if len(tm.tabs) <= 1 {
return nil
}
return tm.Close(tm.active)
}
// Next switches to the next tab
func (tm *TabManager) Next() {
if len(tm.tabs) <= 1 {
return
}
tm.active = (tm.active + 1) % len(tm.tabs)
}
// Prev switches to the previous tab
func (tm *TabManager) Prev() {
if len(tm.tabs) <= 1 {
return
}
tm.active--
if tm.active < 0 {
tm.active = len(tm.tabs) - 1
}
}
// Len returns the number of tabs
func (tm *TabManager) Len() int {
return len(tm.tabs)
}
// SetSize updates the terminal size for the tab manager
func (tm *TabManager) SetSize(width, height int) {
tm.width = width
tm.height = height
}
// Init initializes all tabs
func (tm *TabManager) Init() tea.Cmd {
var cmds []tea.Cmd
for _, t := range tm.tabs {
if cmd := t.Init(); cmd != nil {
cmds = append(cmds, cmd)
}
}
return tea.Batch(cmds...)
}
// Update sends a message to the active tab
func (tm *TabManager) Update(msg tea.Msg) (tea.Cmd, error) {
if len(tm.tabs) == 0 {
return nil, nil
}
// Handle tab-level keys
if keyMsg, ok := msg.(tea.KeyMsg); ok {
switch keyMsg.String() {
case "ctrl+tab":
tm.Next()
return nil, nil
case "shift+tab":
tm.Prev()
return nil, nil
case "ctrl+q":
if closed := tm.CloseActive(); closed != nil {
return nil, nil
}
}
}
// Handle window resize — forward to ALL tabs
if wsMsg, ok := msg.(tea.WindowSizeMsg); ok {
tm.SetSize(wsMsg.Width, wsMsg.Height)
for i, t := range tm.tabs {
updated, _ := t.Update(msg)
tm.tabs[i] = updated
}
return nil, nil
}
updated, cmd := tm.tabs[tm.active].Update(msg)
tm.tabs[tm.active] = updated
return cmd, nil
}
// View renders the tab bar and active tab content
func (tm *TabManager) View() string {
if len(tm.tabs) == 0 {
return ""
}
var b strings.Builder
// Render tab bar
b.WriteString(renderTabBar(tm))
// Render active tab content
content := tm.tabs[tm.active].View()
if content != "" {
b.WriteString("\n")
b.WriteString(content)
}
return b.String()
}
// renderTabBar renders the top tab bar (responsive: truncates names on overflow)
func renderTabBar(tm *TabManager) string {
if len(tm.tabs) == 0 {
return ""
}
const cellPad = 4 // Padding(0,2) per tab = 2 left + 2 right
availW := tm.width - 2
if availW < 1 {
availW = 1
}
// Measure total width and decide if truncation is needed
totalW := 0
for _, tab := range tm.tabs {
totalW += lipgloss.Width(tab.Name()) + cellPad
}
maxNameW := 0
if totalW > availW {
perTab := availW / len(tm.tabs)
maxNameW = perTab - cellPad
if maxNameW < 1 {
maxNameW = 1
}
}
var cells []string
for i, tab := range tm.tabs {
name := tab.Name()
if maxNameW > 0 && lipgloss.Width(name) > maxNameW {
name = truncateStr(name, maxNameW)
}
if i == tm.active {
cells = append(cells, TabActiveStyle.Render(name))
} else {
cells = append(cells, TabInactiveStyle.Render(name))
}
}
bar := strings.Join(cells, "")
return TabBarStyle.Render(bar)
}
+148
View File
@@ -0,0 +1,148 @@
package tui
import "github.com/charmbracelet/lipgloss"
// Theme defines a complete color palette for the TUI
type Theme struct {
Name string
Fg lipgloss.Color
FgMute lipgloss.Color
Bg lipgloss.Color
BgSel lipgloss.Color
Red lipgloss.Color
Orange lipgloss.Color
Yellow lipgloss.Color
Green lipgloss.Color
Aqua lipgloss.Color
Blue lipgloss.Color
Purple lipgloss.Color
Border lipgloss.Color
TabBg lipgloss.Color
}
// Predefined themes
var (
ThemeDark = Theme{
Name: "dark",
Fg: lipgloss.Color("#d4be98"),
FgMute: lipgloss.Color("#7c6f64"),
Bg: lipgloss.Color("#1d2021"),
BgSel: lipgloss.Color("#45403d"),
Red: lipgloss.Color("#ea6962"),
Orange: lipgloss.Color("#e78a4e"),
Yellow: lipgloss.Color("#d8a657"),
Green: lipgloss.Color("#a9b665"),
Aqua: lipgloss.Color("#89b482"),
Blue: lipgloss.Color("#7daea3"),
Purple: lipgloss.Color("#d3869b"),
Border: lipgloss.Color("#504945"),
TabBg: lipgloss.Color("#1d2021"),
}
ThemeLight = Theme{
Name: "light",
Fg: lipgloss.Color("#3c3836"),
FgMute: lipgloss.Color("#7c6f64"),
Bg: lipgloss.Color("#f2e5bc"),
BgSel: lipgloss.Color("#d5c4a1"),
Red: lipgloss.Color("#cc241d"),
Orange: lipgloss.Color("#d65d0e"),
Yellow: lipgloss.Color("#d79921"),
Green: lipgloss.Color("#98971a"),
Aqua: lipgloss.Color("#689d6a"),
Blue: lipgloss.Color("#458588"),
Purple: lipgloss.Color("#b16286"),
Border: lipgloss.Color("#a89984"),
TabBg: lipgloss.Color("#f2e5bc"),
}
ThemeDracula = Theme{
Name: "dracula",
Fg: lipgloss.Color("#f8f8f2"),
FgMute: lipgloss.Color("#6272a4"),
Bg: lipgloss.Color("#282a36"),
BgSel: lipgloss.Color("#44475a"),
Red: lipgloss.Color("#ff5555"),
Orange: lipgloss.Color("#ffb86c"),
Yellow: lipgloss.Color("#f1fa8c"),
Green: lipgloss.Color("#50fa7b"),
Aqua: lipgloss.Color("#8be9fd"),
Blue: lipgloss.Color("#6272a4"),
Purple: lipgloss.Color("#bd93f9"),
Border: lipgloss.Color("#44475a"),
TabBg: lipgloss.Color("#282a36"),
}
)
// Themes is the registry of all available themes
var Themes = map[string]Theme{
"dark": ThemeDark,
"light": ThemeLight,
"dracula": ThemeDracula,
}
// activeTheme holds the currently active theme
var activeTheme = ThemeDark
// GetTheme returns a theme by name, defaults to dark
func GetTheme(name string) Theme {
if t, ok := Themes[name]; ok {
return t
}
return ThemeDark
}
// SetTheme applies a theme by name and updates all component styles
func SetTheme(name string) {
theme := GetTheme(name)
activeTheme = theme
applyTheme(theme)
}
// GetActiveTheme returns the currently active theme
func GetActiveTheme() Theme {
return activeTheme
}
// applyTheme updates all component styles from the given theme
func applyTheme(t Theme) {
// Palette aliases
gbFg = t.Fg
gbFgMute = t.FgMute
gbBgSel = t.BgSel
gbRed = t.Red
gbOrange = t.Orange
gbYellow = t.Yellow
gbGreen = t.Green
gbAqua = t.Aqua
gbBlue = t.Blue
gbPurple = t.Purple
gbBorder = t.Border
// Component styles
TabActiveStyle = lipgloss.NewStyle().Background(t.Yellow).Foreground(t.Bg).Bold(true).Padding(0, 2)
TabInactiveStyle = lipgloss.NewStyle().Background(t.Border).Foreground(t.FgMute).Padding(0, 2)
TabBarStyle = lipgloss.NewStyle().Background(t.TabBg)
StatusBarStyle = lipgloss.NewStyle().Background(t.Green).Foreground(t.Bg).Padding(0, 1)
AppTitleStyle = lipgloss.NewStyle().Foreground(t.Yellow).Bold(true)
HighlightStyle = lipgloss.NewStyle().Foreground(t.Orange).Bold(true)
SelectedStyle = lipgloss.NewStyle().Foreground(t.Fg).Background(t.BgSel).Bold(true).Padding(0, 1)
ErrorStyle = lipgloss.NewStyle().Foreground(t.Red).Bold(true)
SuccessStyle = lipgloss.NewStyle().Foreground(t.Green).Bold(true)
InfoStyle = lipgloss.NewStyle().Foreground(t.Aqua)
SubtitleStyle = lipgloss.NewStyle().Foreground(t.FgMute)
HostNameStyle = lipgloss.NewStyle().Foreground(t.Yellow).Bold(true)
HostDetailStyle = lipgloss.NewStyle().Foreground(t.FgMute)
TagStyle = lipgloss.NewStyle().Foreground(t.Green)
TitleStyle = AppTitleStyle
SectionStyle = lipgloss.NewStyle().Foreground(t.Orange).Bold(true)
BorderStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Border).Padding(1, 2)
// Pane styles
StylePaneActive = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Green).Padding(0, 1)
StylePaneInactive = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Border).Padding(0, 1)
// Host card styles
HostCardStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Border).Padding(0, 1)
HostCardActiveStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Green).Padding(0, 1)
}
+384
View File
@@ -0,0 +1,384 @@
package tui
import (
"context"
tea "github.com/charmbracelet/bubbletea"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/knownhosts"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
)
// Screen represents different TUI screens (deprecated, use tabs)
type Screen int
const (
ScreenHostList Screen = iota
ScreenConnection
ScreenSettings
)
// Model represents the main TUI model
type Model struct {
tabs *TabManager
CurrentScreen Screen // deprecated, kept for backward compat
Hosts []*models.Host // deprecated
SelectedIndex int // deprecated
Error error
Quit bool
dataDir string
program *tea.Program
// Security
storagePassword string
knownHosts *knownhosts.KnownHosts
showEncryptPrompt bool
}
// New creates a new TUI model
func New() *Model {
hostList := NewHostListTab()
tm := NewTabManager(hostList)
return &Model{
tabs: tm,
CurrentScreen: ScreenHostList,
SelectedIndex: 0,
Quit: false,
}
}
// Init initializes the TUI
func (m *Model) Init() tea.Cmd {
if m.showEncryptPrompt {
// Show password prompt tab
tab := NewPasswordPromptTab(passwordModeSetup, m.dataDir, func(password string) {
m.storagePassword = password
})
cmd := m.tabs.Add(tab)
m.showEncryptPrompt = false
return cmd
}
return m.tabs.Init()
}
// SetProgram stores a reference to the tea.Program for sending messages from goroutines
func (m *Model) SetProgram(p *tea.Program) {
m.program = p
}
// SetStoragePassword sets the master password for encrypted storage
func (m *Model) SetStoragePassword(password string) {
m.storagePassword = password
}
// SetKnownHosts sets the known_hosts manager for host key verification
func (m *Model) SetKnownHosts(kh *knownhosts.KnownHosts) {
m.knownHosts = kh
}
// ShowEncryptPrompt sets a flag to show the encryption setup prompt on first render
func (m *Model) ShowEncryptPrompt() {
m.showEncryptPrompt = true
}
// Update handles messages and updates the model
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
// Only hard quit on ctrl+c (let tabs handle q)
if msg.String() == "ctrl+c" {
m.Quit = true
return m, tea.Quit
}
case quitMsg:
m.Quit = true
return m, tea.Quit
case sshConnectToMsg:
return m, tea.Batch(tea.ClearScreen, sshConnectCmd(msg.host, m.dataDir))
case sshExitMsg:
if msg.err != nil {
m.Error = msg.err
}
return m, tea.ClearScreen
case openHostFormMsg:
var tab Tab
if msg.editing != nil {
tab = NewEditHostFormTab(msg.editing, m.dataDir)
} else {
tab = NewAddHostFormTab(m.dataDir)
}
cmd := m.tabs.Add(tab)
return m, cmd
case passwordSetMsg:
// Password was set — store it and load hosts
m.storagePassword = msg.password
// Load hosts with the password
store, err := storage.NewJSONStorage(m.dataDir)
if err == nil {
store.SetPassword(msg.password)
ctx := context.Background()
hosts, loadErr := store.ListHosts(ctx)
if loadErr == nil {
m.Hosts = hosts
// Update host list tab if it exists
if hl, ok := m.tabs.Active().(*HostListTab); ok {
hl.SetHosts(hosts)
}
}
}
// Close the password prompt tab
if m.tabs.Len() > 1 {
m.tabs.CloseActive()
}
return m, nil
case openEncryptPromptMsg:
// Show password setup prompt
tab := NewPasswordPromptTab(passwordModeSetup, m.dataDir, func(password string) {
m.storagePassword = password
})
cmd := m.tabs.Add(tab)
return m, cmd
case openSFTPMsg:
tab := NewSFTPBrowserTab(msg.host, m.dataDir)
if m.program != nil {
tab.SetProgram(m.program)
}
if m.storagePassword != "" {
tab.SetStoragePassword(m.storagePassword)
}
if m.knownHosts != nil {
tab.SetPassphraseCallback(func() string {
// TODO: prompt for passphrase in TUI
return ""
})
}
cmd := m.tabs.Add(tab)
return m, cmd
case openKeyListMsg:
tab := NewKeyListTab(m.dataDir)
cmd := m.tabs.Add(tab)
return m, cmd
case openSnippetListMsg:
tab := NewSnippetListTab(m.dataDir)
cmd := m.tabs.Add(tab)
return m, cmd
case openKeyFormMsg:
var tab Tab
if msg.editing != nil {
tab = NewEditKeyFormTab(msg.editing, m.dataDir)
} else {
tab = NewAddKeyFormTab(m.dataDir)
}
cmd := m.tabs.Add(tab)
return m, cmd
case openSnippetFormMsg:
var tab Tab
if msg.editing != nil {
tab = NewEditSnippetFormTab(msg.editing, m.dataDir)
} else {
tab = NewAddSnippetFormTab(m.dataDir)
}
cmd := m.tabs.Add(tab)
return m, cmd
case closeFormMsg:
if m.tabs.Len() > 1 {
m.tabs.CloseActive()
}
return m, nil
case saveHostResultMsg:
if msg.err != nil {
m.Error = msg.err
return m, nil
}
// Close the form tab, switch back to host list
if m.tabs.Len() > 1 {
m.tabs.CloseActive()
}
// Reload hosts
return m, func() tea.Msg {
store, err := storage.NewJSONStorage(m.dataDir)
if err != nil {
return nil
}
hosts, err := store.ListHosts(context.Background())
if err != nil {
return nil
}
return loadedHostsMsg{hosts: hosts}
}
case loadedHostsMsg:
m.LoadHosts(msg.hosts)
case saveKeyResultMsg:
if msg.err != nil {
m.Error = msg.err
return m, nil
}
// Close form tab and switch back to list
if m.tabs.Len() > 1 {
m.tabs.CloseActive()
}
// Reload keys
return m, func() tea.Msg {
store, err := storage.NewJSONStorage(m.dataDir)
if err != nil {
return nil
}
keys, err := store.ListKeyPairs(context.Background())
if err != nil {
return nil
}
return keyListLoadedMsg{keys: keys}
}
case deleteKeyResultMsg:
if msg.err != nil {
m.Error = msg.err
}
// Reload keys
return m, func() tea.Msg {
store, err := storage.NewJSONStorage(m.dataDir)
if err != nil {
return nil
}
keys, err := store.ListKeyPairs(context.Background())
if err != nil {
return nil
}
return keyListLoadedMsg{keys: keys}
}
case keyListLoadedMsg:
if msg.err != nil {
m.Error = msg.err
return m, nil
}
if kt := FindKeyListTab(m.tabs.tabs); kt != nil {
kt.SetKeys(msg.keys)
}
case saveSnippetResultMsg:
if msg.err != nil {
m.Error = msg.err
return m, nil
}
// Close form tab and switch back to list
if m.tabs.Len() > 1 {
m.tabs.CloseActive()
}
// Reload snippets
return m, func() tea.Msg {
store, err := storage.NewJSONStorage(m.dataDir)
if err != nil {
return nil
}
snippets, err := store.ListSnippets(context.Background())
if err != nil {
return nil
}
return snippetListLoadedMsg{snippets: snippets}
}
case deleteSnippetResultMsg:
if msg.err != nil {
m.Error = msg.err
}
// Reload snippets
return m, func() tea.Msg {
store, err := storage.NewJSONStorage(m.dataDir)
if err != nil {
return nil
}
snippets, err := store.ListSnippets(context.Background())
if err != nil {
return nil
}
return snippetListLoadedMsg{snippets: snippets}
}
case snippetListLoadedMsg:
if msg.err != nil {
m.Error = msg.err
return m, nil
}
if st := FindSnippetListTab(m.tabs.tabs); st != nil {
st.SetSnippets(msg.snippets)
}
}
cmd, err := m.tabs.Update(msg)
if err != nil {
m.Error = err
}
// Sync deprecated fields
if ht := FindHostListTab(m.tabs.tabs); ht != nil {
m.Hosts = ht.Hosts()
m.SelectedIndex = ht.SelectedIndex()
}
return m, cmd
}
// View renders the TUI
func (m *Model) View() string {
if m.Quit {
m.tabs = nil
return "Thanks for using hostkeeper!\n"
}
if m.tabs == nil || m.tabs.Len() == 0 {
return "No tabs open. Press 'q' to quit.\n"
}
// Pass error to host list tab for display
if m.Error != nil {
if ht := FindHostListTab(m.tabs.tabs); ht != nil {
ht.err = m.Error
}
m.Error = nil
}
return m.tabs.View()
}
// LoadHosts loads hosts into the TUI model
func (m *Model) LoadHosts(hosts []*models.Host) {
if m.tabs == nil {
return
}
if ht := FindHostListTab(m.tabs.tabs); ht != nil {
ht.SetHosts(hosts)
m.Hosts = hosts
}
}
// SetDataDir sets the data directory for SSH connections
func (m *Model) SetDataDir(dir string) {
m.dataDir = dir
}
// TabManager returns the underlying tab manager
func (m *Model) TabManager() *TabManager {
return m.tabs
}
+119
View File
@@ -0,0 +1,119 @@
package tui
import (
"testing"
)
func TestTUIInitialization(t *testing.T) {
ui := New()
if ui == nil {
t.Fatal("Failed to initialize TUI")
}
if ui.tabs == nil {
t.Fatal("expected tabs manager to be initialized")
}
if ui.tabs.Len() != 1 {
t.Errorf("expected 1 tab, got %d", ui.tabs.Len())
}
if ui.Quit {
t.Error("expected Quit to be false")
}
// Should have a HostListTab by default
ht := FindHostListTab(ui.tabs.tabs)
if ht == nil {
t.Error("expected HostListTab to be the initial tab")
}
}
func TestTUILoadHosts(t *testing.T) {
ui := New()
if ui == nil {
t.Fatal("Failed to initialize TUI")
}
ui.LoadHosts(nil)
if ui.Hosts != nil {
t.Error("expected Hosts to be nil")
}
// Should still have a valid tab manager
if ui.tabs == nil {
t.Fatal("expected tabs manager to be valid")
}
}
func TestTabManagerBasic(t *testing.T) {
tm := NewTabManager(NewHostListTab())
if tm.Len() != 1 {
t.Errorf("expected 1 tab, got %d", tm.Len())
}
if tm.Active() == nil {
t.Fatal("expected active tab")
}
if tm.Active().Name() != "Hosts" {
t.Errorf("expected 'Hosts', got '%s'", tm.Active().Name())
}
}
func TestTabManagerNavigation(t *testing.T) {
tm := NewTabManager(NewHostListTab())
// Add a second tab
second := NewHostListTab()
tm.Add(second)
if tm.Len() != 2 {
t.Errorf("expected 2 tabs, got %d", tm.Len())
}
// Active should now be the last added tab
if tm.active != 1 {
t.Errorf("expected active index 1, got %d", tm.active)
}
// Previous
tm.Prev()
if tm.active != 0 {
t.Errorf("expected active index 0 after Prev, got %d", tm.active)
}
// Next
tm.Next()
if tm.active != 1 {
t.Errorf("expected active index 1 after Next, got %d", tm.active)
}
}
func TestTabManagerClose(t *testing.T) {
tm := NewTabManager(NewHostListTab())
second := NewHostListTab()
tm.Add(second)
tm.Add(NewHostListTab())
// Close active (last tab)
closed := tm.CloseActive()
if closed == nil {
t.Error("expected closed tab to be returned")
}
if tm.Len() != 2 {
t.Errorf("expected 2 tabs after close, got %d", tm.Len())
}
// Close all tabs except last
tm.Close(0)
if tm.Len() != 1 {
t.Errorf("expected 1 tab after close, got %d", tm.Len())
}
// Should not close the last tab via CloseActive (returns nil)
result := tm.CloseActive()
if result != nil {
t.Error("expected nil when trying to close the last tab")
}
}