Skip to content

Commit 240c7bf

Browse files
committed
feat: enhance deploy view with dynamic terminal sizing and footer updates
1 parent 40d7c96 commit 240c7bf

File tree

1 file changed

+129
-57
lines changed

1 file changed

+129
-57
lines changed

internal/tui/deploy.go

Lines changed: 129 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/toeirei/keymaster/internal/model"
2020
"github.com/toeirei/keymaster/internal/state"
2121
"github.com/toeirei/keymaster/internal/ui"
22+
"golang.org/x/term"
2223
)
2324

2425
type deployState int
@@ -83,13 +84,25 @@ type deployModel struct {
8384
func newDeployModelWithSearcher(s ui.AccountSearcher) deployModel {
8485
pi := newPassphraseInput()
8586
fi := newFilenameInput()
86-
return deployModel{
87+
m := deployModel{
8788
state: deployStateMenu,
8889
fleetResults: make(map[int]error),
8990
passphraseInput: pi,
9091
filenameInput: fi,
9192
searcher: s,
9293
}
94+
95+
// Try to initialize with the current terminal size so the view fills
96+
// the available area on first render. Fall back to 80x24 on error.
97+
if w, h, err := term.GetSize(int(os.Stdout.Fd())); err == nil {
98+
m.width = w
99+
m.height = h
100+
} else {
101+
m.width = 80
102+
m.height = 24
103+
}
104+
105+
return m
93106
}
94107

95108
// newDeployModel is a convenience wrapper that uses the package default searcher.
@@ -125,6 +138,11 @@ func newFilenameInput() textinput.Model {
125138

126139
// Update handles messages and updates the deploy model's state.
127140
func (m deployModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
141+
// Update model size when terminal is resized so the view can reflow.
142+
if sizeMsg, ok := msg.(tea.WindowSizeMsg); ok {
143+
m.width = sizeMsg.Width
144+
m.height = sizeMsg.Height
145+
}
128146
switch m.state {
129147
case deployStateMenu:
130148
return m.updateMenu(msg)
@@ -232,15 +250,15 @@ func (m deployModel) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
232250
case "up", "k":
233251
if m.menuCursor > 0 {
234252
m.menuCursor--
235-
m.hasInteracted = true
253+
// no-op: do not track hasInteracted for deploy view
236254
}
237255
case "down", "j":
238256
if m.menuCursor < 3 { // There are 4 menu items (0-3)
239257
m.menuCursor++
240-
m.hasInteracted = true
258+
// no-op: do not track hasInteracted for deploy view
241259
}
242260
case "enter":
243-
m.hasInteracted = true
261+
// no-op: do not track hasInteracted for deploy view
244262
switch m.menuCursor {
245263
case 0: // Deploy to Fleet (fully automatic)
246264
m.wasFleetDeploy = true
@@ -454,15 +472,15 @@ func (m deployModel) updateSelectTag(msg tea.Msg) (tea.Model, tea.Cmd) {
454472
case "up", "k":
455473
if m.tagCursor > 0 {
456474
m.tagCursor--
457-
m.hasInteracted = true
475+
// no-op: do not track hasInteracted for deploy view
458476
}
459477
case "down", "j":
460478
if m.tagCursor < len(m.tags)-1 {
461479
m.tagCursor++
462-
m.hasInteracted = true
480+
// no-op: do not track hasInteracted for deploy view
463481
}
464482
case "enter":
465-
m.hasInteracted = true
483+
// no-op: do not track hasInteracted for deploy view
466484
if len(m.tags) == 0 {
467485
return m, nil
468486
}
@@ -515,11 +533,11 @@ func (m deployModel) updateShowAuthorizedKeys(msg tea.Msg) (tea.Model, tea.Cmd)
515533
m.status = i18n.T("deploy.status.copy_failed", err.Error())
516534
} else {
517535
m.status = i18n.T("deploy.status.copy_success")
518-
m.hasInteracted = true
536+
// no-op: do not track hasInteracted for deploy view
519537
}
520538
return m, nil
521539
case "s":
522-
m.hasInteracted = true
540+
// no-op: do not track hasInteracted for deploy view
523541
m.state = deployStateEnterFilename
524542
m.filenameInput.Focus()
525543
m.status = ""
@@ -590,12 +608,16 @@ func (m deployModel) View() string {
590608

591609
paneStyle := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colorSubtle).Padding(1, 2)
592610
helpFooterStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Background(lipgloss.Color("236")).Padding(0, 1).Italic(true)
611+
paneWidth := m.width
612+
if paneWidth <= 0 {
613+
paneWidth = 80
614+
}
593615

594616
if m.err != nil {
595617
title := titleStyle.Render(i18n.T("deploy.failed"))
596618
help := helpFooterStyle.Render(i18n.T("deploy.help_failed"))
597619
content := fmt.Sprintf(i18n.T("account_form.error"), m.err)
598-
mainPane := paneStyle.Width(60).Render(lipgloss.JoinVertical(lipgloss.Left, title, "", content))
620+
mainPane := paneStyle.Width(paneWidth).Render(lipgloss.JoinVertical(lipgloss.Left, title, "", content))
599621
return lipgloss.JoinVertical(lipgloss.Left, mainPane, "", help)
600622
}
601623

@@ -612,14 +634,16 @@ func (m deployModel) View() string {
612634
listItems = append(listItems, itemStyle.Render(" "+label))
613635
}
614636
}
615-
mainPane := paneStyle.Width(60).Render(lipgloss.JoinVertical(lipgloss.Left, title, "", lipgloss.JoinVertical(lipgloss.Left, listItems...)))
616-
// Use AlignFooter for consistent right-aligned layout and honor initial hint
617-
var help string
618-
if !m.hasInteracted {
619-
help = helpFooterStyle.Render(AlignFooter(i18n.T("deploy.footer_empty"), "", m.width))
620-
} else {
621-
help = helpFooterStyle.Render(AlignFooter(i18n.T("deploy.help_menu"), "", m.width))
637+
footerStr := m.footerFor(paneWidth)
638+
headerHeight := lipgloss.Height(title)
639+
footerHeight := lipgloss.Height(footerStr)
640+
paneHeight := m.height - headerHeight - footerHeight - 2
641+
if paneHeight <= 2 {
642+
paneHeight = 3
622643
}
644+
mainPane := paneStyle.Width(paneWidth).Height(paneHeight).Render(lipgloss.JoinVertical(lipgloss.Left, title, "", lipgloss.JoinVertical(lipgloss.Left, listItems...)))
645+
// Centralized footer for consistent right-aligned layout
646+
help := footerStr
623647
if m.status != "" {
624648
mainPane += "\n" + helpFooterStyle.Render(m.status)
625649
}
@@ -644,22 +668,15 @@ func (m deployModel) View() string {
644668
}
645669
}
646670
}
647-
mainPane := paneStyle.Width(60).Render(lipgloss.JoinVertical(lipgloss.Left, title, "", lipgloss.JoinVertical(lipgloss.Left, listItems...)))
648-
var filterStatus string
649-
if m.isFilteringAccount {
650-
filterStatus = i18n.T("deploy.filtering", m.accountFilter)
651-
} else if m.accountFilter != "" {
652-
filterStatus = i18n.T("deploy.filter_active", m.accountFilter)
653-
} else {
654-
filterStatus = i18n.T("deploy.filter_hint")
655-
}
656-
left := i18n.T("deploy.help_select")
657-
help := helpFooterStyle.Render(AlignFooter(left, filterStatus, m.width))
658-
659-
// If the user has not interacted yet, show a compact initial hint instead
660-
if !m.hasInteracted {
661-
return lipgloss.JoinVertical(lipgloss.Left, mainPane, "", helpFooterStyle.Render(AlignFooter(i18n.T("deploy.footer_empty"), "", m.width)))
671+
footerStr := m.footerFor(paneWidth)
672+
headerHeight := lipgloss.Height(title)
673+
footerHeight := lipgloss.Height(footerStr)
674+
paneHeight := m.height - headerHeight - footerHeight - 2
675+
if paneHeight <= 2 {
676+
paneHeight = 3
662677
}
678+
mainPane := paneStyle.Width(paneWidth).Height(paneHeight).Render(lipgloss.JoinVertical(lipgloss.Left, title, "", lipgloss.JoinVertical(lipgloss.Left, listItems...)))
679+
help := footerStr
663680
return lipgloss.JoinVertical(lipgloss.Left, mainPane, "", help)
664681

665682
case deployStateSelectTag:
@@ -676,15 +693,16 @@ func (m deployModel) View() string {
676693
}
677694
}
678695
}
679-
mainPane := paneStyle.Width(60).Render(lipgloss.JoinVertical(lipgloss.Left, title, "", lipgloss.JoinVertical(lipgloss.Left, listItems...)))
680-
// Show compact initial hint until user interacts
681-
var h string
682-
if !m.hasInteracted {
683-
h = helpFooterStyle.Render(AlignFooter(i18n.T("deploy.footer_empty"), "", m.width))
684-
} else {
685-
h = helpFooterStyle.Render(AlignFooter(i18n.T("deploy.help_select"), "", m.width))
696+
footerStr := m.footerFor(paneWidth)
697+
headerHeight := lipgloss.Height(title)
698+
footerHeight := lipgloss.Height(footerStr)
699+
paneHeight := m.height - headerHeight - footerHeight - 2
700+
if paneHeight <= 2 {
701+
paneHeight = 3
686702
}
687-
return lipgloss.JoinVertical(lipgloss.Left, mainPane, "", h)
703+
mainPane := paneStyle.Width(paneWidth).Height(paneHeight).Render(lipgloss.JoinVertical(lipgloss.Left, title, "", lipgloss.JoinVertical(lipgloss.Left, listItems...)))
704+
help := footerStr
705+
return lipgloss.JoinVertical(lipgloss.Left, mainPane, "", help)
688706

689707
case deployStateShowAuthorizedKeys:
690708
// Render just the keys for easy copy-pasting, with a title and help outside the main content.
@@ -693,14 +711,17 @@ func (m deployModel) View() string {
693711
if m.status != "" {
694712
content = append(content, statusMessageStyle.Render(m.status), "")
695713
}
696-
mainPane := lipgloss.JoinVertical(lipgloss.Left, title, "", lipgloss.JoinVertical(lipgloss.Left, content...), m.authorizedKeys)
697-
var h string
698-
if !m.hasInteracted {
699-
h = helpFooterStyle.Render(AlignFooter(i18n.T("deploy.footer_empty"), "", m.width))
700-
} else {
701-
h = helpFooterStyle.Render(AlignFooter(i18n.T("deploy.help_keys"), "", m.width))
714+
contentStr := lipgloss.JoinVertical(lipgloss.Left, title, "", lipgloss.JoinVertical(lipgloss.Left, content...), m.authorizedKeys)
715+
footerStr := m.footerFor(paneWidth)
716+
headerHeight := lipgloss.Height(title)
717+
footerHeight := lipgloss.Height(footerStr)
718+
paneHeight := m.height - headerHeight - footerHeight - 2
719+
if paneHeight <= 2 {
720+
paneHeight = 3
702721
}
703-
return lipgloss.JoinVertical(lipgloss.Left, mainPane, "", h)
722+
mainPane := paneStyle.Width(paneWidth).Height(paneHeight).Render(contentStr)
723+
help := footerStr
724+
return lipgloss.JoinVertical(lipgloss.Left, mainPane, "", help)
704725

705726
case deployStateEnterFilename:
706727
var b strings.Builder
@@ -735,8 +756,15 @@ func (m deployModel) View() string {
735756
}
736757
statusLines = append(statusLines, fmt.Sprintf(" %s %s", acc.String(), status))
737758
}
738-
mainPane := paneStyle.Width(60).Render(lipgloss.JoinVertical(lipgloss.Left, title, "", lipgloss.JoinVertical(lipgloss.Left, statusLines...)))
739-
help := helpFooterStyle.Render(AlignFooter(i18n.T("deploy.help_wait"), "", m.width))
759+
footerStr := m.footerFor(paneWidth)
760+
headerHeight := lipgloss.Height(title)
761+
footerHeight := lipgloss.Height(footerStr)
762+
paneHeight := m.height - headerHeight - footerHeight - 2
763+
if paneHeight <= 2 {
764+
paneHeight = 3
765+
}
766+
mainPane := paneStyle.Width(paneWidth).Height(paneHeight).Render(lipgloss.JoinVertical(lipgloss.Left, title, "", lipgloss.JoinVertical(lipgloss.Left, statusLines...)))
767+
help := footerStr
740768
if m.status != "" {
741769
mainPane += "\n" + helpFooterStyle.Render(m.status)
742770
}
@@ -762,7 +790,14 @@ func (m deployModel) View() string {
762790

763791
case deployStateInProgress:
764792
title := titleStyle.Render(i18n.T("deploy.deploying"))
765-
mainPane := paneStyle.Width(60).Render(lipgloss.JoinVertical(lipgloss.Left, title, "", m.status))
793+
footerStr := m.footerFor(paneWidth)
794+
headerHeight := lipgloss.Height(title)
795+
footerHeight := lipgloss.Height(footerStr)
796+
paneHeight := m.height - headerHeight - footerHeight - 2
797+
if paneHeight <= 2 {
798+
paneHeight = 3
799+
}
800+
mainPane := paneStyle.Width(paneWidth).Height(paneHeight).Render(lipgloss.JoinVertical(lipgloss.Left, title, "", m.status))
766801
return lipgloss.JoinVertical(lipgloss.Left, mainPane)
767802

768803
case deployStateComplete:
@@ -791,16 +826,53 @@ func (m deployModel) View() string {
791826
}
792827

793828
resultBlock := renderResultBlock(primary, warnings, m.err)
794-
mainPane := paneStyle.Width(60).Render(lipgloss.JoinVertical(lipgloss.Left, title, "", resultBlock))
795-
var h string
796-
if !m.hasInteracted {
797-
h = helpFooterStyle.Render(AlignFooter(i18n.T("deploy.footer_empty"), "", m.width))
829+
mainPane := paneStyle.Width(paneWidth).Render(lipgloss.JoinVertical(lipgloss.Left, title, "", resultBlock))
830+
help := m.footerFor(paneWidth)
831+
return lipgloss.JoinVertical(lipgloss.Left, mainPane, "", help)
832+
}
833+
return ""
834+
}
835+
836+
// footerFor builds the standardized footer for the deploy view based on
837+
// the current state and filter flags. It returns a fully styled string.
838+
func (m deployModel) footerFor(width int) string {
839+
footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Background(lipgloss.Color("236")).Padding(0, 1).Italic(true)
840+
841+
// Default tokens
842+
left := ""
843+
right := ""
844+
845+
switch m.state {
846+
case deployStateMenu:
847+
left = i18n.T("deploy.help_menu")
848+
case deployStateSelectAccount:
849+
left = i18n.T("deploy.help_select")
850+
if m.isFilteringAccount {
851+
right = i18n.T("deploy.filtering", m.accountFilter)
852+
} else if m.accountFilter != "" {
853+
right = i18n.T("deploy.filter_active", m.accountFilter)
798854
} else {
799-
h = helpFooterStyle.Render(AlignFooter(i18n.T("deploy.help_complete"), "", m.width))
855+
right = i18n.T("deploy.filter_hint")
800856
}
801-
return lipgloss.JoinVertical(lipgloss.Left, mainPane, "", h)
857+
case deployStateSelectTag:
858+
left = i18n.T("deploy.help_select")
859+
case deployStateShowAuthorizedKeys:
860+
left = i18n.T("deploy.help_keys")
861+
case deployStateFleetInProgress:
862+
left = i18n.T("deploy.help_wait")
863+
case deployStateEnterPassphrase:
864+
left = i18n.T("deploy.passphrase_help")
865+
case deployStateEnterFilename:
866+
left = i18n.T("deploy.help_enter_filename")
867+
case deployStateInProgress:
868+
left = ""
869+
case deployStateComplete:
870+
left = i18n.T("deploy.help_complete")
871+
default:
872+
left = i18n.T("deploy.help_menu")
802873
}
803-
return ""
874+
875+
return footerStyle.Render(AlignFooter(left, right, width))
804876
}
805877

806878
// performDeploymentCmd is a tea.Cmd that executes the full deployment logic for a single account.

0 commit comments

Comments
 (0)