Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b27388455 | |||
| e5d32bd22e | |||
| 4af555c135 | |||
| f9b2337612 | |||
| 7f197b9e3a |
+1
-1
@@ -39,4 +39,4 @@ Thumbs.db
|
|||||||
*.local.json
|
*.local.json
|
||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
dist/
|
dist/hostkeeper
|
||||||
|
|||||||
+32
-19
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
||||||
)
|
)
|
||||||
@@ -283,18 +284,28 @@ func (t *HostFormTab) submit() (Tab, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *HostFormTab) View() string {
|
func (t *HostFormTab) View() string {
|
||||||
var b strings.Builder
|
contentW := t.width - 12
|
||||||
|
if contentW < 50 {
|
||||||
|
contentW = 50
|
||||||
|
}
|
||||||
|
if contentW > 70 {
|
||||||
|
contentW = 70
|
||||||
|
}
|
||||||
|
|
||||||
|
var inner strings.Builder
|
||||||
|
|
||||||
title := "Add New Host"
|
title := "Add New Host"
|
||||||
if t.mode == formModeEdit {
|
if t.mode == formModeEdit {
|
||||||
title = "Edit Host"
|
title = "Edit Host"
|
||||||
}
|
}
|
||||||
b.WriteString(AppTitleStyle.Render(title))
|
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
|
||||||
b.WriteString("\n\n")
|
lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(title)))
|
||||||
|
inner.WriteString("\n\n")
|
||||||
|
|
||||||
if t.err != nil {
|
if t.err != nil {
|
||||||
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))
|
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
|
||||||
b.WriteString("\n\n")
|
ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))))
|
||||||
|
inner.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := fieldID(0); i < fieldCount; i++ {
|
for i := fieldID(0); i < fieldCount; i++ {
|
||||||
@@ -305,11 +316,10 @@ func (t *HostFormTab) View() string {
|
|||||||
if i == t.focus {
|
if i == t.focus {
|
||||||
style = HighlightStyle
|
style = HighlightStyle
|
||||||
}
|
}
|
||||||
b.WriteString(style.Render(label + ":"))
|
inner.WriteString(style.Render(label + ":"))
|
||||||
b.WriteString("\n")
|
inner.WriteString("\n")
|
||||||
|
|
||||||
if i == fieldAuthType {
|
if i == fieldAuthType {
|
||||||
// Render auth type as toggle pills
|
|
||||||
current := input.Value()
|
current := input.Value()
|
||||||
pills := []string{"password", "key"}
|
pills := []string{"password", "key"}
|
||||||
var parts []string
|
var parts []string
|
||||||
@@ -324,25 +334,28 @@ func (t *HostFormTab) View() string {
|
|||||||
parts = append(parts, SubtitleStyle.Render(" "+p+" "))
|
parts = append(parts, SubtitleStyle.Render(" "+p+" "))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
b.WriteString(" ")
|
inner.WriteString(" ")
|
||||||
b.WriteString(strings.Join(parts, " "))
|
inner.WriteString(strings.Join(parts, " "))
|
||||||
b.WriteString("\n")
|
inner.WriteString("\n")
|
||||||
if i == t.focus {
|
if i == t.focus {
|
||||||
b.WriteString(InfoStyle.Render(" Space/←/→ to toggle"))
|
inner.WriteString(" " + InfoStyle.Render("Space/←/→ to toggle"))
|
||||||
}
|
}
|
||||||
b.WriteString("\n")
|
inner.WriteString("\n\n")
|
||||||
} else {
|
} else {
|
||||||
renderedInput := input.View()
|
renderedInput := input.View()
|
||||||
b.WriteString(" ")
|
inner.WriteString(" ")
|
||||||
b.WriteString(renderedInput)
|
inner.WriteString(renderedInput)
|
||||||
b.WriteString("\n\n")
|
inner.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Help text
|
inner.WriteString("\n")
|
||||||
b.WriteString(SubtitleStyle.Render(
|
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
|
||||||
"↑↓: navigate Enter: next Ctrl+S: save Esc: cancel"))
|
SubtitleStyle.Render("Ctrl+Tab:switch Ctrl+Q:close Tab:next Enter:next Ctrl+S:save Esc:cancel")))
|
||||||
|
|
||||||
|
box := BorderStyle.Render(inner.String())
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box))
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+87
-93
@@ -104,90 +104,120 @@ func (t *HostListTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
|
|||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// View renders the host list
|
// View renders the host list — exactly like tamagosh
|
||||||
func (t *HostListTab) View() string {
|
func (t *HostListTab) View() string {
|
||||||
availW := t.width
|
|
||||||
if availW < 40 {
|
|
||||||
availW = 80
|
|
||||||
}
|
|
||||||
|
|
||||||
cardW := availW - 6
|
|
||||||
if cardW > 74 {
|
|
||||||
cardW = 74
|
|
||||||
}
|
|
||||||
if cardW < 30 {
|
|
||||||
cardW = 30
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
// Show error if any
|
// Error display
|
||||||
if t.err != nil {
|
if t.err != nil {
|
||||||
b.WriteString(ErrorStyle.Render(fmt.Sprintf(" Error: %v ", t.err)))
|
b.WriteString(ErrorStyle.Render(fmt.Sprintf(" Error: %v ", t.err)))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
t.err = nil
|
t.err = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capped vertical padding
|
|
||||||
b.WriteString(strings.Repeat("\n", 2))
|
|
||||||
|
|
||||||
// Title
|
|
||||||
b.WriteString(centerText(AppTitleStyle.Render(" HOSTKEEPER - SSH Manager "), availW))
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
|
|
||||||
if len(t.hosts) == 0 {
|
if len(t.hosts) == 0 {
|
||||||
b.WriteString(centerText(SubtitleStyle.Render("No hosts found. Add with Ctrl+N"), availW))
|
msg := SubtitleStyle.Render("(no connections — press Ctrl+N to add)")
|
||||||
b.WriteString("\n")
|
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, msg))
|
||||||
} else {
|
return b.String()
|
||||||
// Scrolling: show only what fits on screen
|
|
||||||
cardH := 5 // 4 lines per card + 1 blank separator
|
|
||||||
maxCards := (t.height - 8) / cardH
|
|
||||||
if maxCards < 1 {
|
|
||||||
maxCards = 1
|
|
||||||
}
|
|
||||||
if maxCards > len(t.hosts) {
|
|
||||||
maxCards = len(t.hosts)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
start := t.selectedIndex - maxCards/2
|
// Build rows (plain + styled)
|
||||||
|
type styledRow struct {
|
||||||
|
text string
|
||||||
|
plain string
|
||||||
|
}
|
||||||
|
var rows []styledRow
|
||||||
|
|
||||||
|
// 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 {
|
if start < 0 {
|
||||||
start = 0
|
start = 0
|
||||||
}
|
}
|
||||||
if start+maxCards > len(t.hosts) {
|
if start+maxHosts > len(t.hosts) {
|
||||||
start = len(t.hosts) - maxCards
|
start = len(t.hosts) - maxHosts
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := start; i < start+maxCards; i++ {
|
for i := start; i < start+maxHosts; i++ {
|
||||||
host := t.hosts[i]
|
host := t.hosts[i]
|
||||||
entry := renderHostEntry(host, i == t.selectedIndex, cardW)
|
|
||||||
for _, line := range strings.Split(entry, "\n") {
|
// tamagosh format: " %-12s %-18s :%d"
|
||||||
b.WriteString(centerText(line, availW))
|
raw := fmt.Sprintf(" %-12s %-18s :%d", host.Name, host.Hostname, host.Port)
|
||||||
b.WriteString("\n")
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
b.WriteString("\n")
|
rows = append(rows, styledRow{text: styled, plain: raw})
|
||||||
}
|
}
|
||||||
|
|
||||||
if maxCards < len(t.hosts) {
|
// Footer
|
||||||
b.WriteString(centerText(
|
var footer styledRow
|
||||||
SubtitleStyle.Render(fmt.Sprintf("%d of %d hosts", start+1, len(t.hosts))),
|
footerText := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Enter:SSH Ctrl+N:add Ctrl+F:SFTP Ctrl+K:keys Ctrl+P:snippets q:quit"
|
||||||
availW))
|
footer = styledRow{text: SubtitleStyle.Render(footerText), plain: footerText}
|
||||||
|
rows = append(rows, footer) // for widest measurement
|
||||||
|
|
||||||
|
// Title
|
||||||
|
title := lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render("Connection List")
|
||||||
|
titlePlain := "Connection List"
|
||||||
|
|
||||||
|
// tamagosh: widest content line drives the box's natural width
|
||||||
|
widestRow := 0
|
||||||
|
for _, r := range rows {
|
||||||
|
if w := lipgloss.Width(r.plain); w > widestRow {
|
||||||
|
widestRow = w
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
contentW := widestRow
|
||||||
|
if w := lipgloss.Width(footer.plain); w > contentW {
|
||||||
|
contentW = w
|
||||||
|
}
|
||||||
|
if w := lipgloss.Width(titlePlain); w > contentW {
|
||||||
|
contentW = w
|
||||||
|
}
|
||||||
|
const sidePad = 6
|
||||||
|
targetW := contentW + sidePad*2
|
||||||
|
|
||||||
|
var content strings.Builder
|
||||||
|
|
||||||
|
// Centered title
|
||||||
|
content.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, title))
|
||||||
|
content.WriteString("\n\n")
|
||||||
|
|
||||||
|
// Center each row independently within the box
|
||||||
|
for _, r := range rows[:len(rows)-1] { // exclude footer, added below
|
||||||
|
line := lipgloss.PlaceHorizontal(targetW, lipgloss.Center, r.text)
|
||||||
|
content.WriteString(line)
|
||||||
|
content.WriteString("\n")
|
||||||
|
}
|
||||||
|
content.WriteString("\n")
|
||||||
|
|
||||||
|
// Centered footer
|
||||||
|
content.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, footer.text))
|
||||||
|
|
||||||
|
// Wrap in a rounded border box
|
||||||
|
box := BorderStyle.Render(content.String())
|
||||||
|
|
||||||
|
// Center the whole box horizontally
|
||||||
|
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box))
|
||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// centerText centers a line of text horizontally within the given width,
|
|
||||||
// respecting ANSI escape codes for accurate display width.
|
|
||||||
func centerText(s string, width int) string {
|
|
||||||
w := lipgloss.Width(s)
|
|
||||||
if w >= width {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
pad := strings.Repeat(" ", (width-w)/2)
|
|
||||||
return pad + s
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close is a no-op for host list tab
|
// Close is a no-op for host list tab
|
||||||
func (t *HostListTab) Close() {}
|
func (t *HostListTab) Close() {}
|
||||||
|
|
||||||
@@ -219,42 +249,6 @@ func FindHostListTab(tabs []Tab) *HostListTab {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderHostEntry renders a single host as a card
|
|
||||||
func renderHostEntry(host *models.Host, selected bool, width int) string {
|
|
||||||
// Build the card content
|
|
||||||
var inner strings.Builder
|
|
||||||
|
|
||||||
// First line: host name (bold)
|
|
||||||
name := truncateString(host.Name, width-4)
|
|
||||||
inner.WriteString(HostNameStyle.Render(" " + name))
|
|
||||||
inner.WriteString("\n")
|
|
||||||
|
|
||||||
// Second line: user@host:port with tags
|
|
||||||
addr := fmt.Sprintf("%s@%s:%d", host.Username, host.Hostname, host.Port)
|
|
||||||
if len(host.Tags) > 0 {
|
|
||||||
addr += " " + formatTagsForTUI(host.Tags)
|
|
||||||
}
|
|
||||||
inner.WriteString(HostDetailStyle.Render(" " + truncateString(addr, width-2)))
|
|
||||||
|
|
||||||
cardStyle := HostCardStyle
|
|
||||||
if selected {
|
|
||||||
cardStyle = HostCardActiveStyle
|
|
||||||
}
|
|
||||||
|
|
||||||
return cardStyle.Width(width).Render(inner.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// truncateString truncates a string to the given max length, appending "…" if needed
|
|
||||||
func truncateString(s string, maxLen int) string {
|
|
||||||
if maxLen < 1 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if len(s) <= maxLen {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return s[:maxLen-1] + "…"
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatTagsForTUI formats tags for TUI display
|
// formatTagsForTUI formats tags for TUI display
|
||||||
func formatTagsForTUI(tags []string) string {
|
func formatTagsForTUI(tags []string) string {
|
||||||
if len(tags) == 0 {
|
if len(tags) == 0 {
|
||||||
|
|||||||
+26
-11
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
||||||
)
|
)
|
||||||
@@ -191,18 +192,28 @@ func (t *KeyFormTab) submit() (Tab, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *KeyFormTab) View() string {
|
func (t *KeyFormTab) View() string {
|
||||||
var b strings.Builder
|
contentW := t.width - 12
|
||||||
|
if contentW < 50 {
|
||||||
|
contentW = 50
|
||||||
|
}
|
||||||
|
if contentW > 70 {
|
||||||
|
contentW = 70
|
||||||
|
}
|
||||||
|
|
||||||
|
var inner strings.Builder
|
||||||
|
|
||||||
title := "Add SSH Key"
|
title := "Add SSH Key"
|
||||||
if t.mode == keyFormEdit {
|
if t.mode == keyFormEdit {
|
||||||
title = "Edit SSH Key"
|
title = "Edit SSH Key"
|
||||||
}
|
}
|
||||||
b.WriteString(AppTitleStyle.Render(title))
|
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
|
||||||
b.WriteString("\n\n")
|
lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(title)))
|
||||||
|
inner.WriteString("\n\n")
|
||||||
|
|
||||||
if t.err != nil {
|
if t.err != nil {
|
||||||
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))
|
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
|
||||||
b.WriteString("\n\n")
|
ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))))
|
||||||
|
inner.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := keyFieldID(0); i < keyFieldCount; i++ {
|
for i := keyFieldID(0); i < keyFieldCount; i++ {
|
||||||
@@ -212,15 +223,19 @@ func (t *KeyFormTab) View() string {
|
|||||||
if i == t.focus {
|
if i == t.focus {
|
||||||
style = HighlightStyle
|
style = HighlightStyle
|
||||||
}
|
}
|
||||||
b.WriteString(style.Render(label + ":"))
|
inner.WriteString(style.Render(label + ":"))
|
||||||
b.WriteString("\n ")
|
inner.WriteString("\n ")
|
||||||
b.WriteString(input.View())
|
inner.WriteString(input.View())
|
||||||
b.WriteString("\n\n")
|
inner.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString(SubtitleStyle.Render(
|
inner.WriteString("\n")
|
||||||
"↑↓: navigate Enter: next Ctrl+S: save Esc: cancel"))
|
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
|
||||||
|
SubtitleStyle.Render("Ctrl+Tab:switch Ctrl+Q:close Tab:next Enter:next Ctrl+S:save Esc:cancel")))
|
||||||
|
|
||||||
|
box := BorderStyle.Render(inner.String())
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box))
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+70
-26
@@ -7,6 +7,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
||||||
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
|
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
|
||||||
@@ -134,52 +135,95 @@ func (t *KeyListTab) View() string {
|
|||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
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
|
var b strings.Builder
|
||||||
|
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
|
||||||
b.WriteString(AppTitleStyle.Render("SSH Key Pairs"))
|
ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))))
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n")
|
||||||
|
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
|
||||||
switch t.state {
|
SubtitleStyle.Render("Press Esc to go back")))
|
||||||
case keyListLoading:
|
|
||||||
b.WriteString(SubtitleStyle.Render("Loading keys..."))
|
|
||||||
return b.String()
|
|
||||||
case keyListError:
|
|
||||||
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
b.WriteString(SubtitleStyle.Render("Press Esc to go back"))
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type styledRow struct {
|
||||||
|
text string
|
||||||
|
plain string
|
||||||
|
}
|
||||||
|
var rows []styledRow
|
||||||
|
|
||||||
|
title := "SSH Key Pairs"
|
||||||
|
titlePlain := title
|
||||||
|
|
||||||
if t.err != nil {
|
if t.err != nil {
|
||||||
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))
|
errLine := fmt.Sprintf("Error: %v", t.err)
|
||||||
b.WriteString("\n\n")
|
rows = append(rows, styledRow{text: ErrorStyle.Render(errLine), plain: errLine})
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(t.keys) == 0 {
|
if len(t.keys) == 0 {
|
||||||
b.WriteString(SubtitleStyle.Render("No SSH keys stored."))
|
empty := "No SSH keys stored."
|
||||||
b.WriteString("\n")
|
hint := "Press Ctrl+N to add a new key."
|
||||||
b.WriteString(SubtitleStyle.Render("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 {
|
} else {
|
||||||
for i, key := range t.keys {
|
for i, key := range t.keys {
|
||||||
var line string
|
var plain string
|
||||||
if key.Type != "" {
|
if key.Type != "" {
|
||||||
line = fmt.Sprintf(" %s (%s)", key.Name, key.Type)
|
plain = fmt.Sprintf(" %s (%s)", key.Name, key.Type)
|
||||||
} else {
|
} else {
|
||||||
line = fmt.Sprintf(" %s", key.Name)
|
plain = fmt.Sprintf(" %s", key.Name)
|
||||||
}
|
}
|
||||||
|
var styled string
|
||||||
if i == t.selected {
|
if i == t.selected {
|
||||||
b.WriteString(SelectedStyle.Render(line))
|
styled = lipgloss.NewStyle().
|
||||||
|
Foreground(gbFg).
|
||||||
|
Background(gbBgSel).
|
||||||
|
Bold(true).
|
||||||
|
Render("▸ " + strings.TrimLeft(plain, " "))
|
||||||
} else {
|
} else {
|
||||||
b.WriteString(HostNameStyle.Render(line))
|
styled = lipgloss.NewStyle().Foreground(gbFg).Render(plain)
|
||||||
}
|
}
|
||||||
b.WriteString("\n")
|
rows = append(rows, styledRow{text: styled, plain: plain})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString("\n")
|
footerPlain := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Ctrl+N:add Ctrl+E:edit D:delete Esc:back"
|
||||||
b.WriteString(SubtitleStyle.Render("Ctrl+N:add Ctrl+E:edit D:delete Esc:back"))
|
footer := styledRow{text: SubtitleStyle.Render(footerPlain), plain: footerPlain}
|
||||||
|
|
||||||
|
widestRow := lipgloss.Width(titlePlain)
|
||||||
|
for _, r := range rows {
|
||||||
|
if w := lipgloss.Width(r.plain); w > widestRow {
|
||||||
|
widestRow = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w := lipgloss.Width(footer.plain); w > widestRow {
|
||||||
|
widestRow = w
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidePad = 6
|
||||||
|
targetW := widestRow + sidePad*2
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
// Render rows, excluding the footer (last element)
|
||||||
|
for _, r := range rows[:len(rows)-1] {
|
||||||
|
line := lipgloss.PlaceHorizontal(targetW, lipgloss.Center, r.text)
|
||||||
|
inner.WriteString(line)
|
||||||
|
inner.WriteString("\n")
|
||||||
|
}
|
||||||
|
inner.WriteString("\n")
|
||||||
|
inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, footer.text))
|
||||||
|
|
||||||
|
box := BorderStyle.Render(inner.String())
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box))
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-14
@@ -526,20 +526,21 @@ func (t *SFTPBrowserTab) View() string {
|
|||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
if !t.connected && t.err != nil {
|
if !t.connected && t.err != nil {
|
||||||
return fmt.Sprintf("%s\n\n%s",
|
var b strings.Builder
|
||||||
ErrorStyle.Render(fmt.Sprintf("SFTP Error: %v", t.err)),
|
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
|
||||||
SubtitleStyle.Render("Press Esc to close"))
|
ErrorStyle.Render(fmt.Sprintf("SFTP Error: %v", t.err))))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
|
||||||
|
SubtitleStyle.Render("Press Esc to close")))
|
||||||
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
if !t.connected {
|
if !t.connected {
|
||||||
return SubtitleStyle.Render(fmt.Sprintf("Connecting SFTP to %s...", t.host.Name))
|
return lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
|
||||||
|
SubtitleStyle.Render(fmt.Sprintf("Connecting SFTP to %s...", t.host.Name)))
|
||||||
}
|
}
|
||||||
|
|
||||||
contentW := t.width
|
halfW := t.width / 2
|
||||||
if contentW < 40 {
|
|
||||||
contentW = 80
|
|
||||||
}
|
|
||||||
halfW := contentW/2 - 1
|
|
||||||
if halfW < 20 {
|
if halfW < 20 {
|
||||||
halfW = 20
|
halfW = 20
|
||||||
}
|
}
|
||||||
@@ -549,21 +550,24 @@ func (t *SFTPBrowserTab) View() string {
|
|||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
b.WriteString(AppTitleStyle.Render(fmt.Sprintf("SFTP: %s@%s", t.host.Username, t.host.Hostname)))
|
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
|
||||||
|
AppTitleStyle.Render(fmt.Sprintf("SFTP: %s@%s", t.host.Username, t.host.Hostname))))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
|
||||||
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView))
|
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView))
|
||||||
|
|
||||||
if t.err != nil {
|
if t.err != nil {
|
||||||
b.WriteString("\n" + ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))
|
b.WriteString("\n" + lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
|
||||||
|
ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))))
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.transferring && t.transferMsg != "" {
|
if t.transferring && t.transferMsg != "" {
|
||||||
b.WriteString("\n" + InfoStyle.Render(t.transferMsg))
|
b.WriteString("\n" + lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
|
||||||
|
InfoStyle.Render(t.transferMsg)))
|
||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString("\n" + SubtitleStyle.Render(
|
b.WriteString("\n" + lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
|
||||||
"Tab:switch ↑↓:nav /:filter C:copy D:delete N:mkdir R:refresh Esc:close"))
|
SubtitleStyle.Render("Ctrl+Tab:switch Ctrl+Q:close Tab:pane ↑↓:nav /:filter C:copy D:delete N:mkdir R:refresh Esc:close")))
|
||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-11
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
||||||
)
|
)
|
||||||
@@ -199,18 +200,28 @@ func (t *SnippetFormTab) submit() (Tab, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *SnippetFormTab) View() string {
|
func (t *SnippetFormTab) View() string {
|
||||||
var b strings.Builder
|
contentW := t.width - 12
|
||||||
|
if contentW < 50 {
|
||||||
|
contentW = 50
|
||||||
|
}
|
||||||
|
if contentW > 70 {
|
||||||
|
contentW = 70
|
||||||
|
}
|
||||||
|
|
||||||
|
var inner strings.Builder
|
||||||
|
|
||||||
title := "Add Snippet"
|
title := "Add Snippet"
|
||||||
if t.mode == snippetFormEdit {
|
if t.mode == snippetFormEdit {
|
||||||
title = "Edit Snippet"
|
title = "Edit Snippet"
|
||||||
}
|
}
|
||||||
b.WriteString(AppTitleStyle.Render(title))
|
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
|
||||||
b.WriteString("\n\n")
|
lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(title)))
|
||||||
|
inner.WriteString("\n\n")
|
||||||
|
|
||||||
if t.err != nil {
|
if t.err != nil {
|
||||||
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))
|
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
|
||||||
b.WriteString("\n\n")
|
ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))))
|
||||||
|
inner.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := snippetFieldID(0); i < snippetFieldCount; i++ {
|
for i := snippetFieldID(0); i < snippetFieldCount; i++ {
|
||||||
@@ -220,15 +231,19 @@ func (t *SnippetFormTab) View() string {
|
|||||||
if i == t.focus {
|
if i == t.focus {
|
||||||
style = HighlightStyle
|
style = HighlightStyle
|
||||||
}
|
}
|
||||||
b.WriteString(style.Render(label + ":"))
|
inner.WriteString(style.Render(label + ":"))
|
||||||
b.WriteString("\n ")
|
inner.WriteString("\n ")
|
||||||
b.WriteString(input.View())
|
inner.WriteString(input.View())
|
||||||
b.WriteString("\n\n")
|
inner.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString(SubtitleStyle.Render(
|
inner.WriteString("\n")
|
||||||
"↑↓: navigate Enter: next Ctrl+S: save Esc: cancel"))
|
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
|
||||||
|
SubtitleStyle.Render("Ctrl+Tab:switch Ctrl+Q:close Tab:next Enter:next Ctrl+S:save Esc:cancel")))
|
||||||
|
|
||||||
|
box := BorderStyle.Render(inner.String())
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box))
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+71
-28
@@ -7,6 +7,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
||||||
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
|
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
|
||||||
@@ -134,53 +135,95 @@ func (t *SnippetListTab) View() string {
|
|||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
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
|
var b strings.Builder
|
||||||
|
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
|
||||||
b.WriteString(AppTitleStyle.Render("Command Snippets"))
|
ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))))
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n")
|
||||||
|
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
|
||||||
switch t.state {
|
SubtitleStyle.Render("Press Esc to go back")))
|
||||||
case snippetListLoading:
|
|
||||||
b.WriteString(SubtitleStyle.Render("Loading snippets..."))
|
|
||||||
return b.String()
|
|
||||||
case snippetListError:
|
|
||||||
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
b.WriteString(SubtitleStyle.Render("Press Esc to go back"))
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type styledRow struct {
|
||||||
|
text string
|
||||||
|
plain string
|
||||||
|
}
|
||||||
|
var rows []styledRow
|
||||||
|
|
||||||
|
title := "Command Snippets"
|
||||||
|
titlePlain := title
|
||||||
|
|
||||||
if t.err != nil {
|
if t.err != nil {
|
||||||
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))
|
errLine := fmt.Sprintf("Error: %v", t.err)
|
||||||
b.WriteString("\n\n")
|
rows = append(rows, styledRow{text: ErrorStyle.Render(errLine), plain: errLine})
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(t.snippets) == 0 {
|
if len(t.snippets) == 0 {
|
||||||
b.WriteString(SubtitleStyle.Render("No snippets stored."))
|
empty := "No snippets stored."
|
||||||
b.WriteString("\n")
|
hint := "Press Ctrl+N to add a new snippet."
|
||||||
b.WriteString(SubtitleStyle.Render("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 {
|
} else {
|
||||||
for i, sn := range t.snippets {
|
for i, sn := range t.snippets {
|
||||||
var line string
|
var plain string
|
||||||
desc := sn.Description
|
if sn.Description != "" {
|
||||||
if desc != "" {
|
plain = fmt.Sprintf(" %s — %s", sn.Name, sn.Description)
|
||||||
line = fmt.Sprintf(" %s — %s", sn.Name, desc)
|
|
||||||
} else {
|
} else {
|
||||||
line = fmt.Sprintf(" %s", sn.Name)
|
plain = fmt.Sprintf(" %s", sn.Name)
|
||||||
}
|
}
|
||||||
|
var styled string
|
||||||
if i == t.selected {
|
if i == t.selected {
|
||||||
b.WriteString(SelectedStyle.Render(line))
|
styled = lipgloss.NewStyle().
|
||||||
|
Foreground(gbFg).
|
||||||
|
Background(gbBgSel).
|
||||||
|
Bold(true).
|
||||||
|
Render("▸ " + strings.TrimLeft(plain, " "))
|
||||||
} else {
|
} else {
|
||||||
b.WriteString(HostNameStyle.Render(line))
|
styled = lipgloss.NewStyle().Foreground(gbFg).Render(plain)
|
||||||
}
|
}
|
||||||
b.WriteString("\n")
|
rows = append(rows, styledRow{text: styled, plain: plain})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString("\n")
|
footerPlain := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Ctrl+N:add Ctrl+E:edit D:delete Esc:back"
|
||||||
b.WriteString(SubtitleStyle.Render("Ctrl+N:add Ctrl+E:edit D:delete Esc:back"))
|
footer := styledRow{text: SubtitleStyle.Render(footerPlain), plain: footerPlain}
|
||||||
|
|
||||||
|
widestRow := lipgloss.Width(titlePlain)
|
||||||
|
for _, r := range rows {
|
||||||
|
if w := lipgloss.Width(r.plain); w > widestRow {
|
||||||
|
widestRow = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w := lipgloss.Width(footer.plain); w > widestRow {
|
||||||
|
widestRow = w
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidePad = 6
|
||||||
|
targetW := widestRow + sidePad*2
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
// Render rows, excluding the footer (last element)
|
||||||
|
for _, r := range rows[:len(rows)-1] {
|
||||||
|
line := lipgloss.PlaceHorizontal(targetW, lipgloss.Center, r.text)
|
||||||
|
inner.WriteString(line)
|
||||||
|
inner.WriteString("\n")
|
||||||
|
}
|
||||||
|
inner.WriteString("\n")
|
||||||
|
inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, footer.text))
|
||||||
|
|
||||||
|
box := BorderStyle.Render(inner.String())
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box))
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-19
@@ -4,7 +4,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Tab represents a single tab in the TUI
|
// Tab represents a single tab in the TUI
|
||||||
@@ -45,6 +44,13 @@ func (tm *TabManager) Active() Tab {
|
|||||||
func (tm *TabManager) Add(tab Tab) tea.Cmd {
|
func (tm *TabManager) Add(tab Tab) tea.Cmd {
|
||||||
tm.tabs = append(tm.tabs, tab)
|
tm.tabs = append(tm.tabs, tab)
|
||||||
tm.active = len(tm.tabs) - 1
|
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()
|
return tab.Init()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,10 +174,6 @@ func (tm *TabManager) View() string {
|
|||||||
b.WriteString(content)
|
b.WriteString(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render status bar
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(renderStatusBar(tm))
|
|
||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,17 +197,4 @@ func renderTabBar(tm *TabManager) string {
|
|||||||
return TabBarStyle.Render(bar)
|
return TabBarStyle.Render(bar)
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderStatusBar renders the bottom status bar, centered
|
|
||||||
func renderStatusBar(tm *TabManager) string {
|
|
||||||
hints := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Enter:SSH Ctrl+N:add Ctrl+F:SFTP Ctrl+K:keys Ctrl+P:snippets q:quit"
|
|
||||||
rendered := StatusBarStyle.Render(hints)
|
|
||||||
// Center within the terminal width
|
|
||||||
if tm.width > 0 {
|
|
||||||
w := lipgloss.Width(rendered)
|
|
||||||
if w < tm.width {
|
|
||||||
pad := (tm.width - w) / 2
|
|
||||||
rendered = strings.Repeat(" ", pad) + rendered
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rendered
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user