diff --git a/internal/app/app.go b/internal/app/app.go index b875484..3e49d14 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -9,8 +9,6 @@ import ( "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "unic/internal/config" "unic/internal/domain" awsservice "unic/internal/services/aws" @@ -825,12 +823,13 @@ func (m Model) View() string { func (m Model) viewServiceList() string { var b strings.Builder + var panel strings.Builder b.WriteString(m.renderStatusBar()) b.WriteString(titleStyle.Render("Select AWS Service")) b.WriteString("\n\n") - // overhead: status bar (2 lines) + title (1) + blank (1) + blank (1) + footer (1) = 6 - visibleLines := max(m.height-6, 3) + // overhead: status bar (2) + title (1) + blank (1) + list panel (2) + blank (1) + help bar (1) = 8 + visibleLines := max(m.height-8, 3) start := 0 if m.svcIdx >= visibleLines { start = m.svcIdx - visibleLines + 1 @@ -845,25 +844,27 @@ func (m Model) viewServiceList() string { cursor = "> " style = selectedStyle } - b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, svc.Name))) - b.WriteString("\n") + panel.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, svc.Name))) + panel.WriteString("\n") } - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • enter: select • esc: context • q: quit")) + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: navigate • enter: select • esc: context • q: quit")) return b.String() } func (m Model) viewFeatureList() string { var b strings.Builder + var panel strings.Builder b.WriteString(m.renderStatusBar()) svcName := m.services[m.svcIdx].Name b.WriteString(titleStyle.Render(fmt.Sprintf("%s > Select Feature", svcName))) b.WriteString("\n\n") // Each selected item takes 2 lines (name + description), others take 1. - // overhead: status bar (2) + title (1) + blank (1) + blank (1) + footer (1) = 6 - visibleLines := max(m.height-6, 3) + // overhead: status bar (2) + title (1) + blank (1) + list panel (2) + blank (1) + help bar (1) = 8 + visibleLines := max(m.height-8, 3) start := 0 // Count lines from start to cursor to determine if we need to scroll linesFromStart := 0 @@ -905,17 +906,18 @@ func (m Model) viewFeatureList() string { cursor = "> " style = selectedStyle } - b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, feat.Kind))) - b.WriteString("\n") + panel.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, feat.Kind))) + panel.WriteString("\n") if i == m.featIdx { - b.WriteString(dimStyle.Render(fmt.Sprintf(" %s", feat.Description))) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %s", feat.Description))) + panel.WriteString("\n") } linesUsed += needed } - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • enter: select • esc: back")) + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: navigate • enter: select • esc: back")) return b.String() } func (m Model) loadSecrets() tea.Cmd { @@ -996,6 +998,7 @@ func (m Model) updateSecretDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) viewSecretList() string { var b strings.Builder + var panel strings.Builder b.WriteString(m.renderStatusBar()) b.WriteString(titleStyle.Render("Secrets Manager")) b.WriteString("\n") @@ -1004,10 +1007,10 @@ func (m Model) viewSecretList() string { b.WriteString("\n\n") if len(m.filteredSecrets) == 0 { - b.WriteString(dimStyle.Render(" No matching secrets")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" No matching secrets")) + panel.WriteString("\n") } else { - visibleLines := max(m.height-8, 5) + visibleLines := max(m.height-10, 5) start := 0 if m.secretIdx >= visibleLines { start = m.secretIdx - visibleLines + 1 @@ -1022,16 +1025,17 @@ func (m Model) viewSecretList() string { cursor = "> " style = selectedStyle } - b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, s.DisplayTitle()))) - b.WriteString("\n") + panel.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, s.DisplayTitle()))) + panel.WriteString("\n") } - b.WriteString("\n") - b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d secrets", len(m.filteredSecrets), len(m.secrets)))) + panel.WriteString("\n") + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d secrets", len(m.filteredSecrets), len(m.secrets)))) } - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • enter: detail • esc: back • H: home")) + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: navigate • /: filter • enter: detail • esc: back • H: home")) return b.String() } @@ -1045,15 +1049,14 @@ func (m Model) viewSecretDetail() string { b.WriteString(titleStyle.Render("Secret Detail")) b.WriteString("\n\n") - labelStyle := lipgloss.NewStyle().Width(14) - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Name"), d.Name))) + b.WriteString(renderDetailLine("Name", normalStyle.Render(d.Name))) b.WriteString("\n") kmsKey := d.KMSKeyID if kmsKey == "" { kmsKey = dimStyle.Render("(aws/secretsmanager)") } - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Encryption Key"), kmsKey))) + b.WriteString(renderDetailLine("Encryption Key", kmsKey)) b.WriteString("\n\n") if len(d.Values) > 0 { @@ -1081,6 +1084,6 @@ func (m Model) viewSecretDetail() string { } b.WriteString("\n") - b.WriteString(dimStyle.Render("esc: back • H: home")) + b.WriteString(m.renderHelpBar("esc: back • H: home")) return b.String() } diff --git a/internal/app/context_add.go b/internal/app/context_add.go index 673a014..4612260 100644 --- a/internal/app/context_add.go +++ b/internal/app/context_add.go @@ -157,7 +157,7 @@ func (m Model) viewContextAdd() string { b.WriteString("\n") } b.WriteString("\n") - b.WriteString(dimStyle.Render(" ↑/↓: navigate • enter: select • esc: cancel")) + b.WriteString(m.renderHelpBar("↑/↓: navigate • enter: select • esc: cancel")) return b.String() } @@ -189,10 +189,10 @@ func (m Model) viewContextAdd() string { b.WriteString("\n") b.WriteString(normalStyle.Render(" Save this context?")) b.WriteString("\n\n") - b.WriteString(dimStyle.Render(" enter: save • esc: cancel")) + b.WriteString(m.renderHelpBar("enter: save • esc: cancel")) } else if m.addStep > 0 { b.WriteString("\n") - b.WriteString(dimStyle.Render(" enter: next • esc: back")) + b.WriteString(m.renderHelpBar("enter: next • esc: back")) } return b.String() diff --git a/internal/app/context_table.go b/internal/app/context_table.go index b0e8790..def7ff4 100644 --- a/internal/app/context_table.go +++ b/internal/app/context_table.go @@ -11,6 +11,8 @@ import ( const ( defaultContextTableWidth = 52 defaultContextTableHeight = 4 + contextTableColumnCount = 4 + contextTableCellPadding = 2 ) func newContextTable() table.Model { @@ -93,12 +95,17 @@ func contextTableHeight(terminalHeight int) int { if terminalHeight <= 0 { return defaultContextTableHeight } - visibleRows := max(terminalHeight-6, 3) - return visibleRows + 1 + // Context picker layout overhead: + // title/filter block (3) + panel border (2) + separator/help bar (2) = 7. + // The table height itself must fit inside the remaining rows. + return max(terminalHeight-7, 3) } func contextTableColumns(terminalWidth int) []table.Column { - available := contextTableWidth(terminalWidth) + available := contextTableWidth(terminalWidth) - contextTableColumnCount*contextTableCellPadding + if available < 16 { + available = 16 + } currentWidth := 7 regionWidth := 12 diff --git a/internal/app/help.go b/internal/app/help.go index cc30e0c..307c72f 100644 --- a/internal/app/help.go +++ b/internal/app/help.go @@ -37,7 +37,7 @@ func (m Model) viewHelp() string { b.WriteString("\n") } - b.WriteString(dimStyle.Render("?: close help • esc: close help • enter: close help")) + b.WriteString(m.renderHelpBar("?: close help • esc: close help • enter: close help")) return strings.TrimRight(b.String(), "\n") } diff --git a/internal/app/screen_cloudwatchlogs.go b/internal/app/screen_cloudwatchlogs.go index 6859a28..93221fc 100644 --- a/internal/app/screen_cloudwatchlogs.go +++ b/internal/app/screen_cloudwatchlogs.go @@ -186,6 +186,7 @@ func (m Model) updateCWLogGroupList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) viewCWLogGroupList() string { var b strings.Builder + var panel strings.Builder b.WriteString(m.renderStatusBar()) b.WriteString(titleStyle.Render("CloudWatch Log Groups")) b.WriteString("\n") @@ -194,8 +195,8 @@ func (m Model) viewCWLogGroupList() string { b.WriteString("\n\n") if len(m.filteredCWLogGroups) == 0 { - b.WriteString(dimStyle.Render(" No matching log groups")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" No matching log groups")) + panel.WriteString("\n") } else { maxName := 4 // "NAME" for _, g := range m.filteredCWLogGroups { @@ -209,10 +210,10 @@ func (m Model) viewCWLogGroupList() string { nameCol := lipgloss.NewStyle().Width(maxName + 2) retCol := lipgloss.NewStyle().Width(14) - b.WriteString(dimStyle.Render(" " + nameCol.Render("NAME") + retCol.Render("RETENTION") + "SIZE")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" " + nameCol.Render("NAME") + retCol.Render("RETENTION") + "SIZE")) + panel.WriteString("\n") - visibleLines := max(m.height-9, 5) + visibleLines := max(m.height-11, 5) start := 0 if m.cwLogGroupIdx >= visibleLines { start = m.cwLogGroupIdx - visibleLines + 1 @@ -239,20 +240,21 @@ func (m Model) viewCWLogGroupList() string { nameCol.Inherit(style).Render(name) + retCol.Inherit(dimStyle).Render(retention) + dimStyle.Render(awsservice.FormatBytes(g.StoredBytes)) - b.WriteString(row) - b.WriteString("\n") + panel.WriteString(row) + panel.WriteString("\n") } - b.WriteString("\n") + panel.WriteString("\n") countLine := fmt.Sprintf(" %d/%d log groups", len(m.filteredCWLogGroups), len(m.cwLogGroups)) if m.cwLogGroupNextToken != nil { countLine += " • more available" } - b.WriteString(dimStyle.Render(countLine)) + panel.WriteString(dimStyle.Render(countLine)) } - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • n: load more • enter: streams • esc: back • H: home")) + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: navigate • /: filter • n: load more • enter: streams • esc: back • H: home")) return b.String() } @@ -302,6 +304,7 @@ func (m Model) updateCWLogStreamList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) viewCWLogStreamList() string { var b strings.Builder + var panel strings.Builder b.WriteString(m.renderStatusBar()) groupName := "" if m.selectedCWLogGroup != nil { @@ -314,8 +317,8 @@ func (m Model) viewCWLogStreamList() string { b.WriteString("\n\n") if len(m.filteredCWLogStreams) == 0 { - b.WriteString(dimStyle.Render(" No matching log streams")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" No matching log streams")) + panel.WriteString("\n") } else { maxName := 4 for _, s := range m.filteredCWLogStreams { @@ -328,10 +331,10 @@ func (m Model) viewCWLogStreamList() string { } nameCol := lipgloss.NewStyle().Width(maxName + 2) - b.WriteString(dimStyle.Render(" " + nameCol.Render("NAME") + "LAST EVENT")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" " + nameCol.Render("NAME") + "LAST EVENT")) + panel.WriteString("\n") - visibleLines := max(m.height-9, 5) + visibleLines := max(m.height-11, 5) start := 0 if m.cwLogStreamIdx >= visibleLines { start = m.cwLogStreamIdx - visibleLines + 1 @@ -357,20 +360,21 @@ func (m Model) viewCWLogStreamList() string { row := cursor + nameCol.Inherit(style).Render(name) + dimStyle.Render(lastEvent) - b.WriteString(row) - b.WriteString("\n") + panel.WriteString(row) + panel.WriteString("\n") } - b.WriteString("\n") + panel.WriteString("\n") countLine := fmt.Sprintf(" %d/%d streams", len(m.filteredCWLogStreams), len(m.cwLogStreams)) if m.cwLogStreamNextToken != nil { countLine += " • more available" } - b.WriteString(dimStyle.Render(countLine)) + panel.WriteString(dimStyle.Render(countLine)) } - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • n: load more • enter: view logs • esc: back • H: home")) + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: navigate • /: filter • n: load more • enter: view logs • esc: back • H: home")) return b.String() } @@ -519,7 +523,7 @@ func (m Model) viewCWLogViewer() string { hint += fmt.Sprintf(" • h/l: horizontal (%d)", m.cwLogHorizontalOffset) } hint += " • n: load more • esc: back" - b.WriteString(dimStyle.Render(hint)) + b.WriteString(m.renderHelpBar(hint)) return b.String() } diff --git a/internal/app/screen_context.go b/internal/app/screen_context.go index ab5f927..27c014a 100644 --- a/internal/app/screen_context.go +++ b/internal/app/screen_context.go @@ -172,6 +172,7 @@ func (m Model) doFinalizeContextSwitch() tea.Cmd { func (m Model) viewContextPicker() string { var b strings.Builder + var panel strings.Builder b.WriteString(titleStyle.Render("Select Context")) b.WriteString("\n") @@ -179,23 +180,24 @@ func (m Model) viewContextPicker() string { b.WriteString("\n\n") if len(m.ctxList) == 0 { - b.WriteString(normalStyle.Render(" No contexts defined.")) - b.WriteString("\n\n") - b.WriteString(dimStyle.Render(" Press 'a' to add your first context.")) - b.WriteString("\n") + panel.WriteString(normalStyle.Render(" No contexts defined.")) + panel.WriteString("\n\n") + panel.WriteString(dimStyle.Render(" Press 'a' to add your first context.")) + panel.WriteString("\n") } else if len(m.filteredCtxList) == 0 { - b.WriteString(dimStyle.Render(" No matching contexts")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" No matching contexts")) + panel.WriteString("\n") } else { - b.WriteString(m.contextTable.View()) - b.WriteString("\n") + panel.WriteString(m.contextTable.View()) + panel.WriteString("\n") } - b.WriteString("\n") + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") if m.cfg.ContextName != "" { - b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • enter: select • a: add • esc: back • q: quit")) + b.WriteString(m.renderHelpBar("↑/↓: navigate • /: filter • enter: select • a: add • esc: back • q: quit")) } else { - b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • enter: select • a: add • q: quit")) + b.WriteString(m.renderHelpBar("↑/↓: navigate • /: filter • enter: select • a: add • q: quit")) } return b.String() } diff --git a/internal/app/screen_ec2.go b/internal/app/screen_ec2.go index c5ee7d0..b011bd4 100644 --- a/internal/app/screen_ec2.go +++ b/internal/app/screen_ec2.go @@ -172,6 +172,7 @@ func (m Model) startSSMSession(inst awsservice.EC2Instance) tea.Cmd { func (m Model) viewInstanceList() string { var b strings.Builder + var panel strings.Builder b.WriteString(m.renderStatusBar()) b.WriteString(titleStyle.Render("EC2 Instances (Running)")) b.WriteString("\n") @@ -180,11 +181,11 @@ func (m Model) viewInstanceList() string { b.WriteString("\n\n") if len(m.filtered) == 0 { - b.WriteString(dimStyle.Render(" No matching instances")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" No matching instances")) + panel.WriteString("\n") } else { // Calculate visible range for scrolling - visibleLines := max(m.height-8, 5) + visibleLines := max(m.height-10, 5) start := 0 if m.instIdx >= visibleLines { start = m.instIdx - visibleLines + 1 @@ -199,15 +200,16 @@ func (m Model) viewInstanceList() string { cursor = "> " style = selectedStyle } - b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, inst.DisplayTitle()))) - b.WriteString("\n") + panel.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, inst.DisplayTitle()))) + panel.WriteString("\n") } - b.WriteString("\n") - b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d instances", len(m.filtered), len(m.instances)))) + panel.WriteString("\n") + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d instances", len(m.filtered), len(m.instances)))) } - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • r: refresh • enter: connect • esc: back • H: home")) + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: navigate • /: filter • r: refresh • enter: connect • esc: back • H: home")) return b.String() } diff --git a/internal/app/screen_ecs.go b/internal/app/screen_ecs.go index f9472c0..59d32b3 100644 --- a/internal/app/screen_ecs.go +++ b/internal/app/screen_ecs.go @@ -87,6 +87,7 @@ func (m Model) updateECSClusterList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) viewECSClusterList() string { var b strings.Builder + var panel strings.Builder b.WriteString(m.renderStatusBar()) b.WriteString(titleStyle.Render("ECS Clusters")) b.WriteString("\n") @@ -95,11 +96,11 @@ func (m Model) viewECSClusterList() string { b.WriteString("\n\n") if len(m.filteredECSClusters) == 0 { - b.WriteString(dimStyle.Render(" No clusters found")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" No clusters found")) + panel.WriteString("\n") } else { - // overhead: status bar (2) + title (1) + filter line (1) + blank (1) + count (1) + blank (1) + footer (1) = 8 - visibleLines := max(m.height-8, 5) + // overhead: status bar (2) + title (1) + filter line (1) + blank (1) + list panel (2) + blank (1) + footer (1) = 10 + visibleLines := max(m.height-10, 5) start := 0 if m.ecsClusterIdx >= visibleLines { start = m.ecsClusterIdx - visibleLines + 1 @@ -114,15 +115,16 @@ func (m Model) viewECSClusterList() string { cursor = "> " style = selectedStyle } - b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, c.DisplayTitle()))) - b.WriteString("\n") + panel.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, c.DisplayTitle()))) + panel.WriteString("\n") } - b.WriteString("\n") - b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d clusters", len(m.filteredECSClusters), len(m.ecsClusters)))) + panel.WriteString("\n") + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d clusters", len(m.filteredECSClusters), len(m.ecsClusters)))) } - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • r: refresh • enter: select • esc: back • H: home")) + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: navigate • /: filter • r: refresh • enter: select • esc: back • H: home")) return b.String() } @@ -162,6 +164,7 @@ func (m Model) updateECSServiceList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) viewECSServiceList() string { var b strings.Builder + var panel strings.Builder b.WriteString(m.renderStatusBar()) clusterName := "" if m.selectedECSCluster != nil { @@ -174,11 +177,11 @@ func (m Model) viewECSServiceList() string { b.WriteString("\n\n") if len(m.filteredECSServices) == 0 { - b.WriteString(dimStyle.Render(" No services found")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" No services found")) + panel.WriteString("\n") } else { - // overhead: status bar (2) + title (1) + filter line (1) + blank (1) + count (1) + blank (1) + footer (1) = 8 - visibleLines := max(m.height-8, 5) + // overhead: status bar (2) + title (1) + filter line (1) + blank (1) + list panel (2) + blank (1) + footer (1) = 10 + visibleLines := max(m.height-10, 5) start := 0 if m.ecsServiceIdx >= visibleLines { start = m.ecsServiceIdx - visibleLines + 1 @@ -193,15 +196,16 @@ func (m Model) viewECSServiceList() string { cursor = "> " style = selectedStyle } - b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, s.DisplayTitle()))) - b.WriteString("\n") + panel.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, s.DisplayTitle()))) + panel.WriteString("\n") } - b.WriteString("\n") - b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d services", len(m.filteredECSServices), len(m.ecsServices)))) + panel.WriteString("\n") + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d services", len(m.filteredECSServices), len(m.ecsServices)))) } - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • r: refresh • enter: select • esc: back • H: home")) + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: navigate • /: filter • r: refresh • enter: select • esc: back • H: home")) return b.String() } @@ -233,6 +237,7 @@ func (m Model) updateECSTaskList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) viewECSTaskList() string { var b strings.Builder + var panel strings.Builder b.WriteString(m.renderStatusBar()) svcName := "" if m.selectedECSService != nil { @@ -242,11 +247,11 @@ func (m Model) viewECSTaskList() string { b.WriteString("\n\n") if len(m.ecsTasks) == 0 { - b.WriteString(dimStyle.Render(" No running tasks found")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" No running tasks found")) + panel.WriteString("\n") } else { - // overhead: status bar (2) + title (1) + blank (1) + count (1) + blank (1) + footer (1) = 7 - visibleLines := max(m.height-7, 5) + // overhead: status bar (2) + title (1) + blank (1) + list panel (2) + blank (1) + footer (1) = 9 + visibleLines := max(m.height-9, 5) start := 0 if m.ecsTaskIdx >= visibleLines { start = m.ecsTaskIdx - visibleLines + 1 @@ -261,15 +266,16 @@ func (m Model) viewECSTaskList() string { cursor = "> " style = selectedStyle } - b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, t.DisplayTitle()))) - b.WriteString("\n") + panel.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, t.DisplayTitle()))) + panel.WriteString("\n") } - b.WriteString("\n") - b.WriteString(dimStyle.Render(fmt.Sprintf(" %d tasks", len(m.ecsTasks)))) + panel.WriteString("\n") + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d tasks", len(m.ecsTasks)))) } - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • r: refresh • enter: select • esc: back • H: home")) + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: navigate • r: refresh • enter: select • esc: back • H: home")) return b.String() } @@ -306,6 +312,7 @@ func (m Model) updateECSContainerList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) viewECSContainerList() string { var b strings.Builder + var panel strings.Builder b.WriteString(m.renderStatusBar()) taskID := "" if m.selectedECSTask != nil { @@ -315,11 +322,11 @@ func (m Model) viewECSContainerList() string { b.WriteString("\n\n") if len(m.ecsContainers) == 0 { - b.WriteString(dimStyle.Render(" No containers found")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" No containers found")) + panel.WriteString("\n") } else { - // overhead: status bar (2) + title (1) + blank (1) + count (1) + blank (1) + footer (1) = 7 - visibleLines := max(m.height-7, 5) + // overhead: status bar (2) + title (1) + blank (1) + list panel (2) + blank (1) + footer (1) = 9 + visibleLines := max(m.height-9, 5) start := 0 if m.ecsContainerIdx >= visibleLines { start = m.ecsContainerIdx - visibleLines + 1 @@ -334,15 +341,16 @@ func (m Model) viewECSContainerList() string { cursor = "> " style = selectedStyle } - b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, c.DisplayTitle()))) - b.WriteString("\n") + panel.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, c.DisplayTitle()))) + panel.WriteString("\n") } - b.WriteString("\n") - b.WriteString(dimStyle.Render(fmt.Sprintf(" %d containers", len(m.ecsContainers)))) + panel.WriteString("\n") + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d containers", len(m.ecsContainers)))) } - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • enter: exec session • esc: back • H: home")) + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: navigate • enter: exec session • esc: back • H: home")) return b.String() } diff --git a/internal/app/screen_iam.go b/internal/app/screen_iam.go index 66c8744..2b0d057 100644 --- a/internal/app/screen_iam.go +++ b/internal/app/screen_iam.go @@ -510,6 +510,7 @@ func (m Model) updateIAMKeyRotateResult(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) viewIAMUserList() string { var b strings.Builder + var panel strings.Builder b.WriteString(m.renderStatusBar()) b.WriteString(titleStyle.Render("IAM Users")) b.WriteString("\n") @@ -518,8 +519,8 @@ func (m Model) viewIAMUserList() string { b.WriteString("\n\n") if len(m.filteredIAMUsers) == 0 { - b.WriteString(dimStyle.Render(" No matching IAM users")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" No matching IAM users")) + panel.WriteString("\n") } else { maxName := len("USERNAME") for _, user := range m.filteredIAMUsers { @@ -532,15 +533,15 @@ func (m Model) viewIAMUserList() string { createdCol := lipgloss.NewStyle().Width(12) pathCol := lipgloss.NewStyle().Width(24) - b.WriteString(dimStyle.Render( + panel.WriteString(dimStyle.Render( " " + nameCol.Render("USERNAME") + createdCol.Render("CREATED") + "PATH", )) - b.WriteString("\n") + panel.WriteString("\n") - visibleLines := max(m.height-9, 5) + visibleLines := max(m.height-11, 5) start := 0 if m.iamUserIdx >= visibleLines { start = m.iamUserIdx - visibleLines + 1 @@ -560,36 +561,37 @@ func (m Model) viewIAMUserList() string { nameCol.Inherit(style).Render(user.UserName) + createdCol.Inherit(dimStyle).Render(user.CreateDate.Format(time.DateOnly)) + pathCol.Inherit(dimStyle).Render(truncateIAMPath(user.Path)) - b.WriteString(row) - b.WriteString("\n") + panel.WriteString(row) + panel.WriteString("\n") } - b.WriteString("\n") + panel.WriteString("\n") status := fmt.Sprintf(" %d/%d loaded IAM users", len(m.filteredIAMUsers), len(m.iamUsers)) if m.iamUserHasMore { status += " • more available" } - b.WriteString(dimStyle.Render(status)) + panel.WriteString(dimStyle.Render(status)) } if m.iamUserLoadingMore { - b.WriteString("\n") + panel.WriteString("\n") if m.isFiltering(filterIAMUsers) || m.filterValue(filterIAMUsers) != "" { - b.WriteString(filterStyle.Render(" Loading remaining IAM usernames for filter...")) + panel.WriteString(filterStyle.Render(" Loading remaining IAM usernames for filter...")) } else { - b.WriteString(filterStyle.Render(" Loading more IAM users...")) + panel.WriteString(filterStyle.Render(" Loading more IAM users...")) } } else if m.iamUserHasMore { - b.WriteString("\n") + panel.WriteString("\n") if m.isFiltering(filterIAMUsers) || m.filterValue(filterIAMUsers) != "" { - b.WriteString(dimStyle.Render(" Continue typing to filter loaded usernames")) + panel.WriteString(dimStyle.Render(" Continue typing to filter loaded usernames")) } else { - b.WriteString(dimStyle.Render(" Press n to load the next page")) + panel.WriteString(dimStyle.Render(" Press n to load the next page")) } } - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • n: next page • enter: detail • esc: back • H: home")) + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: navigate • /: filter • n: next page • enter: detail • esc: back • H: home")) return b.String() } @@ -604,29 +606,28 @@ func (m Model) viewIAMUserDetail() string { b.WriteString(titleStyle.Render("IAM User Detail")) b.WriteString("\n\n") - labelStyle := lipgloss.NewStyle().Width(18) - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("User Name"), u.UserName))) + b.WriteString(renderDetailLine("User Name", normalStyle.Render(u.UserName))) b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("User ID"), u.UserID))) + b.WriteString(renderDetailLine("User ID", normalStyle.Render(u.UserID))) b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("ARN"), u.ARN))) + b.WriteString(renderDetailLine("ARN", normalStyle.Render(u.ARN))) b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Path"), u.Path))) + b.WriteString(renderDetailLine("Path", normalStyle.Render(u.Path))) b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Created"), u.CreateDate.Format(time.DateOnly)))) + b.WriteString(renderDetailLine("Created", normalStyle.Render(u.CreateDate.Format(time.DateOnly)))) b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Console Last Used"), u.PasswordLastUsedDisplay()))) + b.WriteString(renderDetailLine("Console Last Used", normalStyle.Render(u.PasswordLastUsedDisplay()))) b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Last Activity"), u.LastActivityDisplay()))) + b.WriteString(renderDetailLine("Last Activity", normalStyle.Render(u.LastActivityDisplay()))) b.WriteString("\n") mfaText := dimStyle.Render("Disabled") if u.MFAEnabled { mfaText = selectedStyle.Render("Enabled") } - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("MFA"), mfaText))) + b.WriteString(renderDetailLine("MFA", mfaText)) b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%d", labelStyle.Render("Access Keys"), len(u.AccessKeys)))) + b.WriteString(renderDetailLine("Access Keys", normalStyle.Render(fmt.Sprintf("%d", len(u.AccessKeys))))) b.WriteString("\n\n") b.WriteString(titleStyle.Render("Groups")) @@ -644,12 +645,13 @@ func (m Model) viewIAMUserDetail() string { b.WriteString(renderIAMAccessKeyList(u.AccessKeys)) b.WriteString("\n\n") - b.WriteString(dimStyle.Render("esc: back • H: home")) + b.WriteString(m.renderHelpBar("esc: back • H: home")) return b.String() } func (m Model) viewIAMKeyList() string { var b strings.Builder + var panel strings.Builder b.WriteString(m.renderStatusBar()) title := "IAM Access Keys" if m.iamRotationEnabled { @@ -664,10 +666,10 @@ func (m Model) viewIAMKeyList() string { } if len(m.iamKeys) == 0 { - b.WriteString(dimStyle.Render(" No access keys found")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" No access keys found")) + panel.WriteString("\n") } else { - visibleLines := max(m.height-8, 5) + visibleLines := max(m.height-10, 5) start := 0 if m.iamKeyIdx >= visibleLines { start = m.iamKeyIdx - visibleLines + 1 @@ -694,16 +696,17 @@ func (m Model) viewIAMKeyList() string { } else { title = fmt.Sprintf("%s%s", cursor, key.DisplayTitle()) } - b.WriteString(title) - b.WriteString("\n") + panel.WriteString(title) + panel.WriteString("\n") } - b.WriteString("\n") - b.WriteString(dimStyle.Render(fmt.Sprintf(" %d keys", len(m.iamKeys)))) + panel.WriteString("\n") + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d keys", len(m.iamKeys)))) } - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • enter: detail • esc: back • H: home")) + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: navigate • enter: detail • esc: back • H: home")) return b.String() } @@ -717,8 +720,7 @@ func (m Model) viewIAMKeyDetail() string { b.WriteString(titleStyle.Render("Access Key Detail")) b.WriteString("\n\n") - labelStyle := lipgloss.NewStyle().Width(16) - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Access Key ID"), k.AccessKeyID))) + b.WriteString(renderDetailLine("Access Key ID", normalStyle.Render(k.AccessKeyID))) b.WriteString("\n") statusStr := k.Status @@ -727,28 +729,28 @@ func (m Model) viewIAMKeyDetail() string { } else { statusStr = dimStyle.Render(k.Status) } - b.WriteString(fmt.Sprintf(" %s%s", labelStyle.Render("Status"), statusStr)) + b.WriteString(renderDetailLine("Status", statusStr)) b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Created"), k.CreateDate.Format(time.DateOnly)))) + b.WriteString(renderDetailLine("Created", normalStyle.Render(k.CreateDate.Format(time.DateOnly)))) b.WriteString("\n") ageStr := fmt.Sprintf("%d days", k.Age()) if k.IsAged() { ageStr = errorStyle.Render(fmt.Sprintf("%d days ⚠ (>90 days)", k.Age())) } - b.WriteString(fmt.Sprintf(" %s%s", labelStyle.Render("Age"), ageStr)) + b.WriteString(renderDetailLine("Age", ageStr)) b.WriteString("\n") lastUsed := dimStyle.Render("Never") if !k.LastUsed.IsZero() { lastUsed = k.LastUsed.Format(time.DateOnly) } - b.WriteString(fmt.Sprintf(" %s%s", labelStyle.Render("Last Used"), lastUsed)) + b.WriteString(renderDetailLine("Last Used", lastUsed)) b.WriteString("\n") if k.ServiceName != "" && k.ServiceName != "N/A" { - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Last Service"), k.ServiceName))) + b.WriteString(renderDetailLine("Last Service", normalStyle.Render(k.ServiceName))) b.WriteString("\n") } @@ -767,7 +769,7 @@ func (m Model) viewIAMKeyDetail() string { } b.WriteString("\n") - b.WriteString(dimStyle.Render("esc: back • H: home")) + b.WriteString(m.renderHelpBar("esc: back • H: home")) return b.String() } @@ -798,7 +800,7 @@ func (m Model) viewIAMKeyRotateConfirm() string { b.WriteString("\n") b.WriteString(filterStyle.Render(fmt.Sprintf(" %s▏", m.iamRotateConfirm))) b.WriteString("\n\n") - b.WriteString(dimStyle.Render(" enter: confirm • esc: cancel")) + b.WriteString(m.renderHelpBar("enter: confirm • esc: cancel")) return b.String() } @@ -814,10 +816,9 @@ func (m Model) viewIAMKeyRotateResult() string { b.WriteString(normalStyle.Render(" New credentials (shown once only):")) b.WriteString("\n\n") - labelStyle := lipgloss.NewStyle().Width(22) - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Access Key ID"), m.iamNewKey.AccessKeyID))) + b.WriteString(renderDetailLine("Access Key ID", normalStyle.Render(m.iamNewKey.AccessKeyID))) b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Secret Access Key"), m.iamNewKey.SecretAccessKey))) + b.WriteString(renderDetailLine("Secret Access Key", normalStyle.Render(m.iamNewKey.SecretAccessKey))) b.WriteString("\n\n") if m.iamRotationOldKeyID != "" { @@ -828,9 +829,9 @@ func (m Model) viewIAMKeyRotateResult() string { if m.iamOldKeyDeleted { oldKeyStatus = "Deleted" } - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Old Key"), m.iamRotationOldKeyID))) + b.WriteString(renderDetailLine("Old Key", normalStyle.Render(m.iamRotationOldKeyID))) b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Old Key Status"), oldKeyStatus))) + b.WriteString(renderDetailLine("Old Key Status", normalStyle.Render(oldKeyStatus))) b.WriteString("\n\n") } @@ -868,7 +869,7 @@ func (m Model) viewIAMKeyRotateResult() string { b.WriteString(dimStyle.Render(" [x] Delete old key (available after deactivation)")) } b.WriteString("\n\n") - b.WriteString(dimStyle.Render(" esc: back to key list")) + b.WriteString(m.renderHelpBar("esc: back to key list")) return b.String() } diff --git a/internal/app/screen_inspector.go b/internal/app/screen_inspector.go index 7ba5781..0400bd0 100644 --- a/internal/app/screen_inspector.go +++ b/internal/app/screen_inspector.go @@ -149,7 +149,7 @@ func (m Model) viewInspectorHome() string { b.WriteString("\n") } b.WriteString("\n") - b.WriteString(dimStyle.Render("enter/r: run security scan • esc: back • H: home")) + b.WriteString(m.renderHelpBar("enter/r: run security scan • esc: back • H: home")) return b.String() } @@ -166,6 +166,7 @@ func (m Model) viewInspectorScanning() string { func (m Model) viewInspectorResults() string { var b strings.Builder + var panel strings.Builder b.WriteString(m.renderStatusBar()) b.WriteString(titleStyle.Render("Inspector Findings")) b.WriteString("\n") @@ -184,19 +185,19 @@ func (m Model) viewInspectorResults() string { b.WriteString("\n\n") if m.inspectorReport != nil && len(m.inspectorReport.Warnings) > 0 { - b.WriteString(errorStyle.Render(fmt.Sprintf("Warnings: %d rule pack(s) reported errors", len(m.inspectorReport.Warnings)))) - b.WriteString("\n") - b.WriteString(dimStyle.Render(" " + m.inspectorReport.Warnings[0])) - b.WriteString("\n\n") + panel.WriteString(errorStyle.Render(fmt.Sprintf("Warnings: %d rule pack(s) reported errors", len(m.inspectorReport.Warnings)))) + panel.WriteString("\n") + panel.WriteString(dimStyle.Render(" " + m.inspectorReport.Warnings[0])) + panel.WriteString("\n\n") } if len(m.inspectorFindings) == 0 { - b.WriteString(dimStyle.Render(" No matching findings")) + panel.WriteString(dimStyle.Render(" No matching findings")) if m.inspectorReport != nil && len(m.inspectorReport.Findings) == 0 && m.inspectorReport.ScannerCount == 0 { - b.WriteString("\n") - b.WriteString(dimStyle.Render(" No built-in rule packs are registered yet.")) + panel.WriteString("\n") + panel.WriteString(dimStyle.Render(" No built-in rule packs are registered yet.")) } - b.WriteString("\n") + panel.WriteString("\n") } else { resourceWidth := 24 for _, finding := range m.inspectorFindings { @@ -207,10 +208,10 @@ func (m Model) viewInspectorResults() string { } resourceCol := lipgloss.NewStyle().Width(resourceWidth) - b.WriteString(dimStyle.Render(" SEVERITY " + resourceCol.Render("RESOURCE") + "RULE")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" SEVERITY " + resourceCol.Render("RESOURCE") + "RULE")) + panel.WriteString("\n") - visibleLines := max(m.height-11, 5) + visibleLines := max(m.height-13, 5) start := 0 if m.inspectorIdx >= visibleLines { start = m.inspectorIdx - visibleLines + 1 @@ -232,13 +233,14 @@ func (m Model) viewInspectorResults() string { " " + resourceCol.Inherit(textStyle).Render(resource) + textStyle.Render(finding.RuleName) - b.WriteString(row) - b.WriteString("\n") + panel.WriteString(row) + panel.WriteString("\n") } } - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • 1-5: severity • enter: detail • r: rescan • esc: back • H: home")) + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: navigate • 1-5: severity • enter: detail • r: rescan • esc: back • H: home")) return b.String() } @@ -253,20 +255,19 @@ func (m Model) viewInspectorFindingDetail() string { b.WriteString(titleStyle.Render("Inspector Finding Detail")) b.WriteString("\n\n") - labelCol := lipgloss.NewStyle().Width(16) - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelCol.Render("Severity"), renderInspectorSeverity(finding.Severity)))) + b.WriteString(renderDetailLine("Severity", renderInspectorSeverity(finding.Severity))) b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelCol.Render("Rule"), finding.RuleName))) + b.WriteString(renderDetailLine("Rule", normalStyle.Render(finding.RuleName))) b.WriteString("\n") if finding.RuleID != "" { - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelCol.Render("Rule ID"), finding.RuleID))) + b.WriteString(renderDetailLine("Rule ID", normalStyle.Render(finding.RuleID))) b.WriteString("\n") } if finding.ResourceType != "" { - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelCol.Render("Resource Type"), finding.ResourceType))) + b.WriteString(renderDetailLine("Resource Type", normalStyle.Render(finding.ResourceType))) b.WriteString("\n") } - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelCol.Render("Resource ID"), inspectorFindingResource(*finding)))) + b.WriteString(renderDetailLine("Resource ID", normalStyle.Render(inspectorFindingResource(*finding)))) b.WriteString("\n\n") width := 80 @@ -285,7 +286,7 @@ func (m Model) viewInspectorFindingDetail() string { b.WriteString(paragraph.Render(" " + finding.Recommendation)) b.WriteString("\n\n") - b.WriteString(dimStyle.Render("esc: back • r: rescan • H: home")) + b.WriteString(m.renderHelpBar("esc: back • r: rescan • H: home")) return b.String() } diff --git a/internal/app/screen_rds.go b/internal/app/screen_rds.go index 9207e5c..006e3db 100644 --- a/internal/app/screen_rds.go +++ b/internal/app/screen_rds.go @@ -253,6 +253,8 @@ func (m Model) tickRDSPoll(dbInstanceID string) tea.Cmd { func (m Model) viewRDSList() string { var b strings.Builder + var panel strings.Builder + b.WriteString(m.renderStatusBar()) b.WriteString(titleStyle.Render("RDS Instances")) b.WriteString("\n") @@ -260,10 +262,10 @@ func (m Model) viewRDSList() string { b.WriteString("\n\n") if len(m.filteredRDS) == 0 { - b.WriteString(dimStyle.Render(" No matching instances")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" No matching instances")) + panel.WriteString("\n") } else { - visibleLines := max(m.height-8, 5) + visibleLines := max(m.height-10, 5) start := 0 if m.rdsIdx >= visibleLines { start = m.rdsIdx - visibleLines + 1 @@ -278,16 +280,17 @@ func (m Model) viewRDSList() string { cursor = "> " style = selectedStyle } - b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, inst.DisplayTitle()))) - b.WriteString("\n") + panel.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, inst.DisplayTitle()))) + panel.WriteString("\n") } - b.WriteString("\n") - b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d instances", len(m.filteredRDS), len(m.rdsInstances)))) + panel.WriteString("\n") + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d instances", len(m.filteredRDS), len(m.rdsInstances)))) } - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • enter: detail • esc: back • H: home")) + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: navigate • /: filter • enter: detail • esc: back • H: home")) return b.String() } @@ -297,12 +300,13 @@ func (m Model) viewRDSDetail() string { } r := m.selectedRDS var b strings.Builder + b.WriteString(m.renderStatusBar()) b.WriteString(titleStyle.Render("RDS Instance Detail")) b.WriteString("\n\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" Identifier : %s", r.DBInstanceID))) + b.WriteString(renderDetailLine("Identifier", normalStyle.Render(r.DBInstanceID))) b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" Engine : %s %s", r.Engine, r.EngineVersion))) + b.WriteString(renderDetailLine("Engine", normalStyle.Render(fmt.Sprintf("%s %s", r.Engine, r.EngineVersion)))) b.WriteString("\n") // Color-code status @@ -318,27 +322,27 @@ func (m Model) viewRDSDetail() string { if m.rdsPolling { pollingIndicator = filterStyle.Render(" (polling...)") } - b.WriteString(fmt.Sprintf(" Status : %s%s", statusStr, pollingIndicator)) + b.WriteString(renderDetailLine("Status", statusStr+pollingIndicator)) b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" Class : %s", r.InstanceClass))) + b.WriteString(renderDetailLine("Class", normalStyle.Render(r.InstanceClass))) b.WriteString("\n") multiAZStr := "No" if r.MultiAZ { multiAZStr = "Yes" } - b.WriteString(normalStyle.Render(fmt.Sprintf(" Multi-AZ : %s", multiAZStr))) + b.WriteString(renderDetailLine("Multi-AZ", normalStyle.Render(multiAZStr))) b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" Storage : %d GB", r.StorageGB))) + b.WriteString(renderDetailLine("Storage", normalStyle.Render(fmt.Sprintf("%d GB", r.StorageGB)))) b.WriteString("\n") endpoint := r.Endpoint if endpoint == "" { endpoint = dimStyle.Render("(unavailable)") } - b.WriteString(normalStyle.Render(fmt.Sprintf(" Endpoint : %s", endpoint))) + b.WriteString(renderDetailLine("Endpoint", endpoint)) b.WriteString("\n") if r.ClusterID != "" { - b.WriteString(normalStyle.Render(fmt.Sprintf(" Cluster : %s", r.ClusterID))) + b.WriteString(renderDetailLine("Cluster", normalStyle.Render(r.ClusterID))) b.WriteString("\n") } @@ -374,7 +378,7 @@ func (m Model) viewRDSDetail() string { b.WriteString("\n") b.WriteString("\n") - b.WriteString(dimStyle.Render("esc: back • H: home")) + b.WriteString(m.renderHelpBar("esc: back • H: home")) return b.String() } @@ -393,6 +397,7 @@ func (m Model) viewRDSConfirm() string { } var b strings.Builder + b.WriteString(m.renderStatusBar()) b.WriteString(errorStyle.Render("Confirm Action")) b.WriteString("\n\n") @@ -411,7 +416,7 @@ func (m Model) viewRDSConfirm() string { b.WriteString("\n") b.WriteString(filterStyle.Render(fmt.Sprintf(" %s▏", m.rdsConfirmInput))) b.WriteString("\n\n") - b.WriteString(dimStyle.Render(" enter: confirm • esc: cancel")) + b.WriteString(m.renderHelpBar("enter: confirm • esc: cancel")) b.WriteString("\n") } return b.String() diff --git a/internal/app/screen_reachability.go b/internal/app/screen_reachability.go index 58b9bf3..9982cba 100644 --- a/internal/app/screen_reachability.go +++ b/internal/app/screen_reachability.go @@ -397,6 +397,7 @@ func (m Model) viewReachabilityDestinationList() string { func (m Model) viewReachabilityRegionList() string { var b strings.Builder + var panel strings.Builder b.WriteString(m.renderStatusBar()) b.WriteString(titleStyle.Render("Reachability Analyzer > Region")) b.WriteString("\n") @@ -410,10 +411,10 @@ func (m Model) viewReachabilityRegionList() string { b.WriteString("\n\n") if len(m.filteredReachabilityRegions) == 0 { - b.WriteString(dimStyle.Render(" No matching regions")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" No matching regions")) + panel.WriteString("\n") } else { - visibleLines := max(m.height-10, 5) + visibleLines := max(m.height-12, 5) start := 0 if m.reachabilityRegionIdx >= visibleLines { start = m.reachabilityRegionIdx - visibleLines + 1 @@ -431,18 +432,20 @@ func (m Model) viewReachabilityRegionList() string { if region == m.cfg.Region { label += " [context default]" } - b.WriteString(style.Render(cursor + label)) - b.WriteString("\n") + panel.WriteString(style.Render(cursor + label)) + panel.WriteString("\n") } } - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • enter: load targets • esc: back • H: home")) + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: navigate • /: filter • enter: load targets • esc: back • H: home")) return b.String() } func (m Model) viewReachabilityTargetList(title, subtitle string, items []awsservice.ReachabilityTarget, typeOptions []string, typeIdx int, footer string) string { var b strings.Builder + var panel strings.Builder b.WriteString(m.renderStatusBar()) b.WriteString(titleStyle.Render(title)) b.WriteString("\n") @@ -460,10 +463,10 @@ func (m Model) viewReachabilityTargetList(title, subtitle string, items []awsser b.WriteString("\n\n") if len(items) == 0 { - b.WriteString(dimStyle.Render(" No matching targets")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" No matching targets")) + panel.WriteString("\n") } else { - visibleLines := max(m.height-10, 5) + visibleLines := max(m.height-12, 5) start := 0 if m.reachabilityIdx >= visibleLines { start = m.reachabilityIdx - visibleLines + 1 @@ -478,15 +481,16 @@ func (m Model) viewReachabilityTargetList(title, subtitle string, items []awsser cursor = "> " style = selectedStyle } - b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, item.DisplayTitle()))) - b.WriteString("\n") + panel.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, item.DisplayTitle()))) + panel.WriteString("\n") } - b.WriteString("\n") - b.WriteString(dimStyle.Render(fmt.Sprintf(" %d targets", len(items)))) + panel.WriteString("\n") + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d targets", len(items)))) } - b.WriteString("\n") - b.WriteString(dimStyle.Render(footer)) + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar(footer)) return b.String() } @@ -544,7 +548,7 @@ func (m Model) viewReachabilityConfig() string { b.WriteString("\n") b.WriteString(dimStyle.Render("Protocol and destination port are part of the path intent. Reachability Analyzer evaluates the shortest matching path and identifies blockers when traffic is not reachable.")) b.WriteString("\n\n") - b.WriteString(dimStyle.Render("↑/↓ or tab: field • ←/→: protocol • type: edit • enter: analyze • esc: back • H: home")) + b.WriteString(m.renderHelpBar("↑/↓ or tab: field • ←/→: protocol • type: edit • enter: analyze • esc: back • H: home")) return b.String() } @@ -565,7 +569,7 @@ func (m Model) viewReachabilityResult() string { if len(lines) > 0 { b.WriteString("\n") } - b.WriteString(dimStyle.Render("j/k: scroll • r: rerun • esc: back • H: home")) + b.WriteString(m.renderHelpBar("j/k: scroll • r: rerun • esc: back • H: home")) return b.String() } diff --git a/internal/app/screen_route53.go b/internal/app/screen_route53.go index a4ee124..265a44e 100644 --- a/internal/app/screen_route53.go +++ b/internal/app/screen_route53.go @@ -217,6 +217,7 @@ func (m Model) loadRoute53Records(zoneID string) tea.Cmd { func (m Model) viewRoute53ZoneList() string { var b strings.Builder + var panel strings.Builder b.WriteString(m.renderStatusBar()) b.WriteString(titleStyle.Render("Route53 Hosted Zones")) b.WriteString("\n") @@ -225,8 +226,8 @@ func (m Model) viewRoute53ZoneList() string { b.WriteString("\n\n") if len(m.filteredRoute53Zones) == 0 { - b.WriteString(dimStyle.Render(" No matching hosted zones")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" No matching hosted zones")) + panel.WriteString("\n") } else { // Measure max widths for column alignment maxName, maxID := 4, 2 // "NAME", "ID" @@ -243,10 +244,10 @@ func (m Model) viewRoute53ZoneList() string { recordsCol := lipgloss.NewStyle().Width(9) // "RECORDS" + padding // Header - b.WriteString(dimStyle.Render(" " + nameCol.Render("NAME") + idCol.Render("ID") + recordsCol.Render("RECORDS") + "TYPE")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" " + nameCol.Render("NAME") + idCol.Render("ID") + recordsCol.Render("RECORDS") + "TYPE")) + panel.WriteString("\n") - visibleLines := max(m.height-9, 5) + visibleLines := max(m.height-11, 5) start := 0 if m.route53ZoneIdx >= visibleLines { start = m.route53ZoneIdx - visibleLines + 1 @@ -270,21 +271,23 @@ func (m Model) viewRoute53ZoneList() string { idCol.Inherit(dimStyle).Render(zone.ID) + recordsCol.Inherit(dimStyle).Render(fmt.Sprintf("%d", zone.ResourceRecordCount)) + dimStyle.Render(zoneType) - b.WriteString(row) - b.WriteString("\n") + panel.WriteString(row) + panel.WriteString("\n") } - b.WriteString("\n") - b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d zones", len(m.filteredRoute53Zones), len(m.route53Zones)))) + panel.WriteString("\n") + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d zones", len(m.filteredRoute53Zones), len(m.route53Zones)))) } - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • enter: records • esc: back • H: home")) + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: navigate • /: filter • enter: records • esc: back • H: home")) return b.String() } func (m Model) viewRoute53RecordList() string { var b strings.Builder + var panel strings.Builder b.WriteString(m.renderStatusBar()) zoneName := "" if m.selectedRoute53Zone != nil { @@ -297,8 +300,8 @@ func (m Model) viewRoute53RecordList() string { b.WriteString("\n\n") if len(m.filteredRoute53Records) == 0 { - b.WriteString(dimStyle.Render(" No matching records")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" No matching records")) + panel.WriteString("\n") } else { // Measure max widths for column alignment maxName, maxType := 4, 4 // "NAME", "TYPE" @@ -314,10 +317,10 @@ func (m Model) viewRoute53RecordList() string { typeCol := lipgloss.NewStyle().Width(maxType + 2) // Header - b.WriteString(dimStyle.Render(" " + nameCol.Render("NAME") + typeCol.Render("TYPE") + "VALUE")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" " + nameCol.Render("NAME") + typeCol.Render("TYPE") + "VALUE")) + panel.WriteString("\n") - visibleLines := max(m.height-9, 5) + visibleLines := max(m.height-11, 5) start := 0 if m.route53RecordIdx >= visibleLines { start = m.route53RecordIdx - visibleLines + 1 @@ -346,25 +349,28 @@ func (m Model) viewRoute53RecordList() string { nameCol.Inherit(style).Render(rec.Name) + typeCol.Inherit(filterStyle).Render(rec.Type) + dimStyle.Render(valStr) - b.WriteString(row) - b.WriteString("\n") + panel.WriteString(row) + panel.WriteString("\n") } - b.WriteString("\n") - b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d records", len(m.filteredRoute53Records), len(m.route53Records)))) + panel.WriteString("\n") + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d records", len(m.filteredRoute53Records), len(m.route53Records)))) } // Show change status if polling if m.route53Polling { - b.WriteString(filterStyle.Render(fmt.Sprintf(" Change: %s...", m.route53ChangeStatus))) - b.WriteString("\n") + panel.WriteString("\n") + panel.WriteString(filterStyle.Render(fmt.Sprintf(" Change: %s...", m.route53ChangeStatus))) + panel.WriteString("\n") } else if m.route53ChangeStatus == "INSYNC" { - b.WriteString(dimStyle.Render(" Change: INSYNC")) - b.WriteString("\n") + panel.WriteString("\n") + panel.WriteString(dimStyle.Render(" Change: INSYNC")) + panel.WriteString("\n") } - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • c: create • enter: detail • esc: back • H: home")) + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: navigate • /: filter • c: create • enter: detail • esc: back • H: home")) return b.String() } @@ -378,26 +384,24 @@ func (m Model) viewRoute53RecordDetail() string { b.WriteString(titleStyle.Render("DNS Record Detail")) b.WriteString("\n\n") - labelStyle := dimStyle.Width(10) - - b.WriteString(" " + labelStyle.Render("Name") + normalStyle.Render(r.Name)) + b.WriteString(renderDetailLine("Name", normalStyle.Render(r.Name))) b.WriteString("\n") - b.WriteString(" " + labelStyle.Render("Type") + filterStyle.Render(r.Type)) + b.WriteString(renderDetailLine("Type", filterStyle.Render(r.Type))) b.WriteString("\n") if r.AliasTarget != "" { - b.WriteString(" " + labelStyle.Render("Alias") + normalStyle.Render(r.AliasTarget)) + b.WriteString(renderDetailLine("Alias", normalStyle.Render(r.AliasTarget))) b.WriteString("\n") } else { - b.WriteString(" " + labelStyle.Render("TTL") + normalStyle.Render(fmt.Sprintf("%d", r.TTL))) + b.WriteString(renderDetailLine("TTL", normalStyle.Render(fmt.Sprintf("%d", r.TTL)))) b.WriteString("\n") } if len(r.Values) > 0 { - b.WriteString(" " + labelStyle.Render("Values")) + b.WriteString(renderDetailLine("Values", "")) b.WriteString("\n") for _, v := range r.Values { - b.WriteString(" " + labelStyle.Render("") + normalStyle.Render(v)) + b.WriteString(renderDetailLine("", normalStyle.Render(v))) b.WriteString("\n") } } @@ -416,7 +420,7 @@ func (m Model) viewRoute53RecordDetail() string { } hints = "c: create • " + hints } - b.WriteString(dimStyle.Render(hints)) + b.WriteString(m.renderHelpBar(hints)) return b.String() } @@ -570,7 +574,7 @@ func (m Model) viewRoute53RecordCreate() string { } b.WriteString("\n") - b.WriteString(dimStyle.Render("enter: next • esc: cancel")) + b.WriteString(m.renderHelpBar("enter: next • esc: cancel")) return b.String() } @@ -666,7 +670,7 @@ func (m Model) viewRoute53RecordEdit() string { } b.WriteString("\n") - b.WriteString(dimStyle.Render("enter: next • esc: cancel")) + b.WriteString(m.renderHelpBar("enter: next • esc: cancel")) return b.String() } @@ -727,7 +731,7 @@ func (m Model) viewRoute53RecordDeleteConfirm() string { b.WriteString("\n") b.WriteString(filterStyle.Render(fmt.Sprintf(" %s▏", m.route53ConfirmInput))) b.WriteString("\n\n") - b.WriteString(dimStyle.Render("enter: confirm • esc: cancel")) + b.WriteString(m.renderHelpBar("enter: confirm • esc: cancel")) return b.String() } diff --git a/internal/app/screen_s3.go b/internal/app/screen_s3.go index 0d2587c..888f105 100644 --- a/internal/app/screen_s3.go +++ b/internal/app/screen_s3.go @@ -202,6 +202,7 @@ func (m Model) updateS3ObjectDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) viewS3BucketList() string { var b strings.Builder + var panel strings.Builder b.WriteString(m.renderStatusBar()) b.WriteString(titleStyle.Render("S3 Buckets")) b.WriteString("\n") @@ -210,8 +211,8 @@ func (m Model) viewS3BucketList() string { b.WriteString("\n\n") if len(m.filteredS3Buckets) == 0 { - b.WriteString(dimStyle.Render(" No matching buckets")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" No matching buckets")) + panel.WriteString("\n") } else { maxName := 6 for _, bucket := range m.filteredS3Buckets { @@ -224,10 +225,10 @@ func (m Model) viewS3BucketList() string { } nameCol := lipgloss.NewStyle().Width(maxName + 2) regionCol := lipgloss.NewStyle().Width(18) - b.WriteString(dimStyle.Render(" " + nameCol.Render("NAME") + regionCol.Render("REGION") + "CREATED")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" " + nameCol.Render("NAME") + regionCol.Render("REGION") + "CREATED")) + panel.WriteString("\n") - visibleLines := max(m.height-9, 5) + visibleLines := max(m.height-11, 5) start := 0 if m.s3BucketIdx >= visibleLines { start = m.s3BucketIdx - visibleLines + 1 @@ -253,21 +254,23 @@ func (m Model) viewS3BucketList() string { nameCol.Inherit(style).Render(name) + regionCol.Inherit(dimStyle).Render(bucket.Region) + dimStyle.Render(created) - b.WriteString(row) - b.WriteString("\n") + panel.WriteString(row) + panel.WriteString("\n") } - b.WriteString("\n") - b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d buckets", len(m.filteredS3Buckets), len(m.s3Buckets)))) + panel.WriteString("\n") + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d buckets", len(m.filteredS3Buckets), len(m.s3Buckets)))) } - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • enter: browse • esc: back • H: home")) + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: navigate • /: filter • enter: browse • esc: back • H: home")) return b.String() } func (m Model) viewS3ObjectList() string { var b strings.Builder + var panel strings.Builder b.WriteString(m.renderStatusBar()) bucketName := "" if m.selectedS3Bucket != nil { @@ -282,8 +285,8 @@ func (m Model) viewS3ObjectList() string { b.WriteString("\n\n") if len(m.filteredS3Objects) == 0 { - b.WriteString(dimStyle.Render(" No matching objects or prefixes")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" No matching objects or prefixes")) + panel.WriteString("\n") } else { maxName := 6 for _, obj := range m.filteredS3Objects { @@ -297,10 +300,10 @@ func (m Model) viewS3ObjectList() string { nameCol := lipgloss.NewStyle().Width(maxName + 2) sizeCol := lipgloss.NewStyle().Width(10) modCol := lipgloss.NewStyle().Width(18) - b.WriteString(dimStyle.Render(" " + nameCol.Render("NAME") + sizeCol.Render("SIZE") + modCol.Render("MODIFIED") + "TYPE")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" " + nameCol.Render("NAME") + sizeCol.Render("SIZE") + modCol.Render("MODIFIED") + "TYPE")) + panel.WriteString("\n") - visibleLines := max(m.height-10, 5) + visibleLines := max(m.height-12, 5) start := 0 if m.s3ObjectIdx >= visibleLines { start = m.s3ObjectIdx - visibleLines + 1 @@ -339,16 +342,17 @@ func (m Model) viewS3ObjectList() string { sizeCol.Inherit(dimStyle).Render(sizeText) + modCol.Inherit(dimStyle).Render(modified) + dimStyle.Render(typeText) - b.WriteString(row) - b.WriteString("\n") + panel.WriteString(row) + panel.WriteString("\n") } - b.WriteString("\n") - b.WriteString(dimStyle.Render(fmt.Sprintf(" %d items", len(m.filteredS3Objects)))) + panel.WriteString("\n") + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d items", len(m.filteredS3Objects)))) } - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • enter: open • esc: up/back • H: home")) + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: navigate • /: filter • enter: open • esc: up/back • H: home")) return b.String() } @@ -362,37 +366,36 @@ func (m Model) viewS3ObjectDetail() string { b.WriteString(titleStyle.Render("S3 Object Detail")) b.WriteString("\n\n") - labelStyle := lipgloss.NewStyle().Width(14) - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Bucket"), o.Bucket))) + b.WriteString(renderDetailLine("Bucket", normalStyle.Render(o.Bucket))) b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Key"), o.Key))) + b.WriteString(renderDetailLine("Key", normalStyle.Render(o.Key))) b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Size"), awsservice.FormatBytes(o.Size)))) + b.WriteString(renderDetailLine("Size", normalStyle.Render(awsservice.FormatBytes(o.Size)))) b.WriteString("\n") modified := "-" if !o.LastModified.IsZero() { modified = o.LastModified.Format("2006-01-02 15:04:05") } - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Last Modified"), modified))) + b.WriteString(renderDetailLine("Last Modified", normalStyle.Render(modified))) b.WriteString("\n") storageClass := o.StorageClass if storageClass == "" { storageClass = "-" } - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Storage Class"), storageClass))) + b.WriteString(renderDetailLine("Storage Class", normalStyle.Render(storageClass))) b.WriteString("\n") contentType := o.ContentType if contentType == "" { contentType = "-" } - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Content Type"), contentType))) + b.WriteString(renderDetailLine("Content Type", normalStyle.Render(contentType))) b.WriteString("\n") etag := o.ETag if etag == "" { etag = "-" } - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("ETag"), etag))) + b.WriteString(renderDetailLine("ETag", normalStyle.Render(etag))) b.WriteString("\n\n") - b.WriteString(dimStyle.Render("esc: back • H: home")) + b.WriteString(m.renderHelpBar("esc: back • H: home")) return b.String() } diff --git a/internal/app/screen_securitygroup.go b/internal/app/screen_securitygroup.go index 9430aa4..bcd8885 100644 --- a/internal/app/screen_securitygroup.go +++ b/internal/app/screen_securitygroup.go @@ -200,6 +200,7 @@ func (m Model) updateSecurityGroupList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) viewSecurityGroupList() string { var b strings.Builder + var panel strings.Builder b.WriteString(m.renderStatusBar()) b.WriteString(titleStyle.Render("Security Groups")) b.WriteString("\n") @@ -208,10 +209,10 @@ func (m Model) viewSecurityGroupList() string { b.WriteString("\n\n") if len(m.filteredSecurityGroups) == 0 { - b.WriteString(dimStyle.Render(" No matching security groups")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" No matching security groups")) + panel.WriteString("\n") } else { - visibleLines := max(m.height-8, 5) + visibleLines := max(m.height-10, 5) start := 0 if m.sgIdx >= visibleLines { start = m.sgIdx - visibleLines + 1 @@ -226,16 +227,17 @@ func (m Model) viewSecurityGroupList() string { cursor = "> " style = selectedStyle } - b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, sg.DisplayTitle()))) - b.WriteString("\n") + panel.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, sg.DisplayTitle()))) + panel.WriteString("\n") } - b.WriteString("\n") - b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d security groups", len(m.filteredSecurityGroups), len(m.securityGroups)))) + panel.WriteString("\n") + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d security groups", len(m.filteredSecurityGroups), len(m.securityGroups)))) } - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • r: refresh • enter: detail • esc: back • H: home")) + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: navigate • /: filter • r: refresh • enter: detail • esc: back • H: home")) return b.String() } @@ -299,14 +301,13 @@ func (m Model) viewSecurityGroupDetail() string { b.WriteString(titleStyle.Render("Security Group Detail")) b.WriteString("\n\n") - labelStyle := lipgloss.NewStyle().Width(16) - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Group ID"), sg.GroupID))) + b.WriteString(renderDetailLine("Group ID", normalStyle.Render(sg.GroupID))) b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Name"), sg.Name))) + b.WriteString(renderDetailLine("Name", normalStyle.Render(sg.Name))) b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Description"), sg.Description))) + b.WriteString(renderDetailLine("Description", normalStyle.Render(sg.Description))) b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("VPC ID"), sg.VPCID))) + b.WriteString(renderDetailLine("VPC ID", normalStyle.Render(sg.VPCID))) b.WriteString("\n") // Inbound rules @@ -330,7 +331,7 @@ func (m Model) viewSecurityGroupDetail() string { m.renderRuleTable(&b, sg.EgressRules, "DESTINATION", m.sgRuleSection == "egress") b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: select rule • tab: switch section • a: add rule • d: delete rule • esc: back • H: home")) + b.WriteString(m.renderHelpBar("↑/↓: select rule • tab: switch section • a: add rule • d: delete rule • esc: back • H: home")) return b.String() } @@ -569,9 +570,9 @@ func (m Model) viewSecurityGroupAddRule() string { b.WriteString("\n") if m.sgAddField == 0 || m.sgAddField == 1 { - b.WriteString(dimStyle.Render(" ↑/↓: select • enter: confirm • esc: cancel")) + b.WriteString(m.renderHelpBar("↑/↓: select • enter: confirm • esc: cancel")) } else { - b.WriteString(dimStyle.Render(" enter: confirm • esc: cancel")) + b.WriteString(m.renderHelpBar("enter: confirm • esc: cancel")) } return b.String() } @@ -661,6 +662,6 @@ func (m Model) viewSecurityGroupDeleteConfirm() string { b.WriteString(filterStyle.Render(fmt.Sprintf(" %s▏", m.sgDeleteConfirm))) b.WriteString("\n\n") - b.WriteString(dimStyle.Render(" enter: confirm • esc: cancel")) + b.WriteString(m.renderHelpBar("enter: confirm • esc: cancel")) return b.String() } diff --git a/internal/app/screen_vpc.go b/internal/app/screen_vpc.go index faa6270..e66977c 100644 --- a/internal/app/screen_vpc.go +++ b/internal/app/screen_vpc.go @@ -161,15 +161,16 @@ func (m Model) loadAvailableIPs(subnet awsservice.Subnet) tea.Cmd { func (m Model) viewVPCList() string { var b strings.Builder + var panel strings.Builder b.WriteString(m.renderStatusBar()) b.WriteString(titleStyle.Render("VPCs")) b.WriteString("\n\n") if len(m.filteredVPCs) == 0 { - b.WriteString(dimStyle.Render(" No VPCs found")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" No VPCs found")) + panel.WriteString("\n") } else { - visibleLines := max(m.height-8, 5) + visibleLines := max(m.height-10, 5) start := 0 if m.vpcIdx >= visibleLines { start = m.vpcIdx - visibleLines + 1 @@ -184,20 +185,22 @@ func (m Model) viewVPCList() string { cursor = "> " style = selectedStyle } - b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, vpc.DisplayTitle()))) - b.WriteString("\n") + panel.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, vpc.DisplayTitle()))) + panel.WriteString("\n") } - b.WriteString("\n") - b.WriteString(dimStyle.Render(fmt.Sprintf(" %d VPCs", len(m.filteredVPCs)))) + panel.WriteString("\n") + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d VPCs", len(m.filteredVPCs)))) } - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • enter: select • esc: back • H: home")) + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: navigate • enter: select • esc: back • H: home")) return b.String() } func (m Model) viewSubnetList() string { var b strings.Builder + var panel strings.Builder b.WriteString(m.renderStatusBar()) vpcName := "" if m.selectedVPC != nil { @@ -207,10 +210,10 @@ func (m Model) viewSubnetList() string { b.WriteString("\n\n") if len(m.subnets) == 0 { - b.WriteString(dimStyle.Render(" No subnets found")) - b.WriteString("\n") + panel.WriteString(dimStyle.Render(" No subnets found")) + panel.WriteString("\n") } else { - visibleLines := max(m.height-8, 5) + visibleLines := max(m.height-10, 5) start := 0 if m.subnetIdx >= visibleLines { start = m.subnetIdx - visibleLines + 1 @@ -225,15 +228,16 @@ func (m Model) viewSubnetList() string { cursor = "> " style = selectedStyle } - b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, s.DisplayTitle()))) - b.WriteString("\n") + panel.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, s.DisplayTitle()))) + panel.WriteString("\n") } - b.WriteString("\n") - b.WriteString(dimStyle.Render(fmt.Sprintf(" %d subnets", len(m.subnets)))) + panel.WriteString("\n") + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d subnets", len(m.subnets)))) } - b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: navigate • enter: detail • esc: back • H: home")) + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: navigate • enter: detail • esc: back • H: home")) return b.String() } @@ -246,15 +250,15 @@ func (m Model) viewSubnetDetail() string { b.WriteString(m.renderStatusBar()) b.WriteString(titleStyle.Render("Subnet Detail")) b.WriteString("\n\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" Subnet ID : %s", s.SubnetID))) + b.WriteString(renderDetailLine("Subnet ID", normalStyle.Render(s.SubnetID))) b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" Name : %s", s.Name))) + b.WriteString(renderDetailLine("Name", normalStyle.Render(s.Name))) b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" CIDR : %s", s.CIDR))) + b.WriteString(renderDetailLine("CIDR", normalStyle.Render(s.CIDR))) b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" AZ : %s", s.AvailabilityZone))) + b.WriteString(renderDetailLine("AZ", normalStyle.Render(s.AvailabilityZone))) b.WriteString("\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" Available IPs : %d", len(m.availableIPs)))) + b.WriteString(renderDetailLine("Available IPs", normalStyle.Render(fmt.Sprintf("%d", len(m.availableIPs))))) b.WriteString("\n\n") b.WriteString(m.renderFilterValue(filterSubnetIPs)) @@ -277,6 +281,6 @@ func (m Model) viewSubnetDetail() string { } b.WriteString("\n") - b.WriteString(dimStyle.Render("↑/↓: scroll • /: filter • esc: back • H: home")) + b.WriteString(m.renderHelpBar("↑/↓: scroll • /: filter • esc: back • H: home")) return b.String() } diff --git a/internal/app/styles.go b/internal/app/styles.go index 68cf847..e0f2e68 100644 --- a/internal/app/styles.go +++ b/internal/app/styles.go @@ -10,25 +10,29 @@ import ( ) var ( - titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")) - selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("170")) - normalStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) - dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) - errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) - successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true) - warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) - infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("117")) - pathNodeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("81")).Bold(true) - pathLineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("67")) - filterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) - statusBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Background(lipgloss.Color("236")) + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")) + selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("170")) + normalStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) + successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true) + warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) + infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("117")) + pathNodeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("81")).Bold(true) + pathLineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("67")) + filterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) + statusBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Background(lipgloss.Color("236")) + listPanelStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("240")).Padding(0, 1) + helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Background(lipgloss.Color("237")) + detailLabelStyle = dimStyle.Copy().Width(14) ) func (m Model) renderStatusBar() string { - var parts []string + var leftParts []string + var rightParts []string if m.cfg.ContextName != "" { - parts = append(parts, fmt.Sprintf("[%s]", m.cfg.ContextName)) + leftParts = append(leftParts, fmt.Sprintf("[%s]", m.cfg.ContextName)) } activeRegion := m.cfg.Region if m.screen == screenReachabilityRegionList || @@ -38,12 +42,12 @@ func (m Model) renderStatusBar() string { m.screen == screenReachabilityResult { activeRegion = m.activeReachabilityRegion() } - parts = append(parts, fmt.Sprintf("region:%s", activeRegion)) + leftParts = append(leftParts, fmt.Sprintf("region:%s", activeRegion)) if m.cfg.AuthType != "" { - parts = append(parts, fmt.Sprintf("auth:%s", m.cfg.AuthType)) + leftParts = append(leftParts, fmt.Sprintf("auth:%s", m.cfg.AuthType)) } if m.callerIdentity != nil && m.callerIdentity.Account != "" { - parts = append(parts, fmt.Sprintf("account:%s", m.callerIdentity.Account)) + leftParts = append(leftParts, fmt.Sprintf("account:%s", m.callerIdentity.Account)) } if m.updateAvailable != "" { @@ -51,16 +55,59 @@ func (m Model) renderStatusBar() string { if m.installMethod == update.InstallBrew { hint = "brew upgrade unic" } - parts = append(parts, filterStyle.Render(fmt.Sprintf("%s available — %s", m.updateAvailable, hint))) + rightParts = append(rightParts, filterStyle.Render(fmt.Sprintf("%s available • %s", m.updateAvailable, hint))) } - bar := strings.Join(parts, " ") + leftText := " " + strings.Join(leftParts, " ") + rightText := "" + if len(rightParts) > 0 { + rightText = strings.Join(rightParts, " ") + " " + } + + width := m.width + leftMinWidth := lipgloss.Width(leftText) + rightMinWidth := lipgloss.Width(rightText) + if width <= 0 || width < leftMinWidth+rightMinWidth { + width = leftMinWidth + rightMinWidth + } + if rightText == "" { + return statusBarStyle.Copy().Width(width).Align(lipgloss.Left).Render(leftText) + "\n\n" + } + + leftWidth := width - rightMinWidth + if leftWidth < leftMinWidth { + leftWidth = leftMinWidth + } + rightWidth := width - leftWidth + + bar := lipgloss.JoinHorizontal( + lipgloss.Top, + statusBarStyle.Copy().Width(leftWidth).Align(lipgloss.Left).Render(leftText), + statusBarStyle.Copy().Width(rightWidth).Align(lipgloss.Right).Render(rightText), + ) + return bar + "\n\n" +} + +func (m Model) renderListPanel(content string) string { + content = strings.TrimRight(content, "\n") + style := listPanelStyle if m.width > 0 { - if len(bar) < m.width { - bar += strings.Repeat(" ", m.width-len(bar)) - } + style = style.MaxWidth(max(m.width, 1)) + } + return style.Render(content) +} + +func (m Model) renderHelpBar(content string) string { + content = " " + strings.TrimSpace(content) + style := helpStyle + if m.width > 0 { + style = style.Width(m.width) } - return statusBarStyle.Render(bar) + "\n\n" + return style.Render(content) +} + +func renderDetailLine(label, value string) string { + return " " + detailLabelStyle.Render(label) + value } // fitToHeight ensures the rendered output is exactly m.height lines. @@ -125,6 +172,6 @@ func (m Model) viewError() string { b.WriteString("\n\n") b.WriteString(normalStyle.Render(m.errMsg)) b.WriteString("\n\n") - b.WriteString(dimStyle.Render("enter/esc: go back • q: quit")) + b.WriteString(m.renderHelpBar("enter/esc: go back • q: quit")) return b.String() } diff --git a/internal/app/styles_test.go b/internal/app/styles_test.go new file mode 100644 index 0000000..44fc5b6 --- /dev/null +++ b/internal/app/styles_test.go @@ -0,0 +1,151 @@ +package app + +import ( + "regexp" + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + + "unic/internal/config" + awsservice "unic/internal/services/aws" +) + +var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +func stripANSI(s string) string { + return ansiPattern.ReplaceAllString(s, "") +} + +func styleTestContexts() []config.ContextInfo { + return []config.ContextInfo{ + {Name: "dev", Region: "us-east-1", AuthType: "credential"}, + {Name: "prod", Region: "us-west-2", AuthType: "sso", Current: true}, + {Name: "staging", Region: "ap-northeast-2", AuthType: "assume_role"}, + } +} + +func TestRenderStatusBarUsesFullWidthAndUpdateHint(t *testing.T) { + m := New(testConfig(), "", "dev") + m.width = 120 + m.cfg.ContextName = "prod" + m.cfg.AuthType = config.AuthTypeCredential + m.callerIdentity = &awsservice.CallerIdentity{Account: "123456789012"} + m.updateAvailable = "v1.2.3" + + bar := strings.TrimRight(m.renderStatusBar(), "\n") + if got := lipgloss.Width(bar); got != 120 { + t.Fatalf("expected status bar width 120, got %d (%q)", got, stripANSI(bar)) + } + + plain := stripANSI(bar) + for _, want := range []string{"[prod]", "region:us-east-1", "auth:credential", "account:123456789012", "v1.2.3 available"} { + if !strings.Contains(plain, want) { + t.Fatalf("expected status bar to contain %q, got %q", want, plain) + } + } +} + +func TestRenderHelpBarUsesFullWidth(t *testing.T) { + m := New(testConfig(), "", "dev") + m.width = 48 + + bar := m.renderHelpBar("esc: back • H: home") + if got := lipgloss.Width(bar); got != 48 { + t.Fatalf("expected help bar width 48, got %d (%q)", got, stripANSI(bar)) + } + if !strings.Contains(stripANSI(bar), "esc: back • H: home") { + t.Fatalf("expected help bar text, got %q", stripANSI(bar)) + } +} + +func TestRenderListPanelUsesRoundedBorder(t *testing.T) { + m := New(testConfig(), "", "dev") + m.width = 80 + + panel := stripANSI(m.renderListPanel("row 1\nrow 2")) + lines := strings.Split(panel, "\n") + if len(lines) < 3 { + t.Fatalf("expected bordered panel output, got %q", panel) + } + if !strings.HasPrefix(lines[0], "╭") || !strings.HasPrefix(lines[len(lines)-1], "╰") { + t.Fatalf("expected rounded border, got %q", panel) + } + if !strings.Contains(panel, "row 1") || !strings.Contains(panel, "row 2") { + t.Fatalf("expected panel body content, got %q", panel) + } + if !strings.Contains(panel, "│ row 1") { + t.Fatalf("expected list panel to add inner padding, got %q", panel) + } +} + +func TestRenderDetailLineUsesStandardWidth(t *testing.T) { + got := stripANSI(renderDetailLine("Name", "value")) + if got != " Name value" { + t.Fatalf("expected standardized detail line spacing, got %q", got) + } +} + +func TestContextPickerUsesBorderedPanelAndHelpBar(t *testing.T) { + m := New(testConfig(), "", "dev") + m.width = 80 + m.height = 20 + updated, _ := m.Update(contextsLoadedMsg{contexts: styleTestContexts()}) + m = updated.(Model) + + view := stripANSI(m.viewContextPicker()) + for _, want := range []string{"╭", "╰", "NAME", "AUTH TYPE", "prod", "↑/↓: navigate • /: filter • enter: select • a: add • q: quit"} { + if !strings.Contains(view, want) { + t.Fatalf("expected context picker view to contain %q, got %q", want, view) + } + } +} + +func TestContextPickerKeepsCurrentColumnVisible(t *testing.T) { + m := New(testConfig(), "", "dev") + m.width = 60 + m.height = 20 + + updated, _ := m.Update(contextsLoadedMsg{contexts: styleTestContexts()}) + m = updated.(Model) + + view := stripANSI(m.viewContextPicker()) + for _, want := range []string{"AUTH TYPE", "CURRENT", "*"} { + if !strings.Contains(view, want) { + t.Fatalf("expected context picker to keep %q visible at width 60, got %q", want, view) + } + } +} + +func TestContextPickerPanelDoesNotOverflowHelpBar(t *testing.T) { + m := New(testConfig(), "", "dev") + m.width = 80 + m.height = 20 + + updated, _ := m.Update(contextsLoadedMsg{contexts: styleTestContexts()}) + m = updated.(Model) + view := stripANSI(m.View()) + lines := strings.Split(view, "\n") + + if len(lines) != 20 { + t.Fatalf("expected fitted view height 20, got %d lines", len(lines)) + } + + borderLine := -1 + helpLine := -1 + for i, line := range lines { + if strings.Contains(line, "╰") { + borderLine = i + } + if strings.Contains(line, "q: quit") { + helpLine = i + } + } + + if borderLine == -1 || helpLine == -1 { + t.Fatalf("expected both bottom border and help bar, got %q", view) + } + if helpLine <= borderLine { + t.Fatalf("expected help bar below panel border, got border line %d help line %d in %q", borderLine, helpLine, view) + } +}