Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b27388455 | |||
| e5d32bd22e | |||
| 4af555c135 | |||
| f9b2337612 | |||
| 7f197b9e3a |
+1
-1
@@ -39,4 +39,4 @@ Thumbs.db
|
||||
*.local.json
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
dist/hostkeeper
|
||||
|
||||
+32
-19
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
||||
)
|
||||
@@ -283,18 +284,28 @@ func (t *HostFormTab) submit() (Tab, tea.Cmd) {
|
||||
}
|
||||
|
||||
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"
|
||||
if t.mode == formModeEdit {
|
||||
title = "Edit Host"
|
||||
}
|
||||
b.WriteString(AppTitleStyle.Render(title))
|
||||
b.WriteString("\n\n")
|
||||
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
|
||||
lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(title)))
|
||||
inner.WriteString("\n\n")
|
||||
|
||||
if t.err != nil {
|
||||
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))
|
||||
b.WriteString("\n\n")
|
||||
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++ {
|
||||
@@ -305,11 +316,10 @@ func (t *HostFormTab) View() string {
|
||||
if i == t.focus {
|
||||
style = HighlightStyle
|
||||
}
|
||||
b.WriteString(style.Render(label + ":"))
|
||||
b.WriteString("\n")
|
||||
inner.WriteString(style.Render(label + ":"))
|
||||
inner.WriteString("\n")
|
||||
|
||||
if i == fieldAuthType {
|
||||
// Render auth type as toggle pills
|
||||
current := input.Value()
|
||||
pills := []string{"password", "key"}
|
||||
var parts []string
|
||||
@@ -324,25 +334,28 @@ func (t *HostFormTab) View() string {
|
||||
parts = append(parts, SubtitleStyle.Render(" "+p+" "))
|
||||
}
|
||||
}
|
||||
b.WriteString(" ")
|
||||
b.WriteString(strings.Join(parts, " "))
|
||||
b.WriteString("\n")
|
||||
inner.WriteString(" ")
|
||||
inner.WriteString(strings.Join(parts, " "))
|
||||
inner.WriteString("\n")
|
||||
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 {
|
||||
renderedInput := input.View()
|
||||
b.WriteString(" ")
|
||||
b.WriteString(renderedInput)
|
||||
b.WriteString("\n\n")
|
||||
inner.WriteString(" ")
|
||||
inner.WriteString(renderedInput)
|
||||
inner.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Help text
|
||||
b.WriteString(SubtitleStyle.Render(
|
||||
"↑↓: navigate Enter: next Ctrl+S: save Esc: cancel"))
|
||||
inner.WriteString("\n")
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
+98
-104
@@ -104,88 +104,118 @@ func (t *HostListTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// View renders the host list
|
||||
// View renders the host list — exactly like tamagosh
|
||||
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
|
||||
|
||||
// Show error if any
|
||||
// Error display
|
||||
if t.err != nil {
|
||||
b.WriteString(ErrorStyle.Render(fmt.Sprintf(" Error: %v ", t.err)))
|
||||
b.WriteString("\n")
|
||||
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 {
|
||||
b.WriteString(centerText(SubtitleStyle.Render("No hosts found. Add with Ctrl+N"), availW))
|
||||
b.WriteString("\n")
|
||||
} else {
|
||||
// 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
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if start+maxCards > len(t.hosts) {
|
||||
start = len(t.hosts) - maxCards
|
||||
}
|
||||
|
||||
for i := start; i < start+maxCards; i++ {
|
||||
host := t.hosts[i]
|
||||
entry := renderHostEntry(host, i == t.selectedIndex, cardW)
|
||||
for _, line := range strings.Split(entry, "\n") {
|
||||
b.WriteString(centerText(line, availW))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
if maxCards < len(t.hosts) {
|
||||
b.WriteString(centerText(
|
||||
SubtitleStyle.Render(fmt.Sprintf("%d of %d hosts", start+1, len(t.hosts))),
|
||||
availW))
|
||||
}
|
||||
}
|
||||
|
||||
msg := SubtitleStyle.Render("(no connections — press Ctrl+N to add)")
|
||||
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, msg))
|
||||
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
|
||||
// Build rows (plain + styled)
|
||||
type styledRow struct {
|
||||
text string
|
||||
plain string
|
||||
}
|
||||
pad := strings.Repeat(" ", (width-w)/2)
|
||||
return pad + s
|
||||
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 {
|
||||
start = 0
|
||||
}
|
||||
if start+maxHosts > len(t.hosts) {
|
||||
start = len(t.hosts) - maxHosts
|
||||
}
|
||||
|
||||
for i := start; i < start+maxHosts; i++ {
|
||||
host := t.hosts[i]
|
||||
|
||||
// tamagosh format: " %-12s %-18s :%d"
|
||||
raw := fmt.Sprintf(" %-12s %-18s :%d", host.Name, host.Hostname, host.Port)
|
||||
|
||||
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
|
||||
var footer styledRow
|
||||
footerText := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Enter:SSH Ctrl+N:add Ctrl+F:SFTP Ctrl+K:keys Ctrl+P:snippets q:quit"
|
||||
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()
|
||||
}
|
||||
|
||||
// Close is a no-op for host list tab
|
||||
@@ -219,42 +249,6 @@ func FindHostListTab(tabs []Tab) *HostListTab {
|
||||
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
|
||||
func formatTagsForTUI(tags []string) string {
|
||||
if len(tags) == 0 {
|
||||
|
||||
+26
-11
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
||||
)
|
||||
@@ -191,18 +192,28 @@ func (t *KeyFormTab) submit() (Tab, tea.Cmd) {
|
||||
}
|
||||
|
||||
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"
|
||||
if t.mode == keyFormEdit {
|
||||
title = "Edit SSH Key"
|
||||
}
|
||||
b.WriteString(AppTitleStyle.Render(title))
|
||||
b.WriteString("\n\n")
|
||||
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
|
||||
lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(title)))
|
||||
inner.WriteString("\n\n")
|
||||
|
||||
if t.err != nil {
|
||||
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))
|
||||
b.WriteString("\n\n")
|
||||
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++ {
|
||||
@@ -212,15 +223,19 @@ func (t *KeyFormTab) View() string {
|
||||
if i == t.focus {
|
||||
style = HighlightStyle
|
||||
}
|
||||
b.WriteString(style.Render(label + ":"))
|
||||
b.WriteString("\n ")
|
||||
b.WriteString(input.View())
|
||||
b.WriteString("\n\n")
|
||||
inner.WriteString(style.Render(label + ":"))
|
||||
inner.WriteString("\n ")
|
||||
inner.WriteString(input.View())
|
||||
inner.WriteString("\n\n")
|
||||
}
|
||||
|
||||
b.WriteString(SubtitleStyle.Render(
|
||||
"↑↓: navigate Enter: next Ctrl+S: save Esc: cancel"))
|
||||
inner.WriteString("\n")
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
+70
-26
@@ -7,6 +7,7 @@ import (
|
||||
"sync"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
|
||||
@@ -134,52 +135,95 @@ 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(AppTitleStyle.Render("SSH Key Pairs"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
switch t.state {
|
||||
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"))
|
||||
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
|
||||
}
|
||||
var rows []styledRow
|
||||
|
||||
title := "SSH Key Pairs"
|
||||
titlePlain := title
|
||||
|
||||
if t.err != nil {
|
||||
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))
|
||||
b.WriteString("\n\n")
|
||||
errLine := fmt.Sprintf("Error: %v", t.err)
|
||||
rows = append(rows, styledRow{text: ErrorStyle.Render(errLine), plain: errLine})
|
||||
}
|
||||
|
||||
if len(t.keys) == 0 {
|
||||
b.WriteString(SubtitleStyle.Render("No SSH keys stored."))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(SubtitleStyle.Render("Press Ctrl+N to add a new key."))
|
||||
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 line string
|
||||
var plain string
|
||||
if key.Type != "" {
|
||||
line = fmt.Sprintf(" %s (%s)", key.Name, key.Type)
|
||||
plain = fmt.Sprintf(" %s (%s)", key.Name, key.Type)
|
||||
} else {
|
||||
line = fmt.Sprintf(" %s", key.Name)
|
||||
plain = fmt.Sprintf(" %s", key.Name)
|
||||
}
|
||||
|
||||
var styled string
|
||||
if i == t.selected {
|
||||
b.WriteString(SelectedStyle.Render(line))
|
||||
styled = lipgloss.NewStyle().
|
||||
Foreground(gbFg).
|
||||
Background(gbBgSel).
|
||||
Bold(true).
|
||||
Render("▸ " + strings.TrimLeft(plain, " "))
|
||||
} 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")
|
||||
b.WriteString(SubtitleStyle.Render("Ctrl+N:add Ctrl+E:edit D:delete Esc:back"))
|
||||
footerPlain := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav 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()
|
||||
}
|
||||
|
||||
|
||||
+18
-14
@@ -526,20 +526,21 @@ func (t *SFTPBrowserTab) View() string {
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if !t.connected && t.err != nil {
|
||||
return fmt.Sprintf("%s\n\n%s",
|
||||
ErrorStyle.Render(fmt.Sprintf("SFTP Error: %v", t.err)),
|
||||
SubtitleStyle.Render("Press Esc to close"))
|
||||
var b strings.Builder
|
||||
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
|
||||
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 {
|
||||
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
|
||||
if contentW < 40 {
|
||||
contentW = 80
|
||||
}
|
||||
halfW := contentW/2 - 1
|
||||
halfW := t.width / 2
|
||||
if halfW < 20 {
|
||||
halfW = 20
|
||||
}
|
||||
@@ -549,21 +550,24 @@ func (t *SFTPBrowserTab) View() string {
|
||||
|
||||
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(lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView))
|
||||
|
||||
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 != "" {
|
||||
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(
|
||||
"Tab:switch ↑↓:nav /:filter C:copy D:delete N:mkdir R:refresh Esc:close"))
|
||||
b.WriteString("\n" + lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
|
||||
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()
|
||||
}
|
||||
|
||||
+26
-11
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
||||
)
|
||||
@@ -199,18 +200,28 @@ func (t *SnippetFormTab) submit() (Tab, tea.Cmd) {
|
||||
}
|
||||
|
||||
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"
|
||||
if t.mode == snippetFormEdit {
|
||||
title = "Edit Snippet"
|
||||
}
|
||||
b.WriteString(AppTitleStyle.Render(title))
|
||||
b.WriteString("\n\n")
|
||||
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
|
||||
lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(title)))
|
||||
inner.WriteString("\n\n")
|
||||
|
||||
if t.err != nil {
|
||||
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))
|
||||
b.WriteString("\n\n")
|
||||
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++ {
|
||||
@@ -220,15 +231,19 @@ func (t *SnippetFormTab) View() string {
|
||||
if i == t.focus {
|
||||
style = HighlightStyle
|
||||
}
|
||||
b.WriteString(style.Render(label + ":"))
|
||||
b.WriteString("\n ")
|
||||
b.WriteString(input.View())
|
||||
b.WriteString("\n\n")
|
||||
inner.WriteString(style.Render(label + ":"))
|
||||
inner.WriteString("\n ")
|
||||
inner.WriteString(input.View())
|
||||
inner.WriteString("\n\n")
|
||||
}
|
||||
|
||||
b.WriteString(SubtitleStyle.Render(
|
||||
"↑↓: navigate Enter: next Ctrl+S: save Esc: cancel"))
|
||||
inner.WriteString("\n")
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
+71
-28
@@ -7,6 +7,7 @@ import (
|
||||
"sync"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
|
||||
@@ -134,53 +135,95 @@ 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(AppTitleStyle.Render("Command Snippets"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
switch t.state {
|
||||
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"))
|
||||
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
|
||||
}
|
||||
var rows []styledRow
|
||||
|
||||
title := "Command Snippets"
|
||||
titlePlain := title
|
||||
|
||||
if t.err != nil {
|
||||
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))
|
||||
b.WriteString("\n\n")
|
||||
errLine := fmt.Sprintf("Error: %v", t.err)
|
||||
rows = append(rows, styledRow{text: ErrorStyle.Render(errLine), plain: errLine})
|
||||
}
|
||||
|
||||
if len(t.snippets) == 0 {
|
||||
b.WriteString(SubtitleStyle.Render("No snippets stored."))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(SubtitleStyle.Render("Press Ctrl+N to add a new snippet."))
|
||||
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 line string
|
||||
desc := sn.Description
|
||||
if desc != "" {
|
||||
line = fmt.Sprintf(" %s — %s", sn.Name, desc)
|
||||
var plain string
|
||||
if sn.Description != "" {
|
||||
plain = fmt.Sprintf(" %s — %s", sn.Name, sn.Description)
|
||||
} else {
|
||||
line = fmt.Sprintf(" %s", sn.Name)
|
||||
plain = fmt.Sprintf(" %s", sn.Name)
|
||||
}
|
||||
|
||||
var styled string
|
||||
if i == t.selected {
|
||||
b.WriteString(SelectedStyle.Render(line))
|
||||
styled = lipgloss.NewStyle().
|
||||
Foreground(gbFg).
|
||||
Background(gbBgSel).
|
||||
Bold(true).
|
||||
Render("▸ " + strings.TrimLeft(plain, " "))
|
||||
} 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")
|
||||
b.WriteString(SubtitleStyle.Render("Ctrl+N:add Ctrl+E:edit D:delete Esc:back"))
|
||||
footerPlain := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav 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()
|
||||
}
|
||||
|
||||
|
||||
+8
-19
@@ -4,7 +4,6 @@ import (
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -168,10 +174,6 @@ func (tm *TabManager) View() string {
|
||||
b.WriteString(content)
|
||||
}
|
||||
|
||||
// Render status bar
|
||||
b.WriteString("\n")
|
||||
b.WriteString(renderStatusBar(tm))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -195,17 +197,4 @@ func renderTabBar(tm *TabManager) string {
|
||||
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