mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2025-09-07 13:20:40 +02:00
refactor(ui): split TUI logic into multiple files and improve styling
This commit is contained in:
parent
1d50e7cb47
commit
5dca755b11
@ -10,44 +10,20 @@ 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"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
titleStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("#FFFDF5")).
|
|
||||||
Background(lipgloss.Color("#25A065")).
|
|
||||||
Padding(0, 1)
|
|
||||||
|
|
||||||
fieldStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("#04B575"))
|
|
||||||
|
|
||||||
errorStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("#FF0000"))
|
|
||||||
|
|
||||||
helpStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("#626262"))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type addFormModel struct {
|
type addFormModel struct {
|
||||||
inputs []textinput.Model
|
inputs []textinput.Model
|
||||||
focused int
|
focused int
|
||||||
err string
|
err string
|
||||||
|
styles Styles
|
||||||
success bool
|
success bool
|
||||||
|
width int
|
||||||
|
height int
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
// NewAddForm creates a new add form model
|
||||||
nameInput = iota
|
func NewAddForm(hostname string, styles Styles, width, height int) *addFormModel {
|
||||||
hostnameInput
|
|
||||||
userInput
|
|
||||||
portInput
|
|
||||||
identityInput
|
|
||||||
proxyJumpInput
|
|
||||||
optionsInput
|
|
||||||
tagsInput
|
|
||||||
)
|
|
||||||
|
|
||||||
func RunAddForm(hostname string) error {
|
|
||||||
// Get current user for default
|
// Get current user for default
|
||||||
currentUser, _ := user.Current()
|
currentUser, _ := user.Current()
|
||||||
defaultUser := "root"
|
defaultUser := "root"
|
||||||
@ -123,28 +99,52 @@ func RunAddForm(hostname string) error {
|
|||||||
inputs[tagsInput].CharLimit = 200
|
inputs[tagsInput].CharLimit = 200
|
||||||
inputs[tagsInput].Width = 50
|
inputs[tagsInput].Width = 50
|
||||||
|
|
||||||
m := addFormModel{
|
return &addFormModel{
|
||||||
inputs: inputs,
|
inputs: inputs,
|
||||||
focused: nameInput,
|
focused: nameInput,
|
||||||
|
styles: styles,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
}
|
}
|
||||||
|
|
||||||
p := tea.NewProgram(&m, tea.WithAltScreen())
|
|
||||||
_, err := p.Run()
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
nameInput = iota
|
||||||
|
hostnameInput
|
||||||
|
userInput
|
||||||
|
portInput
|
||||||
|
identityInput
|
||||||
|
proxyJumpInput
|
||||||
|
optionsInput
|
||||||
|
tagsInput
|
||||||
|
)
|
||||||
|
|
||||||
|
// Messages for communication with parent model
|
||||||
|
type addFormSubmitMsg struct {
|
||||||
|
hostname string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type addFormCancelMsg struct{}
|
||||||
|
|
||||||
func (m *addFormModel) Init() tea.Cmd {
|
func (m *addFormModel) Init() tea.Cmd {
|
||||||
return textinput.Blink
|
return textinput.Blink
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *addFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *addFormModel) Update(msg tea.Msg) (*addFormModel, tea.Cmd) {
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
|
m.styles = NewStyles(m.width)
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "ctrl+c", "esc":
|
case "ctrl+c", "esc":
|
||||||
return m, tea.Quit
|
return m, func() tea.Msg { return addFormCancelMsg{} }
|
||||||
|
|
||||||
case "ctrl+enter":
|
case "ctrl+enter":
|
||||||
// Allow submission from any field with Ctrl+Enter
|
// Allow submission from any field with Ctrl+Enter
|
||||||
@ -182,14 +182,15 @@ func (m *addFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
case submitResult:
|
case addFormSubmitMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.err = msg.err.Error()
|
m.err = msg.err.Error()
|
||||||
} else {
|
} else {
|
||||||
m.success = true
|
m.success = true
|
||||||
m.err = ""
|
m.err = ""
|
||||||
return m, tea.Quit
|
// Don't quit here, let parent handle the success
|
||||||
}
|
}
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update inputs
|
// Update inputs
|
||||||
@ -209,7 +210,7 @@ func (m *addFormModel) View() string {
|
|||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
b.WriteString(titleStyle.Render("Add SSH Host Configuration"))
|
b.WriteString(m.styles.FormTitle.Render("Add SSH Host Configuration"))
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
fields := []string{
|
fields := []string{
|
||||||
@ -224,27 +225,57 @@ func (m *addFormModel) View() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i, field := range fields {
|
for i, field := range fields {
|
||||||
b.WriteString(fieldStyle.Render(field))
|
b.WriteString(m.styles.FormField.Render(field))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
b.WriteString(m.inputs[i].View())
|
b.WriteString(m.inputs[i].View())
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.err != "" {
|
if m.err != "" {
|
||||||
b.WriteString(errorStyle.Render("Error: " + m.err))
|
b.WriteString(m.styles.Error.Render("Error: " + m.err))
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString(helpStyle.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+Enter: submit • Ctrl+C/Esc: cancel"))
|
b.WriteString(m.styles.FormHelp.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+Enter: submit • Ctrl+C/Esc: cancel"))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
b.WriteString(helpStyle.Render("* Required fields"))
|
b.WriteString(m.styles.FormHelp.Render("* Required fields"))
|
||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
type submitResult struct {
|
// Standalone wrapper for add form
|
||||||
hostname string
|
type standaloneAddForm struct {
|
||||||
err error
|
*addFormModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m standaloneAddForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case addFormSubmitMsg:
|
||||||
|
if msg.err != nil {
|
||||||
|
m.addFormModel.err = msg.err.Error()
|
||||||
|
} else {
|
||||||
|
m.addFormModel.success = true
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case addFormCancelMsg:
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
|
||||||
|
newForm, cmd := m.addFormModel.Update(msg)
|
||||||
|
m.addFormModel = newForm
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunAddForm provides backward compatibility for standalone add form
|
||||||
|
func RunAddForm(hostname string) error {
|
||||||
|
styles := NewStyles(80)
|
||||||
|
addForm := NewAddForm(hostname, styles, 80, 24)
|
||||||
|
m := standaloneAddForm{addForm}
|
||||||
|
|
||||||
|
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||||
|
_, err := p.Run()
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *addFormModel) submitForm() tea.Cmd {
|
func (m *addFormModel) submitForm() tea.Cmd {
|
||||||
@ -269,7 +300,7 @@ func (m *addFormModel) submitForm() tea.Cmd {
|
|||||||
|
|
||||||
// Validate all fields
|
// Validate all fields
|
||||||
if err := validation.ValidateHost(name, hostname, port, identity); err != nil {
|
if err := validation.ValidateHost(name, hostname, port, identity); err != nil {
|
||||||
return submitResult{err: err}
|
return addFormSubmitMsg{err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
tagsStr := strings.TrimSpace(m.inputs[tagsInput].Value())
|
tagsStr := strings.TrimSpace(m.inputs[tagsInput].Value())
|
||||||
@ -297,6 +328,6 @@ func (m *addFormModel) submitForm() tea.Cmd {
|
|||||||
|
|
||||||
// Add to config
|
// Add to config
|
||||||
err := config.AddSSHHost(host)
|
err := config.AddSSHHost(host)
|
||||||
return submitResult{hostname: name, err: err}
|
return addFormSubmitMsg{hostname: name, err: err}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,23 +7,6 @@ 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"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
titleStyleEdit = lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("#FFFDF5")).
|
|
||||||
Background(lipgloss.Color("#25A065")).
|
|
||||||
Padding(0, 1)
|
|
||||||
|
|
||||||
fieldStyleEdit = lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("#04B575"))
|
|
||||||
|
|
||||||
errorStyleEdit = lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("#FF0000"))
|
|
||||||
|
|
||||||
helpStyleEdit = lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("#626262"))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type editFormModel struct {
|
type editFormModel struct {
|
||||||
@ -31,14 +14,18 @@ type editFormModel struct {
|
|||||||
focused int
|
focused int
|
||||||
err string
|
err string
|
||||||
success bool
|
success bool
|
||||||
|
styles Styles
|
||||||
originalName string
|
originalName string
|
||||||
|
width int
|
||||||
|
height int
|
||||||
}
|
}
|
||||||
|
|
||||||
func RunEditForm(hostName string) error {
|
// NewEditForm creates a new edit form model
|
||||||
|
func NewEditForm(hostName string, styles Styles, width, height int) (*editFormModel, error) {
|
||||||
// Get the existing host configuration
|
// Get the existing host configuration
|
||||||
host, err := config.GetSSHHost(hostName)
|
host, err := config.GetSSHHost(hostName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
inputs := make([]textinput.Model, 8)
|
inputs := make([]textinput.Model, 8)
|
||||||
@ -102,30 +89,42 @@ func RunEditForm(hostName string) error {
|
|||||||
inputs[tagsInput].SetValue(strings.Join(host.Tags, ", "))
|
inputs[tagsInput].SetValue(strings.Join(host.Tags, ", "))
|
||||||
}
|
}
|
||||||
|
|
||||||
m := editFormModel{
|
return &editFormModel{
|
||||||
inputs: inputs,
|
inputs: inputs,
|
||||||
focused: nameInput,
|
focused: nameInput,
|
||||||
originalName: hostName,
|
originalName: hostName,
|
||||||
}
|
styles: styles,
|
||||||
|
width: width,
|
||||||
// Open in separate window like add form
|
height: height,
|
||||||
p := tea.NewProgram(&m, tea.WithAltScreen())
|
}, nil
|
||||||
_, err = p.Run()
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Messages for communication with parent model
|
||||||
|
type editFormSubmitMsg struct {
|
||||||
|
hostname string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type editFormCancelMsg struct{}
|
||||||
|
|
||||||
func (m *editFormModel) Init() tea.Cmd {
|
func (m *editFormModel) Init() tea.Cmd {
|
||||||
return textinput.Blink
|
return textinput.Blink
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *editFormModel) Update(msg tea.Msg) (*editFormModel, tea.Cmd) {
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
|
m.styles = NewStyles(m.width)
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "ctrl+c", "esc":
|
case "ctrl+c", "esc":
|
||||||
return m, tea.Quit
|
return m, func() tea.Msg { return editFormCancelMsg{} }
|
||||||
|
|
||||||
case "ctrl+enter":
|
case "ctrl+enter":
|
||||||
// Allow submission from any field with Ctrl+Enter
|
// Allow submission from any field with Ctrl+Enter
|
||||||
@ -163,14 +162,15 @@ func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
case editResult:
|
case editFormSubmitMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.err = msg.err.Error()
|
m.err = msg.err.Error()
|
||||||
} else {
|
} else {
|
||||||
m.success = true
|
m.success = true
|
||||||
m.err = ""
|
m.err = ""
|
||||||
return m, tea.Quit
|
// Don't quit here, let parent handle the success
|
||||||
}
|
}
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update inputs
|
// Update inputs
|
||||||
@ -190,7 +190,7 @@ func (m *editFormModel) View() string {
|
|||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
b.WriteString(titleStyleEdit.Render("Edit SSH Host Configuration"))
|
b.WriteString(m.styles.FormTitle.Render("Edit SSH Host Configuration"))
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
fields := []string{
|
fields := []string{
|
||||||
@ -205,27 +205,60 @@ func (m *editFormModel) View() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i, field := range fields {
|
for i, field := range fields {
|
||||||
b.WriteString(fieldStyleEdit.Render(field))
|
b.WriteString(m.styles.FormField.Render(field))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
b.WriteString(m.inputs[i].View())
|
b.WriteString(m.inputs[i].View())
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.err != "" {
|
if m.err != "" {
|
||||||
b.WriteString(errorStyleEdit.Render("Error: " + m.err))
|
b.WriteString(m.styles.Error.Render("Error: " + m.err))
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString(helpStyleEdit.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+Enter: submit • Ctrl+C/Esc: cancel"))
|
b.WriteString(m.styles.FormHelp.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+Enter: submit • Ctrl+C/Esc: cancel"))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
b.WriteString(helpStyleEdit.Render("* Required fields"))
|
b.WriteString(m.styles.FormHelp.Render("* Required fields"))
|
||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
type editResult struct {
|
// Standalone wrapper for edit form
|
||||||
hostname string
|
type standaloneEditForm struct {
|
||||||
err error
|
*editFormModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m standaloneEditForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case editFormSubmitMsg:
|
||||||
|
if msg.err != nil {
|
||||||
|
m.editFormModel.err = msg.err.Error()
|
||||||
|
} else {
|
||||||
|
m.editFormModel.success = true
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case editFormCancelMsg:
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
|
||||||
|
newForm, cmd := m.editFormModel.Update(msg)
|
||||||
|
m.editFormModel = newForm
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunEditForm provides backward compatibility for standalone edit form
|
||||||
|
func RunEditForm(hostName string) error {
|
||||||
|
styles := NewStyles(80)
|
||||||
|
editForm, err := NewEditForm(hostName, styles, 80, 24)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m := standaloneEditForm{editForm}
|
||||||
|
|
||||||
|
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||||
|
_, err = p.Run()
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *editFormModel) submitEditForm() tea.Cmd {
|
func (m *editFormModel) submitEditForm() tea.Cmd {
|
||||||
@ -247,7 +280,7 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
|
|||||||
|
|
||||||
// Validate all fields
|
// Validate all fields
|
||||||
if err := validation.ValidateHost(name, hostname, port, identity); err != nil {
|
if err := validation.ValidateHost(name, hostname, port, identity); err != nil {
|
||||||
return editResult{err: err}
|
return editFormSubmitMsg{err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse tags
|
// Parse tags
|
||||||
@ -276,6 +309,6 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
|
|||||||
|
|
||||||
// Update the configuration
|
// Update the configuration
|
||||||
err := config.UpdateSSHHost(m.originalName, host)
|
err := config.UpdateSSHHost(m.originalName, host)
|
||||||
return editResult{hostname: name, err: err}
|
return editFormSubmitMsg{hostname: name, err: err}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
89
internal/ui/model.go
Normal file
89
internal/ui/model.go
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sshm/internal/config"
|
||||||
|
"sshm/internal/history"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/table"
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SortMode defines the available sorting modes
|
||||||
|
type SortMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
SortByName SortMode = iota
|
||||||
|
SortByLastUsed
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s SortMode) String() string {
|
||||||
|
switch s {
|
||||||
|
case SortByName:
|
||||||
|
return "Name (A-Z)"
|
||||||
|
case SortByLastUsed:
|
||||||
|
return "Last Login"
|
||||||
|
default:
|
||||||
|
return "Name (A-Z)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ViewMode defines the current view state
|
||||||
|
type ViewMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ViewList ViewMode = iota
|
||||||
|
ViewAdd
|
||||||
|
ViewEdit
|
||||||
|
)
|
||||||
|
|
||||||
|
// Model represents the state of the user interface
|
||||||
|
type Model struct {
|
||||||
|
table table.Model
|
||||||
|
searchInput textinput.Model
|
||||||
|
hosts []config.SSHHost
|
||||||
|
filteredHosts []config.SSHHost
|
||||||
|
searchMode bool
|
||||||
|
deleteMode bool
|
||||||
|
deleteHost string
|
||||||
|
exitAction string
|
||||||
|
exitHostName string
|
||||||
|
historyManager *history.HistoryManager
|
||||||
|
sortMode SortMode
|
||||||
|
|
||||||
|
// View management
|
||||||
|
viewMode ViewMode
|
||||||
|
addForm *addFormModel
|
||||||
|
editForm *editFormModel
|
||||||
|
previousView ViewMode
|
||||||
|
|
||||||
|
// Terminal size and styles
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
styles Styles
|
||||||
|
ready bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTableStyles updates the table header border color based on focus state
|
||||||
|
func (m *Model) updateTableStyles() {
|
||||||
|
s := table.DefaultStyles()
|
||||||
|
s.Selected = m.styles.Selected
|
||||||
|
|
||||||
|
if m.searchMode {
|
||||||
|
// When in search mode, use secondary color for table header
|
||||||
|
s.Header = s.Header.
|
||||||
|
BorderStyle(lipgloss.NormalBorder()).
|
||||||
|
BorderForeground(lipgloss.Color(SecondaryColor)).
|
||||||
|
BorderBottom(true).
|
||||||
|
Bold(false)
|
||||||
|
} else {
|
||||||
|
// When table is focused, use primary color for table header
|
||||||
|
s.Header = s.Header.
|
||||||
|
BorderStyle(lipgloss.NormalBorder()).
|
||||||
|
BorderForeground(lipgloss.Color(PrimaryColor)).
|
||||||
|
BorderBottom(true).
|
||||||
|
Bold(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.table.SetStyles(s)
|
||||||
|
}
|
71
internal/ui/sort.go
Normal file
71
internal/ui/sort.go
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"sshm/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sortHosts sorts hosts according to the current sort mode
|
||||||
|
func (m Model) sortHosts(hosts []config.SSHHost) []config.SSHHost {
|
||||||
|
if m.historyManager == nil {
|
||||||
|
return sortHostsByName(hosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch m.sortMode {
|
||||||
|
case SortByLastUsed:
|
||||||
|
return m.historyManager.SortHostsByLastUsed(hosts)
|
||||||
|
case SortByName:
|
||||||
|
fallthrough
|
||||||
|
default:
|
||||||
|
return sortHostsByName(hosts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortHostsByName sorts a slice of SSH hosts alphabetically by name
|
||||||
|
func sortHostsByName(hosts []config.SSHHost) []config.SSHHost {
|
||||||
|
sorted := make([]config.SSHHost, len(hosts))
|
||||||
|
copy(sorted, hosts)
|
||||||
|
|
||||||
|
sort.Slice(sorted, func(i, j int) bool {
|
||||||
|
return strings.ToLower(sorted[i].Name) < strings.ToLower(sorted[j].Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterHosts filters hosts according to the search query (name or tags)
|
||||||
|
func (m Model) filterHosts(query string) []config.SSHHost {
|
||||||
|
var filtered []config.SSHHost
|
||||||
|
|
||||||
|
if query == "" {
|
||||||
|
filtered = m.hosts
|
||||||
|
} else {
|
||||||
|
query = strings.ToLower(query)
|
||||||
|
|
||||||
|
for _, host := range m.hosts {
|
||||||
|
// Check the hostname
|
||||||
|
if strings.Contains(strings.ToLower(host.Name), query) {
|
||||||
|
filtered = append(filtered, host)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the hostname
|
||||||
|
if strings.Contains(strings.ToLower(host.Hostname), query) {
|
||||||
|
filtered = append(filtered, host)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the tags
|
||||||
|
for _, tag := range host.Tags {
|
||||||
|
if strings.Contains(strings.ToLower(tag), query) {
|
||||||
|
filtered = append(filtered, host)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.sortHosts(filtered)
|
||||||
|
}
|
117
internal/ui/styles.go
Normal file
117
internal/ui/styles.go
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import "github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
// Theme colors
|
||||||
|
var (
|
||||||
|
// Primary interface color - easily modifiable
|
||||||
|
PrimaryColor = "#00ADD8" // Official Go logo blue color
|
||||||
|
|
||||||
|
// Secondary colors
|
||||||
|
SecondaryColor = "240" // Gray
|
||||||
|
ErrorColor = "1" // Red
|
||||||
|
SuccessColor = "36" // Green (for reference if needed)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Styles struct centralizes all lipgloss styles
|
||||||
|
type Styles struct {
|
||||||
|
// Layout
|
||||||
|
App lipgloss.Style
|
||||||
|
Header lipgloss.Style
|
||||||
|
|
||||||
|
// Search styles
|
||||||
|
SearchFocused lipgloss.Style
|
||||||
|
SearchUnfocused lipgloss.Style
|
||||||
|
|
||||||
|
// Table styles
|
||||||
|
TableFocused lipgloss.Style
|
||||||
|
TableUnfocused lipgloss.Style
|
||||||
|
Selected lipgloss.Style
|
||||||
|
|
||||||
|
// Info and help styles
|
||||||
|
SortInfo lipgloss.Style
|
||||||
|
HelpText lipgloss.Style
|
||||||
|
|
||||||
|
// Error and confirmation styles
|
||||||
|
Error lipgloss.Style
|
||||||
|
|
||||||
|
// Form styles (for add/edit forms)
|
||||||
|
FormTitle lipgloss.Style
|
||||||
|
FormField lipgloss.Style
|
||||||
|
FormHelp lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStyles creates a new Styles struct with the given terminal width
|
||||||
|
func NewStyles(width int) Styles {
|
||||||
|
return Styles{
|
||||||
|
// Main app container
|
||||||
|
App: lipgloss.NewStyle().
|
||||||
|
Padding(1),
|
||||||
|
|
||||||
|
// Header style
|
||||||
|
Header: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(PrimaryColor)).
|
||||||
|
Bold(true).
|
||||||
|
Align(lipgloss.Center),
|
||||||
|
|
||||||
|
// Search styles
|
||||||
|
SearchFocused: lipgloss.NewStyle().
|
||||||
|
BorderStyle(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color(PrimaryColor)).
|
||||||
|
Padding(0, 1),
|
||||||
|
|
||||||
|
SearchUnfocused: lipgloss.NewStyle().
|
||||||
|
BorderStyle(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color(SecondaryColor)).
|
||||||
|
Padding(0, 1),
|
||||||
|
|
||||||
|
// Table styles
|
||||||
|
TableFocused: lipgloss.NewStyle().
|
||||||
|
BorderStyle(lipgloss.NormalBorder()).
|
||||||
|
BorderForeground(lipgloss.Color(PrimaryColor)),
|
||||||
|
|
||||||
|
TableUnfocused: lipgloss.NewStyle().
|
||||||
|
BorderStyle(lipgloss.NormalBorder()).
|
||||||
|
BorderForeground(lipgloss.Color(SecondaryColor)),
|
||||||
|
|
||||||
|
// Style for selected items
|
||||||
|
Selected: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("229")).
|
||||||
|
Background(lipgloss.Color(PrimaryColor)).
|
||||||
|
Bold(false),
|
||||||
|
|
||||||
|
// Info styles
|
||||||
|
SortInfo: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(SecondaryColor)),
|
||||||
|
|
||||||
|
HelpText: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(SecondaryColor)).
|
||||||
|
MarginTop(1),
|
||||||
|
|
||||||
|
// Error style
|
||||||
|
Error: lipgloss.NewStyle().
|
||||||
|
BorderStyle(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color(ErrorColor)).
|
||||||
|
Padding(1, 2),
|
||||||
|
|
||||||
|
// Form styles
|
||||||
|
FormTitle: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("#FFFDF5")).
|
||||||
|
Background(lipgloss.Color(PrimaryColor)).
|
||||||
|
Padding(0, 1),
|
||||||
|
|
||||||
|
FormField: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(PrimaryColor)),
|
||||||
|
|
||||||
|
FormHelp: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("#626262")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Application ASCII title
|
||||||
|
const asciiTitle = `
|
||||||
|
_____ _____ __ __ _____
|
||||||
|
| __| __| | | |
|
||||||
|
|__ |__ | | | | |
|
||||||
|
|_____|_____|__|__|_|_|_|
|
||||||
|
`
|
133
internal/ui/table.go
Normal file
133
internal/ui/table.go
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"sshm/internal/config"
|
||||||
|
"sshm/internal/history"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/table"
|
||||||
|
)
|
||||||
|
|
||||||
|
// calculateNameColumnWidth calculates the optimal width for the Name column
|
||||||
|
// based on the longest hostname, with a minimum of 8 and maximum of 40 characters
|
||||||
|
func calculateNameColumnWidth(hosts []config.SSHHost) int {
|
||||||
|
maxLength := 8 // Minimum width to accommodate the "Name" header
|
||||||
|
|
||||||
|
for _, host := range hosts {
|
||||||
|
if len(host.Name) > maxLength {
|
||||||
|
maxLength = len(host.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add some padding (2 characters) for better visual spacing
|
||||||
|
maxLength += 2
|
||||||
|
|
||||||
|
// Limit the maximum width to avoid extremely large columns
|
||||||
|
if maxLength > 40 {
|
||||||
|
maxLength = 40
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxLength
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateTagsColumnWidth calculates the optimal width for the Tags column
|
||||||
|
// based on the longest tag string, with a minimum of 8 and maximum of 40 characters
|
||||||
|
func calculateTagsColumnWidth(hosts []config.SSHHost) int {
|
||||||
|
maxLength := 8 // Minimum width to accommodate the "Tags" header
|
||||||
|
|
||||||
|
for _, host := range hosts {
|
||||||
|
// Format tags exactly as they appear in the table
|
||||||
|
var tagsStr string
|
||||||
|
if len(host.Tags) > 0 {
|
||||||
|
// Add the # prefix to each tag and join them with spaces
|
||||||
|
var formattedTags []string
|
||||||
|
for _, tag := range host.Tags {
|
||||||
|
formattedTags = append(formattedTags, "#"+tag)
|
||||||
|
}
|
||||||
|
tagsStr = strings.Join(formattedTags, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tagsStr) > maxLength {
|
||||||
|
maxLength = len(tagsStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add some padding (2 characters) for better visual spacing
|
||||||
|
maxLength += 2
|
||||||
|
|
||||||
|
// Limit the maximum width to avoid extremely large columns
|
||||||
|
if maxLength > 40 {
|
||||||
|
maxLength = 40
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxLength
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateLastLoginColumnWidth calculates the optimal width for the Last Login column
|
||||||
|
// based on the longest time format, with a minimum of 12 and maximum of 20 characters
|
||||||
|
func calculateLastLoginColumnWidth(hosts []config.SSHHost, historyManager *history.HistoryManager) int {
|
||||||
|
maxLength := 12 // Minimum width to accommodate the "Last Login" header
|
||||||
|
|
||||||
|
if historyManager != nil {
|
||||||
|
for _, host := range hosts {
|
||||||
|
if lastConnect, exists := historyManager.GetLastConnectionTime(host.Name); exists {
|
||||||
|
timeStr := formatTimeAgo(lastConnect)
|
||||||
|
if len(timeStr) > maxLength {
|
||||||
|
maxLength = len(timeStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add some padding (2 characters) for better visual spacing
|
||||||
|
maxLength += 2
|
||||||
|
|
||||||
|
// Limit the maximum width to avoid extremely large columns
|
||||||
|
if maxLength > 20 {
|
||||||
|
maxLength = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxLength
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTableRows updates the table with filtered hosts
|
||||||
|
func (m *Model) updateTableRows() {
|
||||||
|
var rows []table.Row
|
||||||
|
hostsToShow := m.filteredHosts
|
||||||
|
if hostsToShow == nil {
|
||||||
|
hostsToShow = m.hosts
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, host := range hostsToShow {
|
||||||
|
// Format tags for display
|
||||||
|
var tagsStr string
|
||||||
|
if len(host.Tags) > 0 {
|
||||||
|
// Add the # prefix to each tag and join them with spaces
|
||||||
|
var formattedTags []string
|
||||||
|
for _, tag := range host.Tags {
|
||||||
|
formattedTags = append(formattedTags, "#"+tag)
|
||||||
|
}
|
||||||
|
tagsStr = strings.Join(formattedTags, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format last login information
|
||||||
|
var lastLoginStr string
|
||||||
|
if m.historyManager != nil {
|
||||||
|
if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists {
|
||||||
|
lastLoginStr = formatTimeAgo(lastConnect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows = append(rows, table.Row{
|
||||||
|
host.Name,
|
||||||
|
host.Hostname,
|
||||||
|
host.User,
|
||||||
|
host.Port,
|
||||||
|
tagsStr,
|
||||||
|
lastLoginStr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
m.table.SetRows(rows)
|
||||||
|
}
|
@ -2,10 +2,7 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"sshm/internal/config"
|
"sshm/internal/config"
|
||||||
"sshm/internal/history"
|
"sshm/internal/history"
|
||||||
@ -16,490 +13,35 @@ import (
|
|||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
var searchStyleFocused = lipgloss.NewStyle().
|
|
||||||
BorderStyle(lipgloss.RoundedBorder()).
|
|
||||||
BorderForeground(lipgloss.Color("36")).
|
|
||||||
Padding(0, 1)
|
|
||||||
|
|
||||||
var searchStyleUnfocused = lipgloss.NewStyle().
|
|
||||||
BorderStyle(lipgloss.RoundedBorder()).
|
|
||||||
BorderForeground(lipgloss.Color("240")).
|
|
||||||
Padding(0, 1)
|
|
||||||
|
|
||||||
var headerStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("36")).
|
|
||||||
Bold(true).
|
|
||||||
Align(lipgloss.Center)
|
|
||||||
|
|
||||||
const asciiTitle = `
|
|
||||||
_____ _____ _ _ _____
|
|
||||||
| __| __| | | |
|
|
||||||
|__ |__ | | | | |
|
|
||||||
|_____|_____|__|__|_|_|_|
|
|
||||||
`
|
|
||||||
|
|
||||||
type SortMode int
|
|
||||||
|
|
||||||
const (
|
|
||||||
SortByName SortMode = iota
|
|
||||||
SortByLastUsed
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s SortMode) String() string {
|
|
||||||
switch s {
|
|
||||||
case SortByName:
|
|
||||||
return "Name (A-Z)"
|
|
||||||
case SortByLastUsed:
|
|
||||||
return "Last Login"
|
|
||||||
default:
|
|
||||||
return "Name (A-Z)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Model struct {
|
|
||||||
table table.Model
|
|
||||||
searchInput textinput.Model
|
|
||||||
hosts []config.SSHHost
|
|
||||||
filteredHosts []config.SSHHost
|
|
||||||
searchMode bool
|
|
||||||
deleteMode bool
|
|
||||||
deleteHost string
|
|
||||||
exitAction string
|
|
||||||
exitHostName string
|
|
||||||
historyManager *history.HistoryManager
|
|
||||||
sortMode SortMode
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd {
|
|
||||||
return textinput.Blink
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
var cmd tea.Cmd
|
|
||||||
|
|
||||||
// Handle key messages
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.KeyMsg:
|
|
||||||
switch msg.String() {
|
|
||||||
case "esc", "ctrl+c":
|
|
||||||
if m.deleteMode {
|
|
||||||
// Exit delete mode
|
|
||||||
m.deleteMode = false
|
|
||||||
m.deleteHost = ""
|
|
||||||
m.table.Focus()
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
return m, tea.Quit
|
|
||||||
case "q":
|
|
||||||
if !m.searchMode && !m.deleteMode {
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
case "/", "ctrl+f":
|
|
||||||
if !m.searchMode && !m.deleteMode {
|
|
||||||
// Enter search mode
|
|
||||||
m.searchMode = true
|
|
||||||
m.table.Blur()
|
|
||||||
m.searchInput.Focus()
|
|
||||||
return m, textinput.Blink
|
|
||||||
}
|
|
||||||
case "tab":
|
|
||||||
if !m.deleteMode {
|
|
||||||
// Toggle focus between search input and table
|
|
||||||
if m.searchMode {
|
|
||||||
// Switch from search to table
|
|
||||||
m.searchMode = false
|
|
||||||
m.searchInput.Blur()
|
|
||||||
m.table.Focus()
|
|
||||||
} else {
|
|
||||||
// Switch from table to search
|
|
||||||
m.searchMode = true
|
|
||||||
m.table.Blur()
|
|
||||||
m.searchInput.Focus()
|
|
||||||
return m, textinput.Blink
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
case "enter":
|
|
||||||
if m.searchMode {
|
|
||||||
// Validate search and return to table mode to allow commands
|
|
||||||
m.searchMode = false
|
|
||||||
m.searchInput.Blur()
|
|
||||||
m.table.Focus()
|
|
||||||
return m, nil
|
|
||||||
} else if m.deleteMode {
|
|
||||||
// Confirm deletion
|
|
||||||
err := config.DeleteSSHHost(m.deleteHost)
|
|
||||||
if err != nil {
|
|
||||||
// Could show error message here
|
|
||||||
m.deleteMode = false
|
|
||||||
m.deleteHost = ""
|
|
||||||
m.table.Focus()
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
// Refresh the host list
|
|
||||||
hosts, err := config.ParseSSHConfig()
|
|
||||||
if err != nil {
|
|
||||||
// Could show error message here
|
|
||||||
m.deleteMode = false
|
|
||||||
m.deleteHost = ""
|
|
||||||
m.table.Focus()
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
m.hosts = sortHostsByName(hosts)
|
|
||||||
m.filteredHosts = m.hosts
|
|
||||||
m.updateTableRows()
|
|
||||||
m.deleteMode = false
|
|
||||||
m.deleteHost = ""
|
|
||||||
m.table.Focus()
|
|
||||||
return m, nil
|
|
||||||
} else {
|
|
||||||
// Connect to selected host
|
|
||||||
selected := m.table.SelectedRow()
|
|
||||||
if len(selected) > 0 {
|
|
||||||
hostName := selected[0] // Host name is in the first column
|
|
||||||
|
|
||||||
// Record the connection in history
|
|
||||||
if m.historyManager != nil {
|
|
||||||
err := m.historyManager.RecordConnection(hostName)
|
|
||||||
if err != nil {
|
|
||||||
// Log error but don't prevent connection
|
|
||||||
fmt.Printf("Warning: Could not record connection history: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return m, tea.ExecProcess(exec.Command("ssh", hostName), func(err error) tea.Msg {
|
|
||||||
return tea.Quit()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "e":
|
|
||||||
if !m.searchMode && !m.deleteMode {
|
|
||||||
// Edit selected host using dedicated edit form
|
|
||||||
selected := m.table.SelectedRow()
|
|
||||||
if len(selected) > 0 {
|
|
||||||
hostName := selected[0] // Host name is in the first column
|
|
||||||
// Store the edit action and exit
|
|
||||||
m.exitAction = "edit"
|
|
||||||
m.exitHostName = hostName
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "a":
|
|
||||||
if !m.searchMode && !m.deleteMode {
|
|
||||||
// Add new host using dedicated add form
|
|
||||||
m.exitAction = "add"
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
case "d":
|
|
||||||
if !m.searchMode && !m.deleteMode {
|
|
||||||
// Delete selected host
|
|
||||||
selected := m.table.SelectedRow()
|
|
||||||
if len(selected) > 0 {
|
|
||||||
hostName := selected[0] // Host name is in the first column
|
|
||||||
m.deleteMode = true
|
|
||||||
m.deleteHost = hostName
|
|
||||||
m.table.Blur()
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "s":
|
|
||||||
if !m.searchMode && !m.deleteMode {
|
|
||||||
// Cycle through sort modes (only 2 modes now)
|
|
||||||
m.sortMode = (m.sortMode + 1) % 2
|
|
||||||
// Re-apply current filter with new sort mode
|
|
||||||
if m.searchInput.Value() != "" {
|
|
||||||
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
|
||||||
} else {
|
|
||||||
m.filteredHosts = m.sortHosts(m.hosts)
|
|
||||||
}
|
|
||||||
m.updateTableRows()
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
case "r":
|
|
||||||
if !m.searchMode && !m.deleteMode {
|
|
||||||
// Switch to sort by recent (last used)
|
|
||||||
m.sortMode = SortByLastUsed
|
|
||||||
// Re-apply current filter with new sort mode
|
|
||||||
if m.searchInput.Value() != "" {
|
|
||||||
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
|
||||||
} else {
|
|
||||||
m.filteredHosts = m.sortHosts(m.hosts)
|
|
||||||
}
|
|
||||||
m.updateTableRows()
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
case "n":
|
|
||||||
if !m.searchMode && !m.deleteMode {
|
|
||||||
// Switch to sort by name
|
|
||||||
m.sortMode = SortByName
|
|
||||||
// Re-apply current filter with new sort mode
|
|
||||||
if m.searchInput.Value() != "" {
|
|
||||||
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
|
||||||
} else {
|
|
||||||
m.filteredHosts = m.sortHosts(m.hosts)
|
|
||||||
}
|
|
||||||
m.updateTableRows()
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update components based on mode
|
|
||||||
if m.searchMode {
|
|
||||||
oldValue := m.searchInput.Value()
|
|
||||||
m.searchInput, cmd = m.searchInput.Update(msg)
|
|
||||||
// Only update filtered hosts if search value changed
|
|
||||||
if m.searchInput.Value() != oldValue {
|
|
||||||
if m.searchInput.Value() != "" {
|
|
||||||
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
|
||||||
} else {
|
|
||||||
m.filteredHosts = m.sortHosts(m.hosts)
|
|
||||||
}
|
|
||||||
m.updateTableRows()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
m.table, cmd = m.table.Update(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) View() string {
|
|
||||||
if m.deleteMode {
|
|
||||||
return m.renderDeleteConfirmation()
|
|
||||||
}
|
|
||||||
|
|
||||||
var view strings.Builder
|
|
||||||
|
|
||||||
// Add ASCII title
|
|
||||||
view.WriteString(headerStyle.Render(asciiTitle) + "\n")
|
|
||||||
|
|
||||||
// Add search bar (always visible) with appropriate style based on focus
|
|
||||||
searchPrompt := "Search (/ to focus, Tab to switch): "
|
|
||||||
if m.searchMode {
|
|
||||||
view.WriteString(searchStyleFocused.Render(searchPrompt+m.searchInput.View()) + "\n")
|
|
||||||
} else {
|
|
||||||
view.WriteString(searchStyleUnfocused.Render(searchPrompt+m.searchInput.View()) + "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add sort mode indicator
|
|
||||||
sortInfo := fmt.Sprintf("Sort: %s", m.sortMode.String())
|
|
||||||
view.WriteString(lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("240")).
|
|
||||||
Render(sortInfo) + "\n\n")
|
|
||||||
|
|
||||||
// Add table with appropriate style based on focus
|
|
||||||
if m.searchMode {
|
|
||||||
// Table is not focused, use gray border
|
|
||||||
tableStyle := lipgloss.NewStyle().
|
|
||||||
BorderStyle(lipgloss.NormalBorder()).
|
|
||||||
BorderForeground(lipgloss.Color("240"))
|
|
||||||
view.WriteString(tableStyle.Render(m.table.View()))
|
|
||||||
} else {
|
|
||||||
// Table is focused, use green border
|
|
||||||
tableStyle := lipgloss.NewStyle().
|
|
||||||
BorderStyle(lipgloss.NormalBorder()).
|
|
||||||
BorderForeground(lipgloss.Color("36"))
|
|
||||||
view.WriteString(tableStyle.Render(m.table.View()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add help text
|
|
||||||
if !m.searchMode {
|
|
||||||
view.WriteString("\nUse ↑/↓ to navigate • Enter to connect • (a)dd • (e)dit • (d)elete • / to search • Tab to switch")
|
|
||||||
view.WriteString("\nSort: (s)witch • (r)ecent • (n)ame • q/ESC to quit")
|
|
||||||
} else {
|
|
||||||
view.WriteString("\nType to filter hosts • Enter to validate search • Tab to switch to table • ESC to quit")
|
|
||||||
}
|
|
||||||
|
|
||||||
return view.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// sortHosts sorts hosts based on the current sort mode
|
|
||||||
func (m Model) sortHosts(hosts []config.SSHHost) []config.SSHHost {
|
|
||||||
if m.historyManager == nil {
|
|
||||||
return sortHostsByName(hosts)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch m.sortMode {
|
|
||||||
case SortByLastUsed:
|
|
||||||
return m.historyManager.SortHostsByLastUsed(hosts)
|
|
||||||
case SortByName:
|
|
||||||
fallthrough
|
|
||||||
default:
|
|
||||||
return sortHostsByName(hosts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sortHostsByName sorts a slice of SSH hosts alphabetically by name
|
|
||||||
func sortHostsByName(hosts []config.SSHHost) []config.SSHHost {
|
|
||||||
sorted := make([]config.SSHHost, len(hosts))
|
|
||||||
copy(sorted, hosts)
|
|
||||||
|
|
||||||
sort.Slice(sorted, func(i, j int) bool {
|
|
||||||
return strings.ToLower(sorted[i].Name) < strings.ToLower(sorted[j].Name)
|
|
||||||
})
|
|
||||||
|
|
||||||
return sorted
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculateNameColumnWidth calculates the optimal width for the Name column
|
|
||||||
// based on the longest host name, with a minimum of 8 and maximum of 40 characters
|
|
||||||
func calculateNameColumnWidth(hosts []config.SSHHost) int {
|
|
||||||
maxLength := 8 // Minimum width to accommodate the "Name" header
|
|
||||||
|
|
||||||
for _, host := range hosts {
|
|
||||||
if len(host.Name) > maxLength {
|
|
||||||
maxLength = len(host.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add some padding (2 characters) for better visual spacing
|
|
||||||
maxLength += 2
|
|
||||||
|
|
||||||
// Cap the maximum width to avoid extremely wide columns
|
|
||||||
if maxLength > 40 {
|
|
||||||
maxLength = 40
|
|
||||||
}
|
|
||||||
|
|
||||||
return maxLength
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculateTagsColumnWidth calculates the optimal width for the Tags column
|
|
||||||
// based on the longest tags string, with a minimum of 8 and maximum of 40 characters
|
|
||||||
func calculateTagsColumnWidth(hosts []config.SSHHost) int {
|
|
||||||
maxLength := 8 // Minimum width to accommodate the "Tags" header
|
|
||||||
|
|
||||||
for _, host := range hosts {
|
|
||||||
// Format tags exactly the same way they appear in the table
|
|
||||||
var tagsStr string
|
|
||||||
if len(host.Tags) > 0 {
|
|
||||||
// Add # prefix to each tag and join with spaces
|
|
||||||
var formattedTags []string
|
|
||||||
for _, tag := range host.Tags {
|
|
||||||
formattedTags = append(formattedTags, "#"+tag)
|
|
||||||
}
|
|
||||||
tagsStr = strings.Join(formattedTags, " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tagsStr) > maxLength {
|
|
||||||
maxLength = len(tagsStr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add some padding (2 characters) for better visual spacing
|
|
||||||
maxLength += 2
|
|
||||||
|
|
||||||
// Cap the maximum width to avoid extremely wide columns
|
|
||||||
if maxLength > 40 {
|
|
||||||
maxLength = 40
|
|
||||||
}
|
|
||||||
|
|
||||||
return maxLength
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatTimeAgo formats a time into a human-readable "time ago" string
|
|
||||||
func formatTimeAgo(t time.Time) string {
|
|
||||||
now := time.Now()
|
|
||||||
duration := now.Sub(t)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case duration < time.Minute:
|
|
||||||
seconds := int(duration.Seconds())
|
|
||||||
if seconds <= 1 {
|
|
||||||
return "1 second ago"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d seconds ago", seconds)
|
|
||||||
case duration < time.Hour:
|
|
||||||
minutes := int(duration.Minutes())
|
|
||||||
if minutes == 1 {
|
|
||||||
return "1 minute ago"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d minutes ago", minutes)
|
|
||||||
case duration < 24*time.Hour:
|
|
||||||
hours := int(duration.Hours())
|
|
||||||
if hours == 1 {
|
|
||||||
return "1 hour ago"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d hours ago", hours)
|
|
||||||
case duration < 7*24*time.Hour:
|
|
||||||
days := int(duration.Hours() / 24)
|
|
||||||
if days == 1 {
|
|
||||||
return "1 day ago"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d days ago", days)
|
|
||||||
case duration < 30*24*time.Hour:
|
|
||||||
weeks := int(duration.Hours() / (24 * 7))
|
|
||||||
if weeks == 1 {
|
|
||||||
return "1 week ago"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d weeks ago", weeks)
|
|
||||||
case duration < 365*24*time.Hour:
|
|
||||||
months := int(duration.Hours() / (24 * 30))
|
|
||||||
if months == 1 {
|
|
||||||
return "1 month ago"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d months ago", months)
|
|
||||||
default:
|
|
||||||
years := int(duration.Hours() / (24 * 365))
|
|
||||||
if years == 1 {
|
|
||||||
return "1 year ago"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d years ago", years)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculateLastLoginColumnWidth calculates the optimal width for the Last Login column
|
|
||||||
// based on the longest time format, with a minimum of 12 and maximum of 20 characters
|
|
||||||
func calculateLastLoginColumnWidth(hosts []config.SSHHost, historyManager *history.HistoryManager) int {
|
|
||||||
maxLength := 12 // Minimum width to accommodate the "Last Login" header
|
|
||||||
|
|
||||||
if historyManager != nil {
|
|
||||||
for _, host := range hosts {
|
|
||||||
if lastConnect, exists := historyManager.GetLastConnectionTime(host.Name); exists {
|
|
||||||
timeStr := formatTimeAgo(lastConnect)
|
|
||||||
if len(timeStr) > maxLength {
|
|
||||||
maxLength = len(timeStr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add some padding (2 characters) for better visual spacing
|
|
||||||
maxLength += 2
|
|
||||||
|
|
||||||
// Cap the maximum width to avoid extremely wide columns
|
|
||||||
if maxLength > 20 {
|
|
||||||
maxLength = 20
|
|
||||||
}
|
|
||||||
|
|
||||||
return maxLength
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculateInfoColumnWidth calculates the optimal width for the combined Info column
|
|
||||||
// based on the longest combined tags and history string, with a minimum of 12 and maximum of 60 characters
|
|
||||||
// enterEditMode initializes edit mode for a specific host
|
|
||||||
|
|
||||||
// NewModel creates a new TUI model with the given SSH hosts
|
// NewModel creates a new TUI model with the given SSH hosts
|
||||||
func NewModel(hosts []config.SSHHost) Model {
|
func NewModel(hosts []config.SSHHost) Model {
|
||||||
// Initialize history manager
|
// Initialize the history manager
|
||||||
historyManager, err := history.NewHistoryManager()
|
historyManager, err := history.NewHistoryManager()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Log error but continue without history functionality
|
// Log the error but continue without the history functionality
|
||||||
fmt.Printf("Warning: Could not initialize history manager: %v\n", err)
|
fmt.Printf("Warning: Could not initialize history manager: %v\n", err)
|
||||||
historyManager = nil
|
historyManager = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create initial styles (will be updated on first WindowSizeMsg)
|
||||||
|
styles := NewStyles(80) // Default width
|
||||||
|
|
||||||
// Create the model with default sorting by name
|
// Create the model with default sorting by name
|
||||||
m := Model{
|
m := Model{
|
||||||
hosts: hosts,
|
hosts: hosts,
|
||||||
historyManager: historyManager,
|
historyManager: historyManager,
|
||||||
sortMode: SortByName,
|
sortMode: SortByName,
|
||||||
|
styles: styles,
|
||||||
|
width: 80,
|
||||||
|
height: 24,
|
||||||
|
ready: false,
|
||||||
|
viewMode: ViewList,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort hosts based on default sort mode
|
// Sort hosts according to the default sort mode
|
||||||
sortedHosts := m.sortHosts(hosts)
|
sortedHosts := m.sortHosts(hosts)
|
||||||
|
|
||||||
// Create search input
|
// Create the search input
|
||||||
ti := textinput.New()
|
ti := textinput.New()
|
||||||
ti.Placeholder = "Search hosts or tags..."
|
ti.Placeholder = "Search hosts or tags..."
|
||||||
ti.CharLimit = 50
|
ti.CharLimit = 50
|
||||||
@ -530,7 +72,7 @@ func NewModel(hosts []config.SSHHost) Model {
|
|||||||
// Format tags for display
|
// Format tags for display
|
||||||
var tagsStr string
|
var tagsStr string
|
||||||
if len(host.Tags) > 0 {
|
if len(host.Tags) > 0 {
|
||||||
// Add # prefix to each tag and join with spaces
|
// Add the # prefix to each tag and join them with spaces
|
||||||
var formattedTags []string
|
var formattedTags []string
|
||||||
for _, tag := range host.Tags {
|
for _, tag := range host.Tags {
|
||||||
formattedTags = append(formattedTags, "#"+tag)
|
formattedTags = append(formattedTags, "#"+tag)
|
||||||
@ -538,7 +80,7 @@ func NewModel(hosts []config.SSHHost) Model {
|
|||||||
tagsStr = strings.Join(formattedTags, " ")
|
tagsStr = strings.Join(formattedTags, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format last login info
|
// Format last login information
|
||||||
var lastLoginStr string
|
var lastLoginStr string
|
||||||
if historyManager != nil {
|
if historyManager != nil {
|
||||||
if lastConnect, exists := historyManager.GetLastConnectionTime(host.Name); exists {
|
if lastConnect, exists := historyManager.GetLastConnectionTime(host.Name); exists {
|
||||||
@ -556,7 +98,7 @@ func NewModel(hosts []config.SSHHost) Model {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Déterminer la hauteur du tableau : 1 (header) + nombre de hosts (max 10)
|
// Determine table height: 1 (header) + number of hosts (max 10)
|
||||||
hostCount := len(rows)
|
hostCount := len(rows)
|
||||||
tableHeight := 1 // header
|
tableHeight := 1 // header
|
||||||
if hostCount < 10 {
|
if hostCount < 10 {
|
||||||
@ -565,7 +107,7 @@ func NewModel(hosts []config.SSHHost) Model {
|
|||||||
tableHeight += 10
|
tableHeight += 10
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create table
|
// Create the table
|
||||||
t := table.New(
|
t := table.New(
|
||||||
table.WithColumns(columns),
|
table.WithColumns(columns),
|
||||||
table.WithRows(rows),
|
table.WithRows(rows),
|
||||||
@ -577,13 +119,11 @@ func NewModel(hosts []config.SSHHost) Model {
|
|||||||
s := table.DefaultStyles()
|
s := table.DefaultStyles()
|
||||||
s.Header = s.Header.
|
s.Header = s.Header.
|
||||||
BorderStyle(lipgloss.NormalBorder()).
|
BorderStyle(lipgloss.NormalBorder()).
|
||||||
BorderForeground(lipgloss.Color("240")).
|
BorderForeground(lipgloss.Color(SecondaryColor)).
|
||||||
BorderBottom(true).
|
BorderBottom(true).
|
||||||
Bold(false)
|
Bold(false)
|
||||||
s.Selected = s.Selected.
|
s.Selected = m.styles.Selected
|
||||||
Foreground(lipgloss.Color("229")).
|
|
||||||
Background(lipgloss.Color("57")).
|
|
||||||
Bold(false)
|
|
||||||
t.SetStyles(s)
|
t.SetStyles(s)
|
||||||
|
|
||||||
// Update the model with the table and other properties
|
// Update the model with the table and other properties
|
||||||
@ -591,153 +131,22 @@ func NewModel(hosts []config.SSHHost) Model {
|
|||||||
m.searchInput = ti
|
m.searchInput = ti
|
||||||
m.filteredHosts = sortedHosts
|
m.filteredHosts = sortedHosts
|
||||||
|
|
||||||
|
// Initialize table styles based on initial focus state
|
||||||
|
m.updateTableStyles()
|
||||||
|
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunInteractiveMode starts the interactive TUI
|
// RunInteractiveMode starts the interactive TUI interface
|
||||||
func RunInteractiveMode(hosts []config.SSHHost) error {
|
func RunInteractiveMode(hosts []config.SSHHost) error {
|
||||||
for {
|
m := NewModel(hosts)
|
||||||
m := NewModel(hosts)
|
|
||||||
|
|
||||||
// Start the application in alt screen mode for clean exit
|
// Start the application in alt screen mode for clean output
|
||||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||||
finalModel, err := p.Run()
|
_, err := p.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error running TUI: %w", err)
|
return fmt.Errorf("error running TUI: %w", err)
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the final model indicates an action
|
|
||||||
if model, ok := finalModel.(Model); ok {
|
|
||||||
if model.exitAction == "edit" && model.exitHostName != "" {
|
|
||||||
// Launch the dedicated edit form (opens in separate window)
|
|
||||||
if err := RunEditForm(model.exitHostName); err != nil {
|
|
||||||
fmt.Printf("Error editing host: %v\n", err)
|
|
||||||
// Continue the loop to return to the main interface
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh the hosts list after editing
|
|
||||||
refreshedHosts, err := config.ParseSSHConfig()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error refreshing hosts after edit: %w", err)
|
|
||||||
}
|
|
||||||
hosts = refreshedHosts
|
|
||||||
|
|
||||||
// Continue the loop to return to the main interface
|
|
||||||
continue
|
|
||||||
} else if model.exitAction == "add" {
|
|
||||||
// Launch the dedicated add form (opens in separate window)
|
|
||||||
if err := RunAddForm(""); err != nil {
|
|
||||||
fmt.Printf("Error adding host: %v\n", err)
|
|
||||||
// Continue the loop to return to the main interface
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh the hosts list after adding
|
|
||||||
refreshedHosts, err := config.ParseSSHConfig()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error refreshing hosts after add: %w", err)
|
|
||||||
}
|
|
||||||
hosts = refreshedHosts
|
|
||||||
|
|
||||||
// Continue the loop to return to the main interface
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no special command, exit normally
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// filterHosts filters hosts based on search query (name or tags)
|
|
||||||
func (m Model) filterHosts(query string) []config.SSHHost {
|
|
||||||
var filtered []config.SSHHost
|
|
||||||
|
|
||||||
if query == "" {
|
|
||||||
filtered = m.hosts
|
|
||||||
} else {
|
|
||||||
query = strings.ToLower(query)
|
|
||||||
|
|
||||||
for _, host := range m.hosts {
|
|
||||||
// Check host name
|
|
||||||
if strings.Contains(strings.ToLower(host.Name), query) {
|
|
||||||
filtered = append(filtered, host)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check hostname
|
|
||||||
if strings.Contains(strings.ToLower(host.Hostname), query) {
|
|
||||||
filtered = append(filtered, host)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check tags
|
|
||||||
for _, tag := range host.Tags {
|
|
||||||
if strings.Contains(strings.ToLower(tag), query) {
|
|
||||||
filtered = append(filtered, host)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return m.sortHosts(filtered)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateTableRows updates the table with filtered hosts
|
|
||||||
func (m *Model) updateTableRows() {
|
|
||||||
var rows []table.Row
|
|
||||||
hostsToShow := m.filteredHosts
|
|
||||||
if hostsToShow == nil {
|
|
||||||
hostsToShow = m.hosts
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, host := range hostsToShow {
|
|
||||||
// Format tags for display
|
|
||||||
var tagsStr string
|
|
||||||
if len(host.Tags) > 0 {
|
|
||||||
// Add # prefix to each tag and join with spaces
|
|
||||||
var formattedTags []string
|
|
||||||
for _, tag := range host.Tags {
|
|
||||||
formattedTags = append(formattedTags, "#"+tag)
|
|
||||||
}
|
|
||||||
tagsStr = strings.Join(formattedTags, " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format last login info
|
|
||||||
var lastLoginStr string
|
|
||||||
if m.historyManager != nil {
|
|
||||||
if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists {
|
|
||||||
lastLoginStr = formatTimeAgo(lastConnect)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rows = append(rows, table.Row{
|
|
||||||
host.Name,
|
|
||||||
host.Hostname,
|
|
||||||
host.User,
|
|
||||||
host.Port,
|
|
||||||
tagsStr,
|
|
||||||
lastLoginStr,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
m.table.SetRows(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
// enterEditMode initializes edit mode for a specific host
|
|
||||||
// renderDeleteConfirmation renders the delete confirmation dialog
|
|
||||||
func (m Model) renderDeleteConfirmation() string {
|
|
||||||
var view strings.Builder
|
|
||||||
|
|
||||||
view.WriteString(lipgloss.NewStyle().
|
|
||||||
BorderStyle(lipgloss.RoundedBorder()).
|
|
||||||
BorderForeground(lipgloss.Color("1")). // Red border
|
|
||||||
Padding(1, 2).
|
|
||||||
Render(fmt.Sprintf("⚠️ Delete SSH Host\n\nAre you sure you want to delete host '%s'?\n\nThis action cannot be undone.\n\nPress Enter to confirm or Esc to cancel", m.deleteHost)))
|
|
||||||
|
|
||||||
return view.String()
|
|
||||||
}
|
|
||||||
|
324
internal/ui/update.go
Normal file
324
internal/ui/update.go
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"sshm/internal/config"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Init initializes the model
|
||||||
|
func (m Model) Init() tea.Cmd {
|
||||||
|
return tea.Batch(
|
||||||
|
textinput.Blink,
|
||||||
|
// Ajoute ici d'autres tea.Cmd si tu veux charger des données, démarrer un spinner, etc.
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles model updates
|
||||||
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
|
||||||
|
// Handle different message types
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
// Update terminal size and recalculate styles
|
||||||
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
|
m.styles = NewStyles(m.width)
|
||||||
|
m.ready = true
|
||||||
|
|
||||||
|
// Update sub-forms if they exist
|
||||||
|
if m.addForm != nil {
|
||||||
|
m.addForm.width = m.width
|
||||||
|
m.addForm.height = m.height
|
||||||
|
m.addForm.styles = m.styles
|
||||||
|
}
|
||||||
|
if m.editForm != nil {
|
||||||
|
m.editForm.width = m.width
|
||||||
|
m.editForm.height = m.height
|
||||||
|
m.editForm.styles = m.styles
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case addFormSubmitMsg:
|
||||||
|
if msg.err != nil {
|
||||||
|
// Show error in form
|
||||||
|
if m.addForm != nil {
|
||||||
|
m.addForm.err = msg.err.Error()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
} else {
|
||||||
|
// Success: refresh hosts and return to list view
|
||||||
|
hosts, err := config.ParseSSHConfig()
|
||||||
|
if err != nil {
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
m.hosts = m.sortHosts(hosts)
|
||||||
|
m.filteredHosts = m.hosts
|
||||||
|
m.updateTableRows()
|
||||||
|
m.viewMode = ViewList
|
||||||
|
m.addForm = nil
|
||||||
|
m.table.Focus()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
case addFormCancelMsg:
|
||||||
|
// Cancel: return to list view
|
||||||
|
m.viewMode = ViewList
|
||||||
|
m.addForm = nil
|
||||||
|
m.table.Focus()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case editFormSubmitMsg:
|
||||||
|
if msg.err != nil {
|
||||||
|
// Show error in form
|
||||||
|
if m.editForm != nil {
|
||||||
|
m.editForm.err = msg.err.Error()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
} else {
|
||||||
|
// Success: refresh hosts and return to list view
|
||||||
|
hosts, err := config.ParseSSHConfig()
|
||||||
|
if err != nil {
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
m.hosts = m.sortHosts(hosts)
|
||||||
|
m.filteredHosts = m.hosts
|
||||||
|
m.updateTableRows()
|
||||||
|
m.viewMode = ViewList
|
||||||
|
m.editForm = nil
|
||||||
|
m.table.Focus()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
case editFormCancelMsg:
|
||||||
|
// Cancel: return to list view
|
||||||
|
m.viewMode = ViewList
|
||||||
|
m.editForm = nil
|
||||||
|
m.table.Focus()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case tea.KeyMsg:
|
||||||
|
// Handle view-specific key presses
|
||||||
|
switch m.viewMode {
|
||||||
|
case ViewAdd:
|
||||||
|
if m.addForm != nil {
|
||||||
|
var newForm *addFormModel
|
||||||
|
newForm, cmd = m.addForm.Update(msg)
|
||||||
|
m.addForm = newForm
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
case ViewEdit:
|
||||||
|
if m.editForm != nil {
|
||||||
|
var newForm *editFormModel
|
||||||
|
newForm, cmd = m.editForm.Update(msg)
|
||||||
|
m.editForm = newForm
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
case ViewList:
|
||||||
|
// Handle list view keys
|
||||||
|
return m.handleListViewKeys(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc", "ctrl+c":
|
||||||
|
if m.deleteMode {
|
||||||
|
// Exit delete mode
|
||||||
|
m.deleteMode = false
|
||||||
|
m.deleteHost = ""
|
||||||
|
m.table.Focus()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, tea.Quit
|
||||||
|
case "q":
|
||||||
|
if !m.searchMode && !m.deleteMode {
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
case "/", "ctrl+f":
|
||||||
|
if !m.searchMode && !m.deleteMode {
|
||||||
|
// Enter search mode
|
||||||
|
m.searchMode = true
|
||||||
|
m.updateTableStyles()
|
||||||
|
m.table.Blur()
|
||||||
|
m.searchInput.Focus()
|
||||||
|
return m, textinput.Blink
|
||||||
|
}
|
||||||
|
case "tab":
|
||||||
|
if !m.deleteMode {
|
||||||
|
// Switch focus between search input and table
|
||||||
|
if m.searchMode {
|
||||||
|
// Switch from search to table
|
||||||
|
m.searchMode = false
|
||||||
|
m.updateTableStyles()
|
||||||
|
m.searchInput.Blur()
|
||||||
|
m.table.Focus()
|
||||||
|
} else {
|
||||||
|
// Switch from table to search
|
||||||
|
m.searchMode = true
|
||||||
|
m.updateTableStyles()
|
||||||
|
m.table.Blur()
|
||||||
|
m.searchInput.Focus()
|
||||||
|
return m, textinput.Blink
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
case "enter":
|
||||||
|
if m.searchMode {
|
||||||
|
// Validate search and return to table mode to allow commands
|
||||||
|
m.searchMode = false
|
||||||
|
m.updateTableStyles()
|
||||||
|
m.searchInput.Blur()
|
||||||
|
m.table.Focus()
|
||||||
|
return m, nil
|
||||||
|
} else if m.deleteMode {
|
||||||
|
// Confirm deletion
|
||||||
|
err := config.DeleteSSHHost(m.deleteHost)
|
||||||
|
if err != nil {
|
||||||
|
// Could display an error message here
|
||||||
|
m.deleteMode = false
|
||||||
|
m.deleteHost = ""
|
||||||
|
m.table.Focus()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
// Refresh the hosts list
|
||||||
|
hosts, err := config.ParseSSHConfig()
|
||||||
|
if err != nil {
|
||||||
|
// Could display an error message here
|
||||||
|
m.deleteMode = false
|
||||||
|
m.deleteHost = ""
|
||||||
|
m.table.Focus()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.hosts = m.sortHosts(hosts)
|
||||||
|
m.filteredHosts = m.hosts
|
||||||
|
m.updateTableRows()
|
||||||
|
m.deleteMode = false
|
||||||
|
m.deleteHost = ""
|
||||||
|
m.table.Focus()
|
||||||
|
return m, nil
|
||||||
|
} else {
|
||||||
|
// Connect to the selected host
|
||||||
|
selected := m.table.SelectedRow()
|
||||||
|
if len(selected) > 0 {
|
||||||
|
hostName := selected[0] // The hostname is in the first column
|
||||||
|
|
||||||
|
// Record the connection in history
|
||||||
|
if m.historyManager != nil {
|
||||||
|
err := m.historyManager.RecordConnection(hostName)
|
||||||
|
if err != nil {
|
||||||
|
// Log the error but don't prevent the connection
|
||||||
|
fmt.Printf("Warning: Could not record connection history: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, tea.ExecProcess(exec.Command("ssh", hostName), func(err error) tea.Msg {
|
||||||
|
return tea.Quit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "e":
|
||||||
|
if !m.searchMode && !m.deleteMode {
|
||||||
|
// Edit the selected host
|
||||||
|
selected := m.table.SelectedRow()
|
||||||
|
if len(selected) > 0 {
|
||||||
|
hostName := selected[0] // The hostname is in the first column
|
||||||
|
editForm, err := NewEditForm(hostName, m.styles, m.width, m.height)
|
||||||
|
if err != nil {
|
||||||
|
// Handle error - could show in UI
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.editForm = editForm
|
||||||
|
m.viewMode = ViewEdit
|
||||||
|
return m, textinput.Blink
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "a":
|
||||||
|
if !m.searchMode && !m.deleteMode {
|
||||||
|
// Add a new host
|
||||||
|
m.addForm = NewAddForm("", m.styles, m.width, m.height)
|
||||||
|
m.viewMode = ViewAdd
|
||||||
|
return m, textinput.Blink
|
||||||
|
}
|
||||||
|
case "d":
|
||||||
|
if !m.searchMode && !m.deleteMode {
|
||||||
|
// Delete the selected host
|
||||||
|
selected := m.table.SelectedRow()
|
||||||
|
if len(selected) > 0 {
|
||||||
|
hostName := selected[0] // The hostname is in the first column
|
||||||
|
m.deleteMode = true
|
||||||
|
m.deleteHost = hostName
|
||||||
|
m.table.Blur()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "s":
|
||||||
|
if !m.searchMode && !m.deleteMode {
|
||||||
|
// Cycle through sort modes (only 2 modes now)
|
||||||
|
m.sortMode = (m.sortMode + 1) % 2
|
||||||
|
// Re-apply the current filter with the new sort mode
|
||||||
|
if m.searchInput.Value() != "" {
|
||||||
|
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||||
|
} else {
|
||||||
|
m.filteredHosts = m.sortHosts(m.hosts)
|
||||||
|
}
|
||||||
|
m.updateTableRows()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
case "r":
|
||||||
|
if !m.searchMode && !m.deleteMode {
|
||||||
|
// Switch to sort by recent (last used)
|
||||||
|
m.sortMode = SortByLastUsed
|
||||||
|
// Re-apply the current filter with the new sort mode
|
||||||
|
if m.searchInput.Value() != "" {
|
||||||
|
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||||
|
} else {
|
||||||
|
m.filteredHosts = m.sortHosts(m.hosts)
|
||||||
|
}
|
||||||
|
m.updateTableRows()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
case "n":
|
||||||
|
if !m.searchMode && !m.deleteMode {
|
||||||
|
// Switch to sort by name
|
||||||
|
m.sortMode = SortByName
|
||||||
|
// Re-apply the current filter with the new sort mode
|
||||||
|
if m.searchInput.Value() != "" {
|
||||||
|
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||||
|
} else {
|
||||||
|
m.filteredHosts = m.sortHosts(m.hosts)
|
||||||
|
}
|
||||||
|
m.updateTableRows()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the appropriate component based on mode
|
||||||
|
if m.searchMode {
|
||||||
|
oldValue := m.searchInput.Value()
|
||||||
|
m.searchInput, cmd = m.searchInput.Update(msg)
|
||||||
|
// Update filtered hosts only if the search value has changed
|
||||||
|
if m.searchInput.Value() != oldValue {
|
||||||
|
if m.searchInput.Value() != "" {
|
||||||
|
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||||
|
} else {
|
||||||
|
m.filteredHosts = m.sortHosts(m.hosts)
|
||||||
|
}
|
||||||
|
m.updateTableRows()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m.table, cmd = m.table.Update(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, cmd
|
||||||
|
}
|
57
internal/ui/utils.go
Normal file
57
internal/ui/utils.go
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// formatTimeAgo formats a time into a readable "X time ago" string
|
||||||
|
func formatTimeAgo(t time.Time) string {
|
||||||
|
now := time.Now()
|
||||||
|
duration := now.Sub(t)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case duration < time.Minute:
|
||||||
|
seconds := int(duration.Seconds())
|
||||||
|
if seconds <= 1 {
|
||||||
|
return "1 second ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d seconds ago", seconds)
|
||||||
|
case duration < time.Hour:
|
||||||
|
minutes := int(duration.Minutes())
|
||||||
|
if minutes == 1 {
|
||||||
|
return "1 minute ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d minutes ago", minutes)
|
||||||
|
case duration < 24*time.Hour:
|
||||||
|
hours := int(duration.Hours())
|
||||||
|
if hours == 1 {
|
||||||
|
return "1 hour ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d hours ago", hours)
|
||||||
|
case duration < 7*24*time.Hour:
|
||||||
|
days := int(duration.Hours() / 24)
|
||||||
|
if days == 1 {
|
||||||
|
return "1 day ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d days ago", days)
|
||||||
|
case duration < 30*24*time.Hour:
|
||||||
|
weeks := int(duration.Hours() / (24 * 7))
|
||||||
|
if weeks == 1 {
|
||||||
|
return "1 week ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d weeks ago", weeks)
|
||||||
|
case duration < 365*24*time.Hour:
|
||||||
|
months := int(duration.Hours() / (24 * 30))
|
||||||
|
if months == 1 {
|
||||||
|
return "1 month ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d months ago", months)
|
||||||
|
default:
|
||||||
|
years := int(duration.Hours() / (24 * 365))
|
||||||
|
if years == 1 {
|
||||||
|
return "1 year ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d years ago", years)
|
||||||
|
}
|
||||||
|
}
|
147
internal/ui/view.go
Normal file
147
internal/ui/view.go
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// View renders the complete user interface
|
||||||
|
func (m Model) View() string {
|
||||||
|
if !m.ready {
|
||||||
|
return "Loading..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different view modes
|
||||||
|
switch m.viewMode {
|
||||||
|
case ViewAdd:
|
||||||
|
if m.addForm != nil {
|
||||||
|
return m.addForm.View()
|
||||||
|
}
|
||||||
|
case ViewEdit:
|
||||||
|
if m.editForm != nil {
|
||||||
|
return m.editForm.View()
|
||||||
|
}
|
||||||
|
case ViewList:
|
||||||
|
return m.renderListView()
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.renderListView()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderListView renders the main list interface
|
||||||
|
func (m Model) renderListView() string {
|
||||||
|
// Build the interface components
|
||||||
|
components := []string{}
|
||||||
|
|
||||||
|
// Add the ASCII title
|
||||||
|
components = append(components, m.styles.Header.Render(asciiTitle))
|
||||||
|
|
||||||
|
// Add the search bar with the appropriate style based on focus
|
||||||
|
searchPrompt := "Search (/ to focus, Tab to switch): "
|
||||||
|
if m.searchMode {
|
||||||
|
components = append(components, m.styles.SearchFocused.Render(searchPrompt+m.searchInput.View()))
|
||||||
|
} else {
|
||||||
|
components = append(components, m.styles.SearchUnfocused.Render(searchPrompt+m.searchInput.View()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the sort mode indicator
|
||||||
|
sortInfo := fmt.Sprintf(" Sort: %s", m.sortMode.String())
|
||||||
|
components = append(components, m.styles.SortInfo.Render(sortInfo))
|
||||||
|
|
||||||
|
// Add the table with the appropriate style based on focus
|
||||||
|
if m.searchMode {
|
||||||
|
// The table is not focused, use the unfocused style
|
||||||
|
components = append(components, m.styles.TableUnfocused.Render(m.table.View()))
|
||||||
|
} else {
|
||||||
|
// The table is focused, use the focused style with the primary color
|
||||||
|
components = append(components, m.styles.TableFocused.Render(m.table.View()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the help text
|
||||||
|
var helpText string
|
||||||
|
if !m.searchMode {
|
||||||
|
helpText = " Use ↑/↓ to navigate • Enter to connect • (a)dd • (e)dit • (d)elete • / to search • Tab to switch\n Sort: (s)witch • (r)ecent • (n)ame • q/ESC to quit"
|
||||||
|
} else {
|
||||||
|
helpText = " Type to filter hosts • Enter to validate search • Tab to switch to table • ESC to quit"
|
||||||
|
}
|
||||||
|
components = append(components, m.styles.HelpText.Render(helpText))
|
||||||
|
|
||||||
|
// Join all components vertically with appropriate spacing
|
||||||
|
mainView := m.styles.App.Render(
|
||||||
|
lipgloss.JoinVertical(
|
||||||
|
lipgloss.Left,
|
||||||
|
components...,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// If in delete mode, overlay the confirmation dialog
|
||||||
|
if m.deleteMode {
|
||||||
|
// Combine the main view with the confirmation dialog overlay
|
||||||
|
confirmation := m.renderDeleteConfirmation()
|
||||||
|
|
||||||
|
// Center the confirmation dialog on the screen
|
||||||
|
centeredConfirmation := lipgloss.Place(
|
||||||
|
m.width,
|
||||||
|
m.height,
|
||||||
|
lipgloss.Center,
|
||||||
|
lipgloss.Center,
|
||||||
|
confirmation,
|
||||||
|
)
|
||||||
|
|
||||||
|
return centeredConfirmation
|
||||||
|
}
|
||||||
|
|
||||||
|
return mainView
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderDeleteConfirmation renders a clean delete confirmation dialog
|
||||||
|
func (m Model) renderDeleteConfirmation() string {
|
||||||
|
// Remove emojis (uncertain width depending on terminal) to stabilize the frame
|
||||||
|
title := "DELETE SSH HOST"
|
||||||
|
question := fmt.Sprintf("Are you sure you want to delete host '%s'?", m.deleteHost)
|
||||||
|
action := "This action cannot be undone."
|
||||||
|
help := "Enter: confirm • Esc: cancel"
|
||||||
|
|
||||||
|
// Individual styles (do not affect width via internal centering)
|
||||||
|
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("196"))
|
||||||
|
questionStyle := lipgloss.NewStyle()
|
||||||
|
actionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||||
|
helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||||
|
|
||||||
|
lines := []string{
|
||||||
|
titleStyle.Render(title),
|
||||||
|
"",
|
||||||
|
questionStyle.Render(question),
|
||||||
|
"",
|
||||||
|
actionStyle.Render(action),
|
||||||
|
"",
|
||||||
|
helpStyle.Render(help),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the real maximum width (ANSI-safe via lipgloss.Width)
|
||||||
|
maxw := 0
|
||||||
|
for _, ln := range lines {
|
||||||
|
w := lipgloss.Width(ln)
|
||||||
|
if w > maxw {
|
||||||
|
maxw = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Minimal width for aesthetics
|
||||||
|
if maxw < 40 {
|
||||||
|
maxw = 40
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the raw text block (without centering) then apply the container style
|
||||||
|
raw := strings.Join(lines, "\n")
|
||||||
|
|
||||||
|
// Container style: wider horizontal padding, stable border
|
||||||
|
box := lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("196")).
|
||||||
|
PaddingTop(1).PaddingBottom(1).PaddingLeft(2).PaddingRight(2).
|
||||||
|
Width(maxw + 4) // +4 = internal margin (2 spaces of left/right padding)
|
||||||
|
|
||||||
|
return box.Render(raw)
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user